feat(desktop): add Codex-style review pane

This commit is contained in:
Brooklyn Nicholson 2026-06-25 16:40:27 -05:00
parent 7a7f9a5b3d
commit 68680db10d
15 changed files with 2240 additions and 148 deletions

View file

@ -0,0 +1,202 @@
import { useStore } from '@nanostores/react'
import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useRef, useState } from 'react'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { translateNow, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { IS_MAC } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import {
$fileActionDialog,
beginInlineRename,
cancelInlineRename,
closeFileActionDialog,
copyFilePath,
executeFileDelete,
executeFileRename,
type FileActionTarget,
requestFileDelete,
revealFile,
toRelativePath
} from '@/store/file-actions'
import { notifyError } from '@/store/notifications'
const IS_WIN = typeof navigator !== 'undefined' && /win/i.test(navigator.platform || navigator.userAgent || '')
// F2 starts a rename anywhere; Enter starts one when a row is focused (VS Code).
export function isRenameShortcut(event: KeyboardEvent | ReactKeyboardEvent): boolean {
return event.key === 'F2' || event.key === 'Enter'
}
/** The platform-appropriate "reveal in file manager" label (Finder / Explorer
* / containing folder). Shared so every file menu reads consistently. */
export function pickRevealLabel(finder: string, explorer: string, fileManager: string): string {
return IS_MAC ? finder : IS_WIN ? explorer : fileManager
}
interface FileEntryContextMenuProps {
children: ReactNode
isDirectory: boolean
/** Display name (basename). */
name: string
/** Absolute path on disk. */
path: string
/** Base dir for "Copy Relative Path" (the cwd / repo root). Omit to hide it. */
relativeTo?: null | string
}
/** Right-click menu shared by both file trees (browser + review/git). */
export function FileEntryContextMenu({ children, isDirectory, name, path, relativeTo }: FileEntryContextMenuProps) {
const { t } = useI18n()
const m = t.fileMenu
// Reveal / rename / delete need the local filesystem; hide them on a remote
// backend (copy-path still works everywhere).
const localFs = !isDesktopFsRemoteMode()
const target: FileActionTarget = { isDirectory, name, path }
const revealLabel = pickRevealLabel(m.revealFinder, m.revealExplorer, m.revealFileManager)
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
{/* Don't restore focus to the row on close: "Rename" mounts an autofocused
inline input, and the default focus-return would blur it immediately. */}
<ContextMenuContent onCloseAutoFocus={event => event.preventDefault()}>
{localFs && (
<>
<ContextMenuItem onSelect={() => void revealFile(path)}>{revealLabel}</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onSelect={() => void copyFilePath(path)}>{m.copyPath}</ContextMenuItem>
{relativeTo && (
<ContextMenuItem onSelect={() => void copyFilePath(toRelativePath(path, relativeTo))}>
{m.copyRelativePath}
</ContextMenuItem>
)}
{localFs && (
<>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => beginInlineRename(path)}>{m.rename}</ContextMenuItem>
<ContextMenuItem onSelect={() => requestFileDelete(target)} variant="destructive">
{m.delete}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
)
}
/** Mounted once near the app root: the delete confirm dialog for shared file
* actions. Rename is inline (see {@link InlineRenameInput}). */
export function FileActionDialogs() {
const { t } = useI18n()
const dialog = useStore($fileActionDialog)
const deleting = dialog?.kind === 'delete'
return (
<ConfirmDialog
confirmLabel={t.fileMenu.delete}
description={t.fileMenu.deleteBody}
destructive
onClose={closeFileActionDialog}
onConfirm={() => {
if (deleting) {
return executeFileDelete(dialog.path)
}
}}
open={deleting}
title={deleting ? t.fileMenu.deleteTitle(dialog.name) : ''}
/>
)
}
interface InlineRenameInputProps {
className?: string
/** Display name (basename) to seed the editor. */
name: string
/** Absolute path being renamed. */
path: string
}
/** The in-row rename editor (VS Code style): seeded with the name (stem
* pre-selected), commits on Enter/blur, cancels on Esc. Render it in place of a
* row's label when `$renamingPath === path`. */
export function InlineRenameInput({ className, name, path }: InlineRenameInputProps) {
const [value, setValue] = useState(name)
// Enter then the resulting blur must not both commit; latch on first finish.
const done = useRef(false)
// Focus churn right after mount (context-menu close, arborist refocus, the
// fall-through click on the row) would blur→commit→cancel instantly; ignore
// blurs in this window and grab focus back instead.
const mountedAt = useRef(Date.now())
const finish = async (commit: boolean) => {
if (done.current) {
return
}
done.current = true
const next = value.trim()
if (commit && next && next !== name) {
try {
await executeFileRename(path, next)
} catch (error) {
notifyError(error, translateNow('errors.genericFailure'))
}
}
cancelInlineRename()
}
return (
<input
aria-label={translateNow('fileMenu.renameLabel')}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
autoFocus
className={cn(
'min-w-0 flex-1 rounded-sm border border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-(--ui-bg-elevated) px-1 py-0 text-xs text-foreground outline-none',
className
)}
onBlur={event => {
if (Date.now() - mountedAt.current < 250) {
event.currentTarget.focus()
return
}
void finish(true)
}}
onChange={event => setValue(event.target.value)}
onClick={event => event.stopPropagation()}
onDoubleClick={event => event.stopPropagation()}
onFocus={event => {
const dot = event.currentTarget.value.lastIndexOf('.')
event.currentTarget.setSelectionRange(0, dot > 0 ? dot : event.currentTarget.value.length)
}}
onKeyDown={event => {
event.stopPropagation()
if (event.key === 'Enter') {
event.preventDefault()
void finish(true)
} else if (event.key === 'Escape') {
event.preventDefault()
void finish(false)
}
}}
spellCheck={false}
value={value}
/>
)
}

View file

@ -1,6 +1,7 @@
import ignore from 'ignore'
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import { ALWAYS_EXCLUDED } from '@/lib/excluded-paths'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry
@ -68,7 +69,7 @@ async function gitRootFor(start: string) {
let cached = gitRootCache.get(key)
if (!cached) {
cached = desktopGitRoot(start)
cached = desktopGitRoot(clean(start))
gitRootCache.set(key, cached)
}
@ -136,7 +137,7 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
}
const result = await readDesktopDir(dirPath)
const entries = result?.entries ?? []
const entries = (result?.entries ?? []).filter(entry => !ALWAYS_EXCLUDED.has(entry.name))
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
}

View file

@ -1,19 +1,36 @@
import { useCallback, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { useStore } from '@nanostores/react'
import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, type RowRendererProps, Tree, type TreeApi } from 'react-arborist'
import { PageLoader } from '@/components/page-loader'
import { TreeSkeleton } from '@/components/chat/skeletons'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $repoChangeByPath, type RepoChangeKind } from '@/store/coding-status'
import { $renamingPath, beginInlineRename } from '@/store/file-actions'
import { $revealInTreeRequest } from '@/store/layout'
import { FileEntryContextMenu, InlineRenameInput, isRenameShortcut } from '../file-actions'
import { getFileTreeDndManager } from './dnd-manager'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 22
const INDENT = 10
/** Base inset for every row; react-arborist owns paddingLeft for depth indent. */
const TREE_ROW_INSET = 12
/** Fixed base inset (`px-6.5`) layered on top of arborist's depth indent. */
const TREE_ROW_INSET = '17px'
function withTreeInset(paddingLeft: number | string | undefined): string {
if (typeof paddingLeft === 'number') {
return `calc(${paddingLeft}px + ${TREE_ROW_INSET})`
}
if (!paddingLeft) {
return TREE_ROW_INSET
}
return `calc(${paddingLeft} + ${TREE_ROW_INSET})`
}
interface ProjectTreeProps {
collapseNonce: number
@ -41,6 +58,7 @@ export function ProjectTree({
const containerRef = useRef<HTMLDivElement | null>(null)
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [size, setSize] = useState({ height: 0, width: 0 })
const changeByPath = useStore($repoChangeByPath)
const syncTreeSize = useCallback(() => {
const el = containerRef.current
@ -79,17 +97,85 @@ export function ProjectTree({
[onLoadChildren, onNodeOpenChange]
)
// "Reveal in side bar": expand each ancestor folder top-down (lazy-loading its
// children first so the node exists), then select + scroll to the target. The
// pane is opened by the caller; this drives the tree to the file.
const revealNode = useCallback(
async (absPath: string) => {
const root = cwd.replace(/[\\/]+$/, '')
const target = absPath.replace(/[\\/]+$/, '')
const rel = target.startsWith(root) ? target.slice(root.length).replace(/^[\\/]+/, '') : ''
const segments = rel.split(/[\\/]/).filter(Boolean)
let acc = root
for (let i = 0; i < segments.length - 1; i += 1) {
acc = `${acc}/${segments[i]}`
const node = treeRef.current?.get(acc)
if (node?.data?.isDirectory && node.data.children === undefined) {
await onLoadChildren(acc)
}
onNodeOpenChange(acc, true)
treeRef.current?.open(acc)
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)))
}
treeRef.current?.select(target)
// 'start' lands the file at/near the top (instant — arborist sets scrollTop
// directly, no smooth scroll).
treeRef.current?.scrollTo(target, 'start')
},
[cwd, onLoadChildren, onNodeOpenChange]
)
useEffect(
() =>
$revealInTreeRequest.subscribe(path => {
if (!path) {
return
}
$revealInTreeRequest.set(null)
void revealNode(path)
}),
[revealNode]
)
const handleActivate = useCallback(
(node: NodeApi<TreeNode>) => {
if (node.data && !node.data.isDirectory) {
// arborist fires onActivate on click/dblclick/Enter — independent of the
// row's own handlers. Suppress it for the row being renamed so the
// context-menu "Rename" (and its fall-through) can't open the preview.
if (node.data && !node.data.isDirectory && $renamingPath.get() !== node.data.id) {
onPreviewFile?.(node.data.id)
}
},
[onPreviewFile]
)
// F2 / Enter on the selected row begins an inline rename. Capture-phase so it
// beats arborist's own Enter-to-activate; skipped while an edit is in progress
// (the editor input owns Enter/Esc then) and for placeholder rows.
const handleRenameShortcut = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!isRenameShortcut(event) || $renamingPath.get()) {
return
}
const node = treeRef.current?.selectedNodes?.[0]
if (!node?.data || node.data.placeholder) {
return
}
event.preventDefault()
event.stopPropagation()
beginInlineRename(node.data.id)
}, [])
return (
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
<div className="min-h-0 flex-1 overflow-hidden" onKeyDownCapture={handleRenameShortcut} ref={containerRef}>
{size.height > 0 && size.width > 0 ? (
<Tree<TreeNode>
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
@ -107,15 +193,18 @@ export function ProjectTree({
openByDefault={false}
padding={0}
ref={treeRef}
renderRow={ProjectTreeRowContainer}
rowHeight={ROW_HEIGHT}
width={size.width}
>
{props => (
<ProjectTreeRow
{...props}
changeKind={props.node.data ? changeByPath.get(props.node.data.id) : undefined}
onAttachFile={onActivateFile}
onAttachFolder={onActivateFolder}
onPreviewFile={onPreviewFile}
relativeTo={cwd}
/>
)}
</Tree>
@ -127,23 +216,51 @@ export function ProjectTree({
}
function TreeSizingState() {
const { t } = useI18n()
return <TreeSkeleton />
}
return <PageLoader aria-label={t.rightSidebar.loadingFiles} className="min-h-24 px-3" />
// arborist's default row hardcodes `min-width: max-content` (so a highlight can
// span horizontally-scrolled content), which grows the row to its full name
// width and defeats the inner `truncate`. We don't scroll sideways — pin the row
// to the viewport so long names ellipsize instead of clipping at the pane edge.
function ProjectTreeRowContainer({ attrs, children, innerRef, node }: RowRendererProps<TreeNode>) {
return (
<div
{...attrs}
onClick={node.handleClick}
onFocus={e => e.stopPropagation()}
ref={innerRef}
style={{ ...attrs.style, minWidth: 0, width: '100%' }}
>
{children}
</div>
)
}
const CHANGE_TINT: Record<RepoChangeKind, string> = {
added: 'text-(--ui-green)',
conflicted: 'text-(--ui-red)',
modified: 'text-(--ui-yellow)'
}
function ProjectTreeRow({
changeKind,
dragHandle,
node,
onAttachFile,
onAttachFolder,
onPreviewFile,
relativeTo,
style
}: NodeRendererProps<TreeNode> & {
changeKind?: RepoChangeKind
onAttachFile: (path: string) => void
onAttachFolder: (path: string) => void
onPreviewFile?: (path: string) => void
relativeTo?: null | string
}) {
const renamingPath = useStore($renamingPath)
if (!node.data) {
return <div style={style} />
}
@ -151,21 +268,25 @@ function ProjectTreeRow({
const isFolder = node.data.isDirectory
const isPlaceholder = Boolean(node.data.placeholder)
const isErrorPlaceholder = node.data.placeholder === 'error'
const editing = !isPlaceholder && renamingPath === node.data.id
return (
const row = (
<div
aria-expanded={isFolder ? node.isOpen : undefined}
aria-selected={node.isSelected}
className={cn(
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--ui-row-hover-background) hover:text-foreground',
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
node.isSelected && 'bg-(--ui-row-active-background) text-foreground',
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
)}
draggable={!isPlaceholder}
draggable={!isPlaceholder && !editing}
onClick={event => {
event.stopPropagation()
if (isPlaceholder) {
// Read the rename atom LIVE (not the render closure): the fall-through
// click from a context-menu close can fire before the editing re-render
// commits, so a stale closure would still select/activate and yank focus.
if (isPlaceholder || $renamingPath.get() === node.data.id) {
return
}
@ -184,12 +305,12 @@ function ProjectTreeRow({
onDoubleClick={event => {
event.stopPropagation()
if (!isFolder && !isPlaceholder) {
if (!isFolder && !isPlaceholder && $renamingPath.get() !== node.data.id) {
onPreviewFile?.(node.data.id)
}
}}
onDragStart={event => {
if (isPlaceholder) {
if (isPlaceholder || $renamingPath.get() === node.data.id) {
event.preventDefault()
return
@ -204,11 +325,9 @@ function ProjectTreeRow({
ref={dragHandle}
style={{
...style,
paddingLeft:
(typeof style.paddingLeft === 'number'
? style.paddingLeft
: Number.parseFloat(String(style.paddingLeft ?? 0)) || 0) + TREE_ROW_INSET
paddingLeft: withTreeInset(style.paddingLeft)
}}
title={node.data.id}
>
{/* No chevron column the folder icon (open/closed) already carries the
expand state, so the extra glyph was pure noise. */}
@ -223,7 +342,23 @@ function ProjectTreeRow({
<Codicon name="file" size="0.875rem" />
)}
</span>
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>
{editing ? (
<InlineRenameInput name={node.data.name} path={node.data.id} />
) : (
// Git decoration (VS Code-style): tint changed files; the explicit color
// wins over the row's hover/selected text color, so it persists.
<span className={cn('min-w-0 flex-1 truncate', changeKind && CHANGE_TINT[changeKind])}>{node.data.name}</span>
)}
</div>
)
if (isPlaceholder) {
return row
}
return (
<FileEntryContextMenu isDirectory={isFolder} name={node.data.name} path={node.data.id} relativeTo={relativeTo}>
{row}
</FileEntryContextMenu>
)
}

View file

@ -3,6 +3,7 @@ import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { $connection } from '@/store/session'
import { $workspaceChangeTick } from '@/store/workspace-events'
import { clearProjectDirCache, readProjectDir } from './ipc'
@ -219,6 +220,52 @@ export function resetProjectTreeState() {
clearProjectDirCache()
}
// Non-destructive refresh: re-read every currently-loaded directory and merge
// entries (add new files/folders, drop deleted ones) while preserving expansion
// and already-loaded subtrees. Unlike `loadRoot({force})` this never collapses
// the tree, so it's safe to run live as the agent edits — and because node ids
// (absolute paths) stay stable across merges, rows can animate in/out.
async function revalidateTree(cwd: string): Promise<void> {
const state = $projectTree.get()
if (!cwd || state.cwd !== cwd || !state.loaded) {
return
}
const rootPath = state.resolvedCwd || cwd
clearProjectDirCache()
const reconcile = async (dirPath: string, existing: TreeNode[]): Promise<TreeNode[]> => {
const { entries, error } = await readProjectDir(dirPath, rootPath)
if (error) {
return existing // keep the last-known children on a transient read error
}
const byId = new Map(existing.filter(node => !node.placeholder).map(node => [node.id, node]))
const merged: TreeNode[] = []
for (const entry of entries) {
const prev = byId.get(entry.path)
if (prev?.isDirectory && prev.children) {
// Loaded folder: recurse so deep edits surface without a re-expand.
merged.push({ ...prev, children: await reconcile(prev.id, prev.children) })
} else if (prev) {
merged.push(prev)
} else {
merged.push(makeNode(entry.path, entry.name, entry.isDirectory))
}
}
return merged
}
const nextData = await reconcile(rootPath, state.data)
setProjectTree(latest => (latest.cwd === cwd && latest.loaded ? { ...latest, data: nextData } : latest))
}
/**
* Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first
* expand and cached in this feature-owned atom so unrelated chat rerenders or
@ -229,6 +276,7 @@ export function resetProjectTreeState() {
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const connection = useStore($connection)
const workspaceTick = useStore($workspaceChangeTick)
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
@ -308,6 +356,14 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
[cwd]
)
// Live, non-destructive refresh when the agent touches the tree (skip the
// very first render: tick 0 is the initial value, not a real change).
useEffect(() => {
if (workspaceTick > 0) {
void revalidateTree(cwd)
}
}, [workspaceTick, cwd])
useEffect(() => {
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
lastConnectionKey = connectionKey

View file

@ -9,32 +9,17 @@ import { resetProjectTreeState } from './files/use-project-tree'
import { RightSidebarPane } from './index'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const selectPaths = vi.fn()
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
readDir: typeof readDir
selectPaths: typeof selectPaths
}
}
).hermesDesktop = { readDir, selectPaths }
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
}
describe('RightSidebarPane', () => {
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
setCurrentCwd('/repo')
readDir.mockReset()
selectPaths.mockReset()
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
selectPaths.mockResolvedValue(['/repo-next'])
readDir.mockResolvedValue({ entries: [{ isDirectory: false, name: 'README.md', path: '/repo/README.md' }] })
installBridge()
})
@ -46,30 +31,27 @@ describe('RightSidebarPane', () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('refreshes the current tree without opening the folder picker', async () => {
const onChangeCwd = vi.fn()
it('renders the tree whenever the session has a working dir (repo or not) — no picker', async () => {
setCurrentCwd('/repo')
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
const refresh = await screen.findByRole('button', { name: 'Refresh tree' })
readDir.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
fireEvent.click(refresh)
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
expect(selectPaths).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
// The freeform folder picker is retired.
expect(screen.queryByRole('button', { name: 'Open folder' })).toBeNull()
})
await waitFor(() =>
expect(selectPaths).toHaveBeenCalledWith({
defaultPath: '/repo',
directories: true,
multiple: false,
title: 'Change working directory'
})
)
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
it('shows no tree for a detached chat (no working dir)', async () => {
setCurrentCwd('')
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} />)
await waitFor(() => expect(screen.queryByRole('button', { name: 'Refresh tree' })).toBeNull())
expect(readDir).not.toHaveBeenCalled()
})
})

View file

@ -1,13 +1,12 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import type { ComponentProps } from 'react'
import { TreeSkeleton } from '@/components/chat/skeletons'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useDelayedTrue } from '@/hooks/use-delayed-true'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@ -24,15 +23,19 @@ import { useProjectTree } from './files/use-project-tree'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
onChangeCwd: (path: string) => Promise<void> | void
}
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
export function RightSidebarPane({ onActivateFile, onActivateFolder }: RightSidebarPaneProps) {
const { t } = useI18n()
const r = t.rightSidebar
const panesFlipped = useStore($panesFlipped)
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
// The file tree is simply "browse the session's working directory". If the
// session has a cwd — a repo, a sibling worktree, or any folder — show it. A
// bare/detached chat (resolveNewSessionCwd → '') has none, so it shows the
// empty hint instead of whatever dir Hermes happens to run from.
const hasWorkspace = Boolean(currentCwd)
const {
collapseAll,
@ -45,30 +48,16 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
rootError,
rootLoading,
setNodeOpen
} = useProjectTree(currentCwd)
} = useProjectTree(hasWorkspace ? currentCwd : '')
const cwdName = hasCwd
? (effectiveCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? effectiveCwd)
: r.noFolderSelected
const cwdName =
effectiveCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? effectiveCwd
const canCollapse = Object.values(openState).some(Boolean)
const chooseFolder = async () => {
const selected = await selectDesktopPaths({
defaultPath: hasCwd ? effectiveCwd : undefined,
directories: true,
multiple: false,
title: r.changeCwdTitle
})
if (selected?.[0]) {
await onChangeCwd(selected[0])
}
}
const previewFile = async (path: string) => {
try {
const preview = await normalizeOrLocalPreviewTarget(path, effectiveCwd || undefined)
@ -102,11 +91,10 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
hasWorkspace={hasWorkspace}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
@ -121,8 +109,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
interface FilesystemTabProps extends FileTreeBodyProps {
canCollapse: boolean
cwdName: string
hasCwd: boolean
onChangeFolder: () => Promise<void> | void
hasWorkspace: boolean
onCollapseAll: () => void
onRefresh: () => void
}
@ -141,11 +128,10 @@ function FilesystemTab({
cwdName,
data,
error,
hasCwd,
hasWorkspace,
loading,
onActivateFile,
onActivateFolder,
onChangeFolder,
onCollapseAll,
onLoadChildren,
onNodeOpenChange,
@ -156,53 +142,40 @@ function FilesystemTab({
const { t } = useI18n()
const r = t.rightSidebar
// No working directory (a bare/detached chat) → no tree, just a terse hint.
// Switching workspace is a project/worktree action, never a raw folder picker.
if (!hasWorkspace) {
return <PaneEmptyState label={r.noProjectOpen} />
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<div className="flex min-w-0 flex-1">
<button
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</div>
<Tip label={r.refreshTree} side="left">
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_LABEL_REVEAL}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</Tip>
<Tip label={r.openFolder} side="left">
<Button
aria-label={r.openFolder}
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon-xs"
variant="ghost"
>
<Codicon name="folder-opened" size="0.8125rem" />
</Button>
</Tip>
<Tip label={r.collapseAll} side="left">
<Button
aria-label={r.collapseAll}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
</Tip>
<Button
aria-label={r.refreshTree}
className={HEADER_ACTION_LABEL_REVEAL}
disabled={loading}
onClick={onRefresh}
size="icon-xs"
title={r.refreshTree}
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
<Button
aria-label={r.collapseAll}
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
disabled={!canCollapse}
onClick={onCollapseAll}
size="icon-xs"
title={r.collapseAll}
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}
@ -222,8 +195,12 @@ function FilesystemTab({
)
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
export function RightSidebarSectionHeader({ children, className, ...props }: ComponentProps<'div'>) {
return (
<div className={cn('group/project-header flex h-7 shrink-0 items-center px-2.5', className)} {...props}>
{children}
</div>
)
}
interface FileTreeBodyProps {
@ -259,6 +236,9 @@ function FileTreeBody({
}: FileTreeBodyProps) {
const { t } = useI18n()
const r = t.rightSidebar
// Stay blank for a beat, then skeleton — so a fast project switch doesn't
// flash a jarring loading state.
const showSkeleton = useDelayedTrue(loading && data.length === 0)
if (!cwd) {
return <EmptyState body={r.noProjectBody} title={r.noProjectTitle} />
@ -282,7 +262,7 @@ function FileTreeBody({
}
if (loading && data.length === 0) {
return <FileTreeLoadingState />
return showSkeleton ? <FileTreeLoadingState /> : <div className="min-h-0 flex-1" />
}
if (data.length === 0) {
@ -325,23 +305,30 @@ function FileTreeLoadingState() {
const { t } = useI18n()
return (
<div aria-label={t.rightSidebar.loadingTree} className="grid min-h-0 flex-1 place-items-center px-3" role="status">
<Loader
aria-hidden="true"
className="size-8 text-(--ui-text-tertiary)"
pathSteps={180}
role="presentation"
strokeScale={0.68}
type="spiral-search"
/>
<div aria-label={t.rightSidebar.loadingTree} className="min-h-0 flex-1" role="status">
<TreeSkeleton />
</div>
)
}
function EmptyState({ body, title }: { body: string; title: string }) {
// Terse pane empty state ("No files" / "No diffs"): the panel label itself —
// same uppercase/tracking + dither dot — just muted instead of theme-primary,
// centered. Shared by the file tree and review panes so both read identically.
export function PaneEmptyState({ label }: { label: string }) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-4">
<SidebarPanelLabel className="pl-0 text-(--ui-text-quaternary)">{label}</SidebarPanelLabel>
</div>
)
}
// Richer empty/error state (title + body) for the file tree's read failures.
export function EmptyState({ body, title }: { body: string; title?: string }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
{title && (
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
)}
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
</div>
)

View file

@ -0,0 +1,59 @@
import { useStore } from '@nanostores/react'
import { useMemo } from 'react'
import type { HermesReviewFile } from '@/global'
import { $reviewMaxChurn } from '@/store/review'
// Per-row "digital rain" churn bar: a right-anchored, clipped stream of
// Matrix-ish glyphs whose width is the file's churn relative to the biggest
// changed file. Not wired in — drop `<ChurnBar file={file} />` into a review row
// (which must be `relative isolate overflow-hidden`) to revive it.
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテナニヌノハヒフヘホマミムメモヤユヨラリレワ0123456789:=*+<>¦'
const MASK = 'linear-gradient(to left, #000 45%, transparent)'
// Deterministic glyph run (FNV-1a seed → xorshift) so a file's rain is stable
// across renders instead of reshuffling every paint.
function rain(seed: string, len: number): string {
let h = 2166136261
for (let i = 0; i < seed.length; i++) {
h = Math.imul(h ^ seed.charCodeAt(i), 16777619)
}
let out = ''
for (let i = 0; i < len; i++) {
h ^= h << 13
h ^= h >>> 17
h ^= h << 5
out += GLYPHS[Math.abs(h) % GLYPHS.length]
}
return out
}
export function ChurnBar({ file }: { file: HermesReviewFile }) {
const max = useStore($reviewMaxChurn)
const fill = useMemo(() => rain(file.path, 200), [file.path])
const width = max > 0 ? ((file.added + file.removed) / max) * 100 : 0
if (width <= 0) {
return null
}
return (
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 -z-10 block overflow-hidden text-right font-mono text-[0.7rem] leading-6 tracking-tight whitespace-nowrap opacity-30 dark:opacity-40"
style={{
WebkitMaskImage: MASK,
color: `var(--ui-${file.added >= file.removed ? 'green' : 'red'})`,
maskImage: MASK,
width: `${width}%`
}}
>
{fill}
</span>
)
}

View file

@ -0,0 +1,443 @@
import { useStore } from '@nanostores/react'
import { AnimatePresence, motion } from 'motion/react'
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { DiffCount } from '@/components/ui/diff-count'
import { Tip } from '@/components/ui/tooltip'
import type { HermesReviewFile } from '@/global'
import { useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $renamingPath, copyFilePath, revealFile, toRelativePath } from '@/store/file-actions'
import { $sidebarWorkspaceCollapsedIds, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import {
$reviewFiles,
$reviewLoading,
$reviewOpen,
$reviewSelectedPath,
$reviewTreeMode,
requestRevert,
selectReviewFile,
stageReviewFile,
unstageReviewFile
} from '@/store/review'
import { $currentCwd } from '@/store/session'
import { pickRevealLabel } from '../file-actions'
import { buildReviewFlatList, buildReviewTree, type ReviewTreeNode } from './tree-data'
const INDENT = 12
// Per git status letter: a tinted diff codicon so the file's nature reads at a
// glance (added / modified / deleted / renamed / untracked).
const STATUS_GLYPH: Record<string, { icon: string; tone: string }> = {
A: { icon: 'diff-added', tone: 'text-(--ui-green)' },
C: { icon: 'diff-added', tone: 'text-(--ui-green)' },
D: { icon: 'diff-removed', tone: 'text-(--ui-red)' },
M: { icon: 'diff-modified', tone: 'text-amber-500/85' },
R: { icon: 'diff-renamed', tone: 'text-sky-500/85' },
U: { icon: 'warning', tone: 'text-(--ui-red)' },
'?': { icon: 'diff-added', tone: 'text-muted-foreground/60' }
}
// Review paths are repo-relative; the composer drop expects absolute paths, so
// join against the active session cwd (the repo we probed).
function absolutePath(relative: string): string {
if (/^([a-zA-Z]:[\\/]|\/)/.test(relative)) {
return relative
}
const cwd = $currentCwd
.get()
?.trim()
.replace(/[\\/]+$/, '')
return cwd ? `${cwd}/${relative}` : relative
}
// Fast, layout-aware row: `layout` slides siblings when one is inserted/removed
// (a new file at index N pushes the rest down), AnimatePresence fades the
// enter/exit. A tight, near-critically-damped spring keeps it crisp (quick
// settle, no bounce) so adds/deletes read as snappy, not floaty.
const ROW_TRANSITION = { type: 'spring', stiffness: 1100, damping: 48, mass: 0.32 } as const
// Instant (no animation) — used while the pane is settling open so the initial
// batch of rows doesn't fly in.
const ROW_INSTANT = { duration: 0 } as const
// Past this many changed files, drop the per-row motion (AnimatePresence +
// layout springs on every node is the heaviest cost) and lean on CSS
// content-visibility so off-screen rows skip layout/paint.
const HEAVY_LIST_CAP = 60
// Reserve a stable row height (h-6 = 1.5rem) so the scrollbar stays correct
// while off-screen rows are skipped.
const ROW_CV_STYLE: CSSProperties = { containIntrinsicSize: 'auto 1.5rem', contentVisibility: 'auto' }
export function ReviewFileTree() {
const files = useStore($reviewFiles)
const open = useStore($reviewOpen)
const loading = useStore($reviewLoading)
const mode = useStore($reviewTreeMode)
const tree = useMemo(
() => (mode === 'tree' ? buildReviewTree(files) : buildReviewFlatList(files)),
[files, mode]
)
const heavy = tree.length > HEAVY_LIST_CAP
// The Pane keeps this tree mounted while collapsed, so opening it doesn't
// remount (AnimatePresence `initial={false}` can't help). The first refresh
// after opening can also surface a batch of edits made while it was closed.
// Suppress row enter/exit until that first post-open refresh settles; real
// edits made while the pane stays open then animate normally.
const [animate, setAnimate] = useState(false)
const armed = useRef(false)
useEffect(() => {
if (!open) {
armed.current = false
setAnimate(false)
}
}, [open])
useEffect(() => {
if (open && !loading && !armed.current) {
armed.current = true
const id = requestAnimationFrame(() => setAnimate(true))
return () => cancelAnimationFrame(id)
}
}, [open, loading])
return (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 py-1" data-suppress-pane-reveal-side="">
<ReviewNodeList animate={animate && !heavy} depth={0} motion={!heavy} nodes={tree} />
</div>
)
}
function ReviewNodeList({
animate,
depth,
motion: useMotion,
nodes
}: {
animate: boolean
depth: number
motion: boolean
nodes: ReviewTreeNode[]
}) {
// Heavy lists: plain rows + content-visibility, no motion.
if (!useMotion) {
return (
<>
{nodes.map(node => (
<div key={node.id} style={ROW_CV_STYLE}>
{node.isDir ? (
<ReviewDirRow animate={false} depth={depth} motion={useMotion} node={node} />
) : (
<ReviewFileRow depth={depth} node={node} />
)}
</div>
))}
</>
)
}
return (
<AnimatePresence initial={false}>
{nodes.map(node => (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
initial={animate ? { opacity: 0, y: -4 } : false}
key={node.id}
layout="position"
transition={animate ? ROW_TRANSITION : ROW_INSTANT}
>
{node.isDir ? (
<ReviewDirRow animate={animate} depth={depth} motion={useMotion} node={node} />
) : (
<ReviewFileRow depth={depth} node={node} />
)}
</motion.div>
))}
</AnimatePresence>
)
}
// Depth-0 rows align their icon to the panel header's dither glyph: the tree
// body has px-1 (4px) and the header glyph sits at px-2.5 (10px) + the label's
// pl-2 (8px) = 18px, so the base inset is 18 4 = 14px.
const ROW_BASE_INSET = 14
function rowStyle(depth: number): CSSProperties {
return { paddingLeft: `${depth * INDENT + ROW_BASE_INSET}px` }
}
function ReviewDirRow({
animate,
depth,
motion: useMotion,
node
}: {
animate: boolean
depth: number
motion: boolean
node: ReviewTreeNode
}) {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const id = `review:${node.id}`
const open = !collapsed.includes(id)
const toggle = () => toggleWorkspaceNodeCollapsed(id)
return (
<>
<div
className="group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none"
onClick={toggle}
style={rowStyle(depth)}
>
<Codicon
className="shrink-0 text-(--ui-text-tertiary)"
name={open ? 'folder-opened' : 'folder'}
size="0.8rem"
/>
<span className="min-w-0 flex-1 truncate" title={node.name}>
{node.name}
</span>
</div>
{open && node.children && (
<ReviewNodeList animate={animate} depth={depth + 1} motion={useMotion} nodes={node.children} />
)}
</>
)
}
function ReviewFileRow({ node, depth }: { node: ReviewTreeNode; depth: number }) {
const { t } = useI18n()
const c = t.statusStack.coding
const selectedPath = useStore($reviewSelectedPath)
const file = node.file!
const selected = file.path === selectedPath
const glyph = STATUS_GLYPH[file.status] ?? STATUS_GLYPH.M
const dragPath = absolutePath(file.path)
const cwd = useStore($currentCwd)
// Single-click shows the inline diff; double-click opens the file in the main
// preview pane (matching the file browser). They're mutually exclusive: defer
// the single-click select briefly so a double-click can cancel it, otherwise a
// double-click would fire BOTH (inline diff + main preview = two previews).
const clickTimer = useRef<null | ReturnType<typeof setTimeout>>(null)
useEffect(
() => () => {
if (clickTimer.current != null) {
clearTimeout(clickTimer.current)
}
},
[]
)
const handleClick = () => {
// A file-browser rename of the same path is active → ignore the fall-through
// click so it doesn't open the diff / steal focus from that editor.
if ($renamingPath.get() === dragPath) {
return
}
if (clickTimer.current != null) {
clearTimeout(clickTimer.current)
}
clickTimer.current = setTimeout(() => {
clickTimer.current = null
void selectReviewFile(file)
}, 200)
}
const openInPreview = () => {
void (async () => {
try {
const preview = await normalizeOrLocalPreviewTarget(dragPath)
if (preview) {
setCurrentSessionPreviewTarget(preview, 'file-browser', dragPath)
}
} catch (error) {
notifyError(error, t.rightSidebar.previewUnavailable)
}
})()
}
const handleDoubleClick = () => {
if (clickTimer.current != null) {
clearTimeout(clickTimer.current)
clickTimer.current = null
}
openInPreview()
}
return (
<ReviewFileContextMenu
cwd={cwd}
dragPath={dragPath}
file={file}
onOpenChanges={() => void selectReviewFile(file)}
onOpenFile={openInPreview}
>
<div
aria-selected={selected}
className={cn(
'group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
selected && 'bg-(--ui-row-active-background) text-foreground'
)}
draggable
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onDragStart={event => {
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData(
'application/x-hermes-paths',
JSON.stringify([{ isDirectory: false, path: dragPath }])
)
event.dataTransfer.setData('text/plain', dragPath)
}}
style={rowStyle(depth)}
title={dragPath}
>
<Codicon className={cn('shrink-0', glyph.tone)} name={glyph.icon} size="0.8rem" />
{/* Dir collapses first (huge shrink); the name only ellipsizes once the
dir is gone either way neither runs into the diff count. */}
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="min-w-0 shrink truncate" title={node.name}>
{node.name}
</span>
{node.dir && (
<span className="min-w-0 shrink-[9999] truncate text-[0.68rem] text-(--ui-text-tertiary)" title={node.dir}>
{node.dir}
</span>
)}
</span>
<span className="hidden shrink-0 items-center gap-0.5 group-hover/review-row:flex">
<Tip label={file.staged ? c.unstage : c.stage}>
<Button
aria-label={file.staged ? c.unstage : c.stage}
className="size-4 rounded text-muted-foreground/70 hover:text-foreground"
onClick={event => {
event.stopPropagation()
void (file.staged ? unstageReviewFile(file.path) : stageReviewFile(file.path))
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={file.staged ? 'remove' : 'add'} size="0.7rem" />
</Button>
</Tip>
<Tip label={c.revert}>
<Button
aria-label={c.revert}
className="size-4 rounded text-muted-foreground/70 hover:text-(--ui-red)"
onClick={event => {
event.stopPropagation()
requestRevert(file.path)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="discard" size="0.7rem" />
</Button>
</Tip>
</span>
<DiffCount
added={node.added}
className="text-[0.64rem] leading-4 group-hover/review-row:hidden"
removed={node.removed}
/>
{file.staged && (
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-(--ui-green)/70" title={c.staged} />
)}
</div>
</ReviewFileContextMenu>
)
}
// Git-specific right-click menu for a changed file (VS Code's SCM menu shape):
// open changes / open file, stage·unstage, discard, then reveal / copy path. No
// rename or delete here — those belong to the file browser; this tree just
// reflects the working-tree state.
function ReviewFileContextMenu({
children,
cwd,
dragPath,
file,
onOpenChanges,
onOpenFile
}: {
children: ReactNode
cwd: null | string
dragPath: string
file: HermesReviewFile
onOpenChanges: () => void
onOpenFile: () => void
}) {
const { t } = useI18n()
const c = t.statusStack.coding
const m = t.fileMenu
const localFs = !isDesktopFsRemoteMode()
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onOpenChanges}>{c.openChanges}</ContextMenuItem>
<ContextMenuItem onSelect={onOpenFile}>{c.openFile}</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
void (file.staged ? unstageReviewFile(file.path) : stageReviewFile(file.path)).catch(err =>
notifyError(err, file.staged ? c.unstage : c.stage)
)
}
>
{file.staged ? c.unstage : c.stage}
</ContextMenuItem>
<ContextMenuItem onSelect={() => requestRevert(file.path)} variant="destructive">
{c.revert}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => revealFileInTree(dragPath)}>{m.revealInSidebar}</ContextMenuItem>
{localFs && (
<ContextMenuItem onSelect={() => void revealFile(dragPath)}>
{pickRevealLabel(m.revealFinder, m.revealExplorer, m.revealFileManager)}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => void copyFilePath(dragPath)}>{m.copyPath}</ContextMenuItem>
{cwd && (
<ContextMenuItem onSelect={() => void copyFilePath(toRelativePath(dragPath, cwd))}>
{m.copyRelativePath}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
)
}

View file

@ -0,0 +1,241 @@
import { useStore } from '@nanostores/react'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { DiffSkeleton, TreeSkeleton } from '@/components/chat/skeletons'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DiffCount } from '@/components/ui/diff-count'
import { Tip } from '@/components/ui/tooltip'
import { useDelayedTrue } from '@/hooks/use-delayed-true'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import {
$reviewDiff,
$reviewDiffLoading,
$reviewFiles,
$reviewIsRepo,
$reviewLoading,
$reviewRevertTarget,
$reviewSelectedPath,
$reviewTreeMode,
cancelRevert,
clearReviewSelection,
closeReview,
confirmRevert,
refreshReview,
requestRevert,
stageReviewFile,
toggleReviewTreeMode,
unstageReviewFile
} from '@/store/review'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { PaneEmptyState, RightSidebarSectionHeader } from '../index'
import { ReviewFileTree } from './file-tree'
import { ReviewShipBar } from './ship-bar'
// Compact header/diff action buttons — micro hit targets packed tight, matching
// the rest of the app's icon-action rows.
const ACTION_BTN = 'size-5'
export function ReviewPane() {
const { t } = useI18n()
const c = t.statusStack.coding
const panesFlipped = useStore($panesFlipped)
const files = useStore($reviewFiles)
const loading = useStore($reviewLoading)
const isRepo = useStore($reviewIsRepo)
const selectedPath = useStore($reviewSelectedPath)
const diff = useStore($reviewDiff)
const diffLoading = useStore($reviewDiffLoading)
const revertTarget = useStore($reviewRevertTarget)
const treeMode = useStore($reviewTreeMode)
const selectedFile = files.find(file => file.path === selectedPath)
const hasFiles = files.length > 0
// `{ path: null }` → revert all; `{ path: '…' }` → revert one file.
const revertingAll = revertTarget?.path == null
// Delay the skeletons so fast loads (most project switches) just blank → content
// instead of flashing a jarring loading state.
const showTreeSkeleton = useDelayedTrue(loading && !hasFiles)
const showDiffSkeleton = useDelayedTrue(diffLoading)
return (
<aside
aria-label={c.review}
className={cn(
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
panesFlipped
? 'border-r shadow-[inset_-0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
{(loading || isRepo) && (
<RightSidebarSectionHeader data-suppress-pane-reveal-side="">
<div className="flex min-w-0 flex-1">
<SidebarPanelLabel>{c.review}</SidebarPanelLabel>
</div>
<Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}>
<Button
aria-label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}
className={ACTION_BTN}
disabled={!hasFiles}
onClick={toggleReviewTreeMode}
size="icon-xs"
variant="ghost"
>
<Codicon name={treeMode === 'tree' ? 'list-flat' : 'list-tree'} size="0.8125rem" />
</Button>
</Tip>
<Tip label={c.stageAll}>
<Button
aria-label={c.stageAll}
className={ACTION_BTN}
disabled={!hasFiles}
onClick={() => void stageReviewFile(null).catch(err => notifyError(err, c.stageAll))}
size="icon-xs"
variant="ghost"
>
<Codicon name="add" size="0.8125rem" />
</Button>
</Tip>
<Tip label={c.revertAll}>
<Button
aria-label={c.revertAll}
className={ACTION_BTN}
disabled={!hasFiles}
onClick={() => requestRevert(null)}
size="icon-xs"
variant="ghost"
>
<Codicon name="discard" size="0.8125rem" />
</Button>
</Tip>
<Tip label={t.rightSidebar.refreshTree}>
<Button
aria-label={t.rightSidebar.refreshTree}
className={ACTION_BTN}
onClick={() => void refreshReview()}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</Tip>
<Tip label={c.close}>
<Button aria-label={c.close} className={ACTION_BTN} onClick={closeReview} size="icon-xs" variant="ghost">
<Codicon name="close" size="0.8125rem" />
</Button>
</Tip>
</RightSidebarSectionHeader>
)}
{loading || isRepo ? (
hasFiles ? (
<ReviewFileTree />
) : showTreeSkeleton ? (
<TreeSkeleton />
) : loading ? (
<div className="min-h-0 flex-1" />
) : (
<PaneEmptyState label={t.rightSidebar.noDiffs} />
)
) : (
// No repo at all → same terse empty state, just without the chrome.
<PaneEmptyState label={t.rightSidebar.noDiffs} />
)}
{/* Selected file's diff — reuses the shiki-highlighted FileDiffPanel. */}
{selectedFile && (
<div className="flex max-h-[55%] shrink-0 flex-col border-t border-(--ui-stroke-secondary)">
<div className="flex items-center gap-1 px-2.5 py-1.5" data-suppress-pane-reveal-side="">
<span
className="min-w-0 flex-1 truncate font-mono text-[0.66rem] text-(--ui-text-secondary)"
title={selectedFile.path}
>
{selectedFile.path}
</span>
<DiffCount added={selectedFile.added} className="text-[0.64rem] leading-4" removed={selectedFile.removed} />
<Tip label={selectedFile.staged ? c.unstage : c.stage}>
<Button
aria-label={selectedFile.staged ? c.unstage : c.stage}
className={ACTION_BTN}
onClick={() =>
void (
selectedFile.staged ? unstageReviewFile(selectedFile.path) : stageReviewFile(selectedFile.path)
).catch(err => notifyError(err, c.stage))
}
size="icon-xs"
variant="ghost"
>
<Codicon name={selectedFile.staged ? 'remove' : 'add'} size="0.8rem" />
</Button>
</Tip>
<Tip label={c.close}>
<Button
aria-label={c.close}
className={ACTION_BTN}
onClick={clearReviewSelection}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.8rem" />
</Button>
</Tip>
</div>
<div className="min-h-0 flex-1 overflow-auto px-1 pb-1">
{diffLoading ? (
showDiffSkeleton ? (
<DiffSkeleton />
) : null
) : diff ? (
<FileDiffPanel diff={diff} path={selectedFile.path} />
) : (
<div className="py-6 text-center text-[0.66rem] text-muted-foreground/60">{c.noDiff}</div>
)}
</div>
</div>
)}
<ReviewShipBar />
<Dialog onOpenChange={open => !open && cancelRevert()} open={revertTarget !== undefined}>
<DialogContent>
<DialogHeader>
<DialogTitle>{revertingAll ? c.revertAll : c.revert}</DialogTitle>
<DialogDescription>
{revertingAll ? c.revertAllConfirm : c.revertConfirm}
{!revertingAll && revertTarget?.path && (
<span
className="mt-2 block truncate font-mono text-[0.7rem] text-(--ui-text-secondary)"
title={revertTarget.path}
>
{revertTarget.path}
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={cancelRevert} variant="ghost">
{t.common.cancel}
</Button>
<Button onClick={() => void confirmRevert().catch(err => notifyError(err, c.revert))} variant="destructive">
{revertingAll ? c.revertAll : c.revert}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</aside>
)
}

View file

@ -0,0 +1,154 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { requestComposerSubmit } from '@/app/chat/composer/focus'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { GenerateButton } from '@/components/ui/generate-button'
import { SplitButton } from '@/components/ui/split-button'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import {
$reviewCommitDefault,
$reviewCommitMsgBusy,
$reviewFiles,
$reviewShipBusy,
$reviewShipInfo,
cancelCommitMessage,
type CommitAction,
commitChanges,
createOrOpenPr,
generateCommitMessage
} from '@/store/review'
// One size for every glyph in the bar so the row reads as a set of peers.
const ICON = '0.85rem'
// The commit / push / PR action bar at the bottom of the review pane. Supports
// both paths: the user drives it directly, OR hands the whole thing to the agent
// with one click (requestComposerSubmit sends it a task through the composer).
export function ReviewShipBar() {
const { t } = useI18n()
const c = t.statusStack.coding
const files = useStore($reviewFiles)
const ship = useStore($reviewShipInfo)
const busy = useStore($reviewShipBusy)
const generating = useStore($reviewCommitMsgBusy)
const commitDefault = useStore($reviewCommitDefault)
const [message, setMessage] = useState('')
const prLabel = ship.pr?.url ? c.openPr : c.createPr
const hasFiles = files.length > 0
const canCommit = hasFiles && message.trim().length > 0 && !busy
const canGenerate = hasFiles && !generating && !busy
// Nothing to commit → no ship bar at all; the pane just shows the tree /
// "No changes" state.
if (!hasFiles) {
return null
}
const runCommit = (action: CommitAction) => {
if (!canCommit) {
return
}
void commitChanges(message, { push: action === 'commitPush' })
.then(() => setMessage(''))
.catch(err => notifyError(err, c.commit))
}
// Draft the commit message off-thread (VS Code style); pass the current text
// so a re-press regenerates instead of returning the same thing.
const runGenerate = () => {
if (!canGenerate) {
return
}
void generateCommitMessage(message)
.then(text => text && setMessage(text))
.catch(err => notifyError(err, c.generateCommitMessage))
}
return (
<div className="flex shrink-0 flex-col gap-1.5 p-2" data-suppress-pane-reveal-side="">
{/* Auto-growing message field (CSS field-sizing); generate/stop action
fills the right edge on one row, then sticks to the top as it grows. */}
<div className="relative">
<Textarea
className="field-sizing-content max-h-40 min-h-0 resize-none pr-9"
disabled={generating}
onChange={event => setMessage(event.target.value)}
onKeyDown={event => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault()
runCommit(commitDefault)
}
}}
placeholder={c.commitPlaceholder}
rows={1}
size="sm"
value={message}
/>
<GenerateButton
className="absolute top-px right-px h-6 w-8 rounded-l-none rounded-r-[2px]"
disabled={!canGenerate}
generating={generating}
generatingLabel={c.stopGenerating}
iconSize={ICON}
label={c.generateCommitMessage}
onCancel={cancelCommitMessage}
onGenerate={runGenerate}
/>
</div>
{/* Commit split (VS Code style). */}
<div className="flex min-w-0">
<SplitButton
actions={[
{ id: 'commit', label: c.commit },
{ id: 'commitPush', label: c.commitAndPush }
]}
className="min-w-0 flex-1"
disabled={!canCommit}
onTrigger={id => runCommit(id as CommitAction)}
onValueChange={id => $reviewCommitDefault.set(id as CommitAction)}
primaryIcon={<Codicon name="check" size={ICON} />}
value={commitDefault}
variant="default"
/>
</div>
{/* Hand it to the agent (one click sends a commit+PR task to the composer).
The PR button floats on the right (out of flow) so the label centers on
the whole bar; px-7 reserves the icon's width on both sides. */}
<div className="relative flex min-w-0 items-center">
<Button
className="min-w-0 flex-1 justify-center px-7 text-[0.7rem] text-muted-foreground/85 hover:text-foreground"
disabled={!hasFiles}
onClick={() => requestComposerSubmit(c.agentShipPrompt, { target: 'main' })}
size="sm"
variant="ghost"
>
<span className="truncate underline underline-offset-2">{c.agentShip}</span>
</Button>
<Tip label={ship.ghReady ? prLabel : c.ghMissing}>
<span className="absolute inset-y-0 right-0 flex items-center">
<Button
aria-label={prLabel}
className="size-7 text-muted-foreground/80 hover:text-foreground"
disabled={!ship.ghReady || busy}
onClick={() => void createOrOpenPr().catch(err => notifyError(err, prLabel))}
size="icon-xs"
variant="ghost"
>
<Codicon name="git-pull-request" size={ICON} />
</Button>
</span>
</Tip>
</div>
</div>
)
}

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import type { HermesReviewFile } from '@/global'
import { buildReviewTree } from './tree-data'
const file = (path: string, added = 1, removed = 0): HermesReviewFile => ({
path,
added,
removed,
status: 'M',
staged: false
})
describe('buildReviewTree', () => {
it('nests files under their folders and sorts dirs before files', () => {
const tree = buildReviewTree([file('src/a.ts'), file('readme.md'), file('src/b.ts')], false)
expect(tree.map(n => n.name)).toEqual(['src', 'readme.md'])
const src = tree[0]
expect(src.isDir).toBe(true)
expect(src.children?.map(n => n.name)).toEqual(['a.ts', 'b.ts'])
})
it('aggregates +/- onto directories', () => {
const tree = buildReviewTree([file('src/a.ts', 5, 2), file('src/b.ts', 3, 1)], false)
expect(tree[0].added).toBe(8)
expect(tree[0].removed).toBe(3)
})
it('compacts single-child directory chains', () => {
const tree = buildReviewTree([file('a/b/c/deep.ts')], true)
expect(tree[0].name).toBe('a/b/c')
expect(tree[0].children?.[0].name).toBe('deep.ts')
})
it('does not compact when a directory has multiple children', () => {
const tree = buildReviewTree([file('a/b/one.ts'), file('a/other.ts')], true)
expect(tree[0].name).toBe('a')
expect(tree[0].children?.map(n => n.name).sort()).toEqual(['b', 'other.ts'])
})
})

View file

@ -0,0 +1,126 @@
import type { HermesReviewFile } from '@/global'
// A node in the review changed-files tree. Directories aggregate their
// descendants' +/- so a collapsed folder still shows its total churn (Codex's
// folder hierarchy view).
export interface ReviewTreeNode {
id: string
name: string
isDir: boolean
added: number
removed: number
/** For a flat-list file row: the parent dir (relative), shown dimmed. */
dir?: string
file?: HermesReviewFile
children?: ReviewTreeNode[]
}
// Flat changed-file list (VS Code's default SCM "List" view): one row per file,
// filename + a dimmed parent-dir path, sorted by path. No folder nodes.
export function buildReviewFlatList(files: HermesReviewFile[]): ReviewTreeNode[] {
return [...files]
.sort((a, b) => a.path.localeCompare(b.path))
.map(file => {
const segments = file.path.split('/').filter(Boolean)
const name = segments.pop() ?? file.path
return {
id: file.path,
name,
dir: segments.join('/'),
isDir: false,
added: file.added,
removed: file.removed,
file
}
})
}
interface MutableDir {
id: string
name: string
added: number
removed: number
dirs: Map<string, MutableDir>
files: ReviewTreeNode[]
}
const makeDir = (id: string, name: string): MutableDir => ({
id,
name,
added: 0,
removed: 0,
dirs: new Map(),
files: []
})
// Build a folder hierarchy from the flat changed-file list. With `compact`,
// single-child directory chains collapse into one row (`a/b/c`), the way VS Code
// and Codex render sparse trees.
export function buildReviewTree(files: HermesReviewFile[], compact = true): ReviewTreeNode[] {
const root = makeDir('', '')
for (const file of files) {
const segments = file.path.split('/').filter(Boolean)
const fileName = segments.pop() ?? file.path
let dir = root
dir.added += file.added
dir.removed += file.removed
let prefix = ''
for (const segment of segments) {
prefix = prefix ? `${prefix}/${segment}` : segment
let child = dir.dirs.get(segment)
if (!child) {
child = makeDir(prefix, segment)
dir.dirs.set(segment, child)
}
child.added += file.added
child.removed += file.removed
dir = child
}
dir.files.push({
id: file.path,
name: fileName,
isDir: false,
added: file.added,
removed: file.removed,
file
})
}
const finalize = (dir: MutableDir): ReviewTreeNode[] => {
const dirNodes: ReviewTreeNode[] = [...dir.dirs.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.map(child => {
let node: ReviewTreeNode = {
id: child.id,
name: child.name,
isDir: true,
added: child.added,
removed: child.removed,
children: finalize(child)
}
// Compact a chain: a folder whose only child is one folder merges into
// `parent/child` so deep sparse paths read on one row.
while (compact && node.children?.length === 1 && node.children[0].isDir) {
const only = node.children[0]
node = { ...only, name: `${node.name}/${only.name}` }
}
return node
})
const fileNodes = [...dir.files].sort((a, b) => a.name.localeCompare(b.name))
return [...dirNodes, ...fileNodes]
}
return finalize(root)
}

View file

@ -0,0 +1,89 @@
import { atom } from 'nanostores'
import { translateNow } from '@/i18n'
import { copyTextToClipboard, renameDesktopPath, revealDesktopPath, trashDesktopPath } from '@/lib/desktop-fs'
import { notify, notifyError } from '@/store/notifications'
import { notifyWorkspaceChanged } from '@/store/workspace-events'
// Shared file-row actions for BOTH trees (the file browser + the review/git
// tree): reveal, copy path, rename, delete. Rename/delete route through a single
// dialog set (driven by this atom, rendered once by `FileActionDialogs`) instead
// of one dialog per row. After a successful mutation we bump the workspace tick
// so every git-/fs-mirroring surface refreshes.
export interface FileActionTarget {
isDirectory: boolean
/** Display name (basename) shown in dialogs. */
name: string
/** Absolute path on disk. */
path: string
}
// Delete routes through a single confirm dialog (rendered once). Rename is
// INLINE (VS Code style — an input in the row), driven by `$renamingPath`.
export type FileActionDialog = { kind: 'delete' } & FileActionTarget
export const $fileActionDialog = atom<FileActionDialog | null>(null)
export function requestFileDelete(target: FileActionTarget): void {
$fileActionDialog.set({ kind: 'delete', ...target })
}
export function closeFileActionDialog(): void {
$fileActionDialog.set(null)
}
// Absolute path of the row currently being renamed inline, or null. A row whose
// path matches renders an edit input in place of its label; F2 / Enter (on a
// focused row) and the context-menu "Rename" all set this.
export const $renamingPath = atom<null | string>(null)
export function beginInlineRename(path: string): void {
$renamingPath.set(path)
}
export function cancelInlineRename(): void {
$renamingPath.set(null)
}
// ── Direct (no-dialog) actions ───────────────────────────────────────────────
export async function revealFile(path: string): Promise<void> {
try {
await revealDesktopPath(path)
} catch (error) {
notifyError(error, translateNow('errors.genericFailure'))
}
}
export async function copyFilePath(path: string): Promise<void> {
try {
await copyTextToClipboard(path)
notify({ durationMs: 1500, kind: 'info', message: translateNow('fileMenu.pathCopied') })
} catch (error) {
notifyError(error, translateNow('common.copyFailed'))
}
}
/** Strip a `relativeTo` prefix to produce a repo/cwd-relative path. */
export function toRelativePath(path: string, relativeTo: string): string {
const base = relativeTo.replace(/[\\/]+$/, '')
if (path === base) {
return path
}
return path.startsWith(`${base}/`) || path.startsWith(`${base}\\`) ? path.slice(base.length + 1) : path
}
// ── Dialog-confirmed mutations (called by FileActionDialogs) ──────────────────
export async function executeFileRename(path: string, newName: string): Promise<void> {
await renameDesktopPath(path, newName)
notifyWorkspaceChanged()
}
export async function executeFileDelete(path: string): Promise<void> {
await trashDesktopPath(path)
notifyWorkspaceChanged()
}

View file

@ -1,5 +1,7 @@
import { atom, computed } from 'nanostores'
import { persistentAtom } from '@/lib/persisted'
import {
$rightRailActiveTabId,
PREVIEW_PANE_ID,
@ -58,11 +60,32 @@ export interface FilePreviewTab {
}
const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1'
const TABS_STORAGE_KEY = 'hermes.desktop.filePreviewTabs.v1'
const MAX_RECORDS_PER_SESSION = 1
const MAX_SESSIONS = 120
export const $previewTarget = atom<PreviewTarget | null>(null)
export const $filePreviewTabs = atom<FilePreviewTab[]>([])
// Persisted so open file-preview tabs survive a relaunch; content is re-read
// from each target's path/url on demand. Invalid rows are dropped on load and
// inline image bytes (megabytes) are stripped on save, mirroring the registry.
export const $filePreviewTabs = persistentAtom<FilePreviewTab[]>(TABS_STORAGE_KEY, [], {
decode: raw => {
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter(isFilePreviewTab) : []
},
encode: tabs => JSON.stringify(tabs, (key, value) => (key === 'dataUrl' ? undefined : value))
})
// Drop a restored active file-tab that didn't survive validation so the rail
// never points at a tab that isn't there.
if (
$rightRailActiveTabId.get().startsWith('file:') &&
!$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())
) {
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
export const $filePreviewTarget = computed([$filePreviewTabs, $rightRailActiveTabId], (tabs, activeTabId) => {
if (!activeTabId.startsWith('file:')) {
return null
@ -170,6 +193,16 @@ function isPreviewTarget(value: unknown): value is PreviewTarget {
)
}
function isFilePreviewTab(value: unknown): value is FilePreviewTab {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return typeof r.id === 'string' && r.id.startsWith('file:') && isPreviewTarget(r.target)
}
function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
if (!value || typeof value !== 'object') {
return false
@ -428,6 +461,48 @@ export function closeRightRailTab(tabId: RightRailTabId) {
export const closeActiveRightRailTab = () => closeRightRailTab($rightRailActiveTabId.get())
// The rail's visible tab order: the live preview tab (when present) first, then
// the file tabs in their stored order. Mirrors `ChatPreviewRail`'s `tabs` memo
// so "close others / to the right" act on what the user actually sees.
function rightRailTabOrder(): RightRailTabId[] {
const ids: RightRailTabId[] = []
if ($previewTarget.get()) {
ids.push(RIGHT_RAIL_PREVIEW_TAB_ID)
}
for (const tab of $filePreviewTabs.get()) {
ids.push(tab.id)
}
return ids
}
/** Close every rail tab except `keepId`, then make `keepId` active. */
export function closeOtherRightRailTabs(keepId: RightRailTabId) {
for (const id of rightRailTabOrder()) {
if (id !== keepId) {
closeRightRailTab(id)
}
}
selectRightRailTab(keepId)
}
/** Close every rail tab positioned after `tabId` (VS Code's "Close to the Right"). */
export function closeRightRailTabsToRight(tabId: RightRailTabId) {
const order = rightRailTabOrder()
const index = order.indexOf(tabId)
if (index === -1) {
return
}
for (const id of order.slice(index + 1)) {
closeRightRailTab(id)
}
}
/** Dismisses the active preview + every file tab so the rail pane unmounts. */
export function closeRightRail() {
if ($previewTarget.get()) {

View file

@ -0,0 +1,497 @@
import { atom, computed } from 'nanostores'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import type { HermesReviewFile, HermesReviewShipInfo } from '@/global'
import { matchesQuery } from '@/hooks/use-media-query'
import { isExcludedPath } from '@/lib/excluded-paths'
import { requestOneShot } from '@/lib/oneshot'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { refreshRepoStatus } from './coding-status'
import { $busy, $currentCwd } from './session'
import { $workspaceChangeTick } from './workspace-events'
// State for the review pane: the working-tree changed-file list, the selected
// file's diff, and the git mutations (stage / unstage / revert). The active
// session's cwd is the repo; the pane reads git as the source of truth, the
// same bounded "re-probe on structural edges" model as the coding rail.
//
// Scope is always "uncommitted" — Hermes' flow is agent edits you review BEFORE
// committing, so branch/last-turn scopes are almost always empty here (unlike
// Codex, which commits per turn). We show the one view that's always populated.
// Must match the review <Pane id> in desktop-controller (the forced-reveal
// event is addressed by pane id).
export const REVIEW_PANE_ID = 'review'
const OPEN_KEY = 'hermes.desktop.reviewOpen'
const COMMIT_DEFAULT_KEY = 'hermes.desktop.reviewCommitDefault'
const TREE_MODE_KEY = 'hermes.desktop.reviewTreeMode'
const SELECTED_KEY = 'hermes.desktop.reviewSelectedPath'
const REVIEW_REFRESH_DEBOUNCE_MS = 100
const SHIP_INFO_STALE_MS = 30_000
// Persisted so the pane stays open across reloads (like the other rail panes).
export const $reviewOpen = persistentAtom(OPEN_KEY, false, Codecs.bool)
// The split-button's remembered default action ('commit' | 'commitPush').
export type CommitAction = 'commit' | 'commitPush'
export const $reviewCommitDefault = persistentAtom<CommitAction>(COMMIT_DEFAULT_KEY, 'commit', {
decode: raw => (raw === 'commitPush' ? 'commitPush' : 'commit'),
encode: value => value
})
// Changed-file layout: a flat path list (VS Code's default) or a folder tree.
export type ReviewTreeMode = 'list' | 'tree'
export const $reviewTreeMode = persistentAtom<ReviewTreeMode>(TREE_MODE_KEY, 'tree', {
decode: raw => (raw === 'list' ? 'list' : 'tree'),
encode: value => value
})
export function toggleReviewTreeMode(): void {
$reviewTreeMode.set($reviewTreeMode.get() === 'tree' ? 'list' : 'tree')
}
export const $reviewFiles = atom<HermesReviewFile[]>([])
export const $reviewLoading = atom(false)
// False when the active session isn't in a local git repo (detached/fresh chat,
// remote backend). Lets the pane say "not a repo" instead of stranding on a
// skeleton or implying a clean repo with "no changes".
export const $reviewIsRepo = atom(true)
// Largest single-file churn (added + removed) in the current diff. Drives the
// per-row data bars: each file's bar is its churn relative to this max, so the
// biggest file fills the row and the rest scale down against it.
export const $reviewMaxChurn = computed($reviewFiles, files =>
files.reduce((max, file) => Math.max(max, file.added + file.removed), 0)
)
// Persisted so a relaunch restores the file you were diffing (its diff is
// re-fetched in refreshReview once the file is confirmed still changed).
export const $reviewSelectedPath = persistentAtom<null | string>(SELECTED_KEY, null, Codecs.nullableText)
export const $reviewDiff = atom<null | string>(null)
export const $reviewDiffLoading = atom(false)
// Ship state: gh availability + this branch's PR, and a busy flag for the
// commit/push/PR action bar (disables buttons + shows progress).
export const $reviewShipInfo = atom<HermesReviewShipInfo>({ ghReady: false, pr: null })
export const $reviewShipBusy = atom(false)
// True while a commit message is being generated (drives the input's spinner).
export const $reviewCommitMsgBusy = atom(false)
const repoCwd = (): null | string => $currentCwd.get()?.trim() || null
type ReviewBridge = NonNullable<NonNullable<NonNullable<Window['hermesDesktop']>['git']>['review']>
let reviewRefreshSeq = 0
let reviewRefreshTimer: ReturnType<typeof setTimeout> | null = null
let shipInfoSeq = 0
let shipInfoLastCheckedAt = 0
// The two things every review op needs: the repo cwd + the IPC bridge. Null when
// either is missing (no session, remote backend), so callers bail in one line.
function reviewCtx(): { cwd: string; review: ReviewBridge } | null {
const cwd = repoCwd()
const review = window.hermesDesktop?.git?.review
return cwd && review ? { cwd, review } : null
}
// ── Reads ────────────────────────────────────────────────────────────────────
export async function refreshReview(): Promise<void> {
const ctx = reviewCtx()
const seq = (reviewRefreshSeq += 1)
if (!$reviewOpen.get() || !ctx) {
$reviewFiles.set([])
$reviewIsRepo.set(Boolean(ctx))
// Critical: clear loading on the no-cwd / not-a-repo path too. It's set
// true (optimistically) before a refresh is scheduled, so skipping it here
// strands the pane on a forever-skeleton for a fresh, detached chat.
if (seq === reviewRefreshSeq) {
$reviewLoading.set(false)
}
return
}
const { cwd, review } = ctx
$reviewIsRepo.set(true)
$reviewLoading.set(true)
try {
const result = await review.list(cwd, 'uncommitted', null)
// Ignore a result that resolved after the cwd moved on.
if (seq !== reviewRefreshSeq || repoCwd() !== cwd) {
return
}
// Hide dep/build/cache dirs and OS noise even when the repo tracks them —
// .gitignored paths are already dropped upstream by `git status`.
const files = result.files.filter(file => !isExcludedPath(file.path))
$reviewFiles.set(files)
// Drop the selection if the file is gone (staged away, reverted) so the diff
// pane doesn't strand on a ghost; otherwise lazily fetch its diff so a
// restored (persisted) selection re-renders on boot.
const selected = $reviewSelectedPath.get()
const selectedFile = selected ? files.find(file => file.path === selected) : null
if (selected && !selectedFile) {
clearReviewSelection()
} else if (selectedFile && $reviewDiff.get() === null) {
void selectReviewFile(selectedFile)
}
} catch {
if (seq === reviewRefreshSeq) {
$reviewFiles.set([])
}
} finally {
if (seq === reviewRefreshSeq) {
$reviewLoading.set(false)
}
}
}
function scheduleReviewRefresh(): void {
if (!$reviewOpen.get()) {
return
}
if (reviewRefreshTimer) {
clearTimeout(reviewRefreshTimer)
}
reviewRefreshTimer = setTimeout(() => {
reviewRefreshTimer = null
void refreshReview()
}, REVIEW_REFRESH_DEBOUNCE_MS)
}
export async function selectReviewFile(file: HermesReviewFile): Promise<void> {
$reviewSelectedPath.set(file.path)
const ctx = reviewCtx()
if (!ctx) {
$reviewDiff.set(null)
return
}
$reviewDiffLoading.set(true)
try {
const diff = await ctx.review.diff(ctx.cwd, file.path, 'uncommitted', null, file.staged)
if ($reviewSelectedPath.get() === file.path) {
$reviewDiff.set(diff || '')
}
} catch {
if ($reviewSelectedPath.get() === file.path) {
$reviewDiff.set('')
}
} finally {
if ($reviewSelectedPath.get() === file.path) {
$reviewDiffLoading.set(false)
}
}
}
export function clearReviewSelection(): void {
$reviewSelectedPath.set(null)
$reviewDiff.set(null)
$reviewDiffLoading.set(false)
}
// ── View state ───────────────────────────────────────────────────────────────
export async function refreshShipInfo(): Promise<void> {
const ctx = reviewCtx()
const seq = (shipInfoSeq += 1)
if (!ctx) {
$reviewShipInfo.set({ ghReady: false, pr: null })
return
}
try {
const info = await ctx.review.shipInfo(ctx.cwd)
if (seq === shipInfoSeq && repoCwd() === ctx.cwd) {
$reviewShipInfo.set(info)
shipInfoLastCheckedAt = Date.now()
}
} catch {
if (seq === shipInfoSeq) {
$reviewShipInfo.set({ ghReady: false, pr: null })
shipInfoLastCheckedAt = Date.now()
}
}
}
function refreshShipInfoIfStale(): void {
if (Date.now() - shipInfoLastCheckedAt > SHIP_INFO_STALE_MS) {
void refreshShipInfo()
}
}
export function openReview(): void {
$reviewOpen.set(true)
void refreshReview()
void refreshShipInfo()
}
export function closeReview(): void {
$reviewOpen.set(false)
clearReviewSelection()
}
export function toggleReview(): void {
// Narrow width: the pane is a collapsed overlay (like the sidebar under ⌘B).
// Make sure its data is loaded, then slide it in/out via the forced-reveal pin
// — never the docked open state, which a 0px track would render invisibly.
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
if (!$reviewOpen.get()) {
openReview()
}
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: REVIEW_PANE_ID } }))
return
}
if ($reviewOpen.get()) {
closeReview()
} else {
openReview()
}
}
// ── Mutations ────────────────────────────────────────────────────────────────
// Run a git mutation then re-sync both the review list and the rail's +/- (the
// working tree changed). A failure is swallowed by the caller's notify wrapper.
async function afterMutation(): Promise<void> {
await refreshReview()
void refreshRepoStatus()
const selected = $reviewSelectedPath.get()
const file = selected ? $reviewFiles.get().find(f => f.path === selected) : null
// Re-fetch the open diff (staging flips which diff — cached vs worktree).
if (file) {
void selectReviewFile(file)
}
}
export async function stageReviewFile(path: null | string): Promise<void> {
await window.hermesDesktop?.git?.review?.stage(repoCwd() ?? '', path)
await afterMutation()
}
export async function unstageReviewFile(path: null | string): Promise<void> {
await window.hermesDesktop?.git?.review?.unstage(repoCwd() ?? '', path)
await afterMutation()
}
export async function revertReviewFile(path: null | string): Promise<void> {
await window.hermesDesktop?.git?.review?.revert(repoCwd() ?? '', path)
await afterMutation()
}
// Revert is destructive (discards working-tree edits with no undo), so it always
// routes through a confirm dialog. The target is `{ path }` where `path === null`
// means "revert all"; `undefined` means no confirm is open. We wrap the path in
// an object so the `null` ("all") case is distinguishable from "closed".
export const $reviewRevertTarget = atom<{ path: null | string } | undefined>(undefined)
/** Open the revert confirm for a single file, or `null` for all changes. */
export function requestRevert(path: null | string): void {
$reviewRevertTarget.set({ path })
}
export function cancelRevert(): void {
$reviewRevertTarget.set(undefined)
}
/** Confirm the pending revert (closes the dialog, then performs it). */
export async function confirmRevert(): Promise<void> {
const target = $reviewRevertTarget.get()
$reviewRevertTarget.set(undefined)
if (target) {
await revertReviewFile(target.path)
}
}
// ── Ship flow (commit / push / PR) ───────────────────────────────────────────
// Serialize ship actions behind one busy flag so the bar can't double-fire.
async function runShip<T>(action: () => Promise<T>): Promise<T> {
$reviewShipBusy.set(true)
try {
return await action()
} finally {
$reviewShipBusy.set(false)
}
}
export async function commitChanges(message: string, opts: { push?: boolean } = {}): Promise<void> {
const ctx = reviewCtx()
if (!ctx || !message.trim()) {
return
}
await runShip(async () => {
await ctx.review.commit(ctx.cwd, message.trim(), Boolean(opts.push))
await refreshReview()
void refreshRepoStatus()
void refreshShipInfo()
})
}
// Monotonic token: each generation captures one; Stop (or a newer press) bumps
// it, so a stale resolve is ignored. The model call can't be aborted
// server-side — we just drop its result and free the UI immediately.
let commitGenSeq = 0
/** Abandon any in-flight commit-message generation and re-enable the input. */
export function cancelCommitMessage(): void {
commitGenSeq += 1
$reviewCommitMsgBusy.set(false)
}
// Draft a commit message from the working-tree diff via a one-off LLM request
// (outside the conversation — no history, no cache break). `previous` is the
// current box text: handing it back as "don't repeat this" makes a re-press a
// real regen even on greedy / temperature-pinned models. Throws so the UI toasts.
export async function generateCommitMessage(previous = ''): Promise<string> {
const ctx = reviewCtx()
if (!ctx?.review.commitContext) {
return ''
}
const gen = (commitGenSeq += 1)
const live = () => gen === commitGenSeq
$reviewCommitMsgBusy.set(true)
try {
const { diff, recent } = await ctx.review.commitContext(ctx.cwd)
if (!live() || !diff.trim()) {
return ''
}
const text = await requestOneShot({
template: 'commit_message',
temperature: 0.8,
variables: { avoid: previous, diff, recent_commits: recent }
})
return live() ? text : ''
} finally {
if (live()) {
$reviewCommitMsgBusy.set(false)
}
}
}
export async function pushChanges(): Promise<void> {
const ctx = reviewCtx()
if (!ctx) {
return
}
await runShip(async () => {
await ctx.review.push(ctx.cwd)
void refreshShipInfo()
})
}
// PR button: open the existing PR in the browser, or create one (pushing first)
// then open it. Caller gates this on shipInfo.ghReady.
export async function createOrOpenPr(): Promise<void> {
const ctx = reviewCtx()
if (!ctx) {
return
}
const existing = $reviewShipInfo.get().pr
if (existing?.url) {
void window.hermesDesktop?.openExternal?.(existing.url)
return
}
await runShip(async () => {
const { url } = await ctx.review.createPr(ctx.cwd)
if (url) {
void window.hermesDesktop?.openExternal?.(url)
}
void refreshShipInfo()
})
}
// ── Triggers (module-scope, mirror coding-status.ts) ─────────────────────────
// A file-mutating tool finished (event-driven, not polled) → refresh the open
// pane's changed-file list. gh/PR re-check is NOT here (gh is slow); it runs on
// the settle edge below.
$workspaceChangeTick.subscribe(() => {
if ($reviewOpen.get()) {
scheduleReviewRefresh()
}
})
// Turn settled: final list refresh + the slower gh/PR re-check.
let prevBusy = $busy.get()
$busy.subscribe(busy => {
if (prevBusy && !busy && $reviewOpen.get()) {
scheduleReviewRefresh()
refreshShipInfoIfStale()
}
prevBusy = busy
})
// The active session's cwd changed → the repo changed under the pane. Clear the
// stale file list + selection up front so the pane drops straight to its loading
// skeleton instead of blipping the previous repo's diff into the new one.
$currentCwd.subscribe(() => {
if ($reviewOpen.get()) {
clearReviewSelection()
$reviewFiles.set([])
$reviewLoading.set(true)
scheduleReviewRefresh()
void refreshShipInfo()
}
})
// An outside terminal may have changed the tree while we were away.
if (typeof window !== 'undefined') {
window.addEventListener('focus', () => {
if ($reviewOpen.get()) {
scheduleReviewRefresh()
refreshShipInfoIfStale()
}
})
}