diff --git a/apps/desktop/src/app/right-sidebar/file-actions.tsx b/apps/desktop/src/app/right-sidebar/file-actions.tsx new file mode 100644 index 00000000000..dbb2ccb65d3 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/file-actions.tsx @@ -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 ( + + {children} + {/* Don't restore focus to the row on close: "Rename" mounts an autofocused + inline input, and the default focus-return would blur it immediately. */} + event.preventDefault()}> + {localFs && ( + <> + void revealFile(path)}>{revealLabel} + + + )} + void copyFilePath(path)}>{m.copyPath} + {relativeTo && ( + void copyFilePath(toRelativePath(path, relativeTo))}> + {m.copyRelativePath} + + )} + {localFs && ( + <> + + beginInlineRename(path)}>{m.rename} + requestFileDelete(target)} variant="destructive"> + {m.delete} + + + )} + + + ) +} + +/** 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 ( + { + 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 ( + { + 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} + /> + ) +} diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.ts b/apps/desktop/src/app/right-sidebar/files/ipc.ts index 7ffed007d0a..eb200695c0d 100644 --- a/apps/desktop/src/app/right-sidebar/files/ipc.ts +++ b/apps/desktop/src/app/right-sidebar/files/ipc.ts @@ -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) } } diff --git a/apps/desktop/src/app/right-sidebar/files/tree.tsx b/apps/desktop/src/app/right-sidebar/files/tree.tsx index e7399d2611a..2b74280a76b 100644 --- a/apps/desktop/src/app/right-sidebar/files/tree.tsx +++ b/apps/desktop/src/app/right-sidebar/files/tree.tsx @@ -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(null) const treeRef = useRef | 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) => { - 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) => { + 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 ( -
+
{size.height > 0 && size.width > 0 ? ( 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 => ( )} @@ -127,23 +216,51 @@ export function ProjectTree({ } function TreeSizingState() { - const { t } = useI18n() + return +} - return +// 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) { + return ( +
e.stopPropagation()} + ref={innerRef} + style={{ ...attrs.style, minWidth: 0, width: '100%' }} + > + {children} +
+ ) +} + +const CHANGE_TINT: Record = { + added: 'text-(--ui-green)', + conflicted: 'text-(--ui-red)', + modified: 'text-(--ui-yellow)' } function ProjectTreeRow({ + changeKind, dragHandle, node, onAttachFile, onAttachFolder, onPreviewFile, + relativeTo, style }: NodeRendererProps & { + 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
} @@ -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 = (
{ 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({ )} - {node.data.name} + {editing ? ( + + ) : ( + // Git decoration (VS Code-style): tint changed files; the explicit color + // wins over the row's hover/selected text color, so it persists. + {node.data.name} + )}
) + + if (isPlaceholder) { + return row + } + + return ( + + {row} + + ) } diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts index 0f454e73a3d..59ec3b1b73f 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -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 { + 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 => { + 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 diff --git a/apps/desktop/src/app/right-sidebar/index.test.tsx b/apps/desktop/src/app/right-sidebar/index.test.tsx index 07a0fbb0435..73ca3d46946 100644 --- a/apps/desktop/src/app/right-sidebar/index.test.tsx +++ b/apps/desktop/src/app/right-sidebar/index.test.tsx @@ -9,32 +9,17 @@ import { resetProjectTreeState } from './files/use-project-tree' import { RightSidebarPane } from './index' const readDir = vi.fn<(path: string) => Promise>() -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() + render() - 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() + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Refresh tree' })).toBeNull()) + expect(readDir).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx index 8a751bafcf2..936ed2dfda2 100644 --- a/apps/desktop/src/app/right-sidebar/index.tsx +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -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 } -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 + 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 + } + return (
- + {cwdName}
- - - - - - - - - + +
{children}
+export function RightSidebarSectionHeader({ children, className, ...props }: ComponentProps<'div'>) { + return ( +
+ {children} +
+ ) } 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 @@ -282,7 +262,7 @@ function FileTreeBody({ } if (loading && data.length === 0) { - return + return showSkeleton ? :
} if (data.length === 0) { @@ -325,23 +305,30 @@ function FileTreeLoadingState() { const { t } = useI18n() return ( -
-