Merge workspace tree and preview fixes for #1361

This commit is contained in:
程序员阿江(Relakkes)
2026-09-25 14:51:03 +08:00
13 changed files with 191 additions and 35 deletions
@@ -139,10 +139,10 @@ afterEach(() => {
})
describe('content states', () => {
it('gives the file picker a centered invitation while the tree remains available', () => {
it('gives the empty file picker all the space instead of reserving a blank preview', () => {
renderTab('')
expect(screen.getByRole('heading', { name: 'Open file' })).toBeVisible()
expect(screen.getByText('Choose a file from the tree')).toBeVisible()
expect(screen.queryByTestId('workspace-file-preview')).toBeNull()
expect(screen.getByTestId('workspace-tree-sidebar')).toHaveStyle({ width: '100%', maxWidth: '100%' })
expect(screen.getByTestId('file-tree-pane')).toBeVisible()
})
@@ -237,7 +237,7 @@ describe('content states', () => {
it('invites the user to pick a file when the tab has no path yet', () => {
renderTab('')
expect(screen.getByText('Choose a file from the tree')).toBeInTheDocument()
expect(screen.getByTestId('file-tree-pane')).toBeVisible()
expect(loadFile).not.toHaveBeenCalled()
})
})
@@ -279,6 +279,45 @@ describe('refresh', () => {
})
describe('file tree', () => {
it.each([undefined, { line: 4 }])('restores a hidden preview when a chat link reopens the same file (%j)', (reveal) => {
const store = useWorkspaceStore.getState()
const id = store.openTarget(SESSION, { kind: 'file', path: 'src/a.ts' })
const getTab = () => useWorkspaceStore.getState().bySession[SESSION]?.tabs.find((tab) => tab.id === id) as WorkspaceFileTabModel
seedEntry('src/a.ts', { content: 'first', previewType: 'text' })
const view = render(<WorkspaceFileTab sessionId={SESSION} tab={getTab()} />)
fireEvent.click(screen.getByRole('button', { name: 'Hide preview' }))
expect(screen.getByTestId('code-surface')).not.toBeVisible()
store.openTarget(SESSION, { kind: 'file', path: 'src/a.ts', reveal })
view.rerender(<WorkspaceFileTab sessionId={SESSION} tab={getTab()} />)
expect(screen.getByTestId('code-surface')).toBeVisible()
})
it('hides and restores the preview without losing the tree or selected file', () => {
seedEntry('src/a.ts', { content: 'const x = 1', previewType: 'text' })
renderTab('src/a.ts')
fireEvent.click(screen.getByTestId('workspace-file-tree-toggle'))
fireEvent.click(screen.getByRole('button', { name: 'Hide preview' }))
expect(screen.getByTestId('workspace-file-preview')).not.toBeVisible()
expect(screen.getByTestId('file-tree-pane')).toBeVisible()
expect(screen.getByTestId('file-tree-pane')).toHaveAttribute('data-selected', 'src/a.ts')
expect(screen.getByTestId('workspace-tree-sidebar')).toHaveStyle({ width: '100%', maxWidth: '100%' })
expect(screen.queryByTestId('workspace-file-tree-toggle')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Show preview' }))
expect(screen.getByTestId('code-surface')).toBeVisible()
expect(screen.getByTestId('file-tree-pane')).toBeVisible()
})
it('shows the preview when another file is selected from tree-only mode', () => {
seedEntry('src/a.ts', { content: 'first', previewType: 'text' })
seedEntry('src/other.ts', { content: 'second', previewType: 'text' })
const view = renderTab('src/a.ts')
fireEvent.click(screen.getByRole('button', { name: 'Hide preview' }))
fireEvent.click(screen.getByRole('button', { name: 'tree row' }))
view.rerender(<WorkspaceFileTab sessionId={SESSION} tab={fileTab('src/other.ts')} />)
expect(screen.getByTestId('code-surface')).toBeVisible()
expect(screen.getByTestId('code-surface')).toHaveTextContent('second')
})
it('keeps the tree beside the content instead of replacing one with the other', () => {
// The previous panel hid its navigation the moment a file opened, so
// browsing a repository made the structure appear and disappear.
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown, ChevronRight, ExternalLink, FolderClosed, FolderOpen } from 'lucide-react'
import { ChevronDown, ChevronRight, ExternalLink, FolderClosed, FolderOpen, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import { IconButton } from '@/components/ui/IconButton'
import { TargetIcon } from '@/components/composite/TargetIcon'
import { useWorkspaceFileOpenTargets } from '@/components/workspace/workspaceFileOpenTargets'
@@ -73,6 +73,10 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
const restoredSurface = useRef<{ node: HTMLElement; revealNonce: number | undefined }>()
const [consumedReveal, setConsumedReveal] = useState<string | null>(null)
const viewKey = `${sessionId}::${path}`
// Explicit file opens replace the tab model, including same-path chat links.
// Keep collapse local to this activation so a later open can reveal content.
const [hiddenPreviewTab, setHiddenPreviewTab] = useState<WorkspaceFileTabModel | null>(null)
const previewVisible = !!path && hiddenPreviewTab !== tab
const revealKey = `${viewKey}::${tab.reveal?.nonce}`
const restoredView = useRef<{ key: string; view: WorkspaceFileView | undefined }>()
if (restoredView.current?.key !== viewKey) {
@@ -202,7 +206,7 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
))
)}
</nav>
<IconButton
{previewVisible ? <IconButton
ref={treeToggleRef}
icon={treeOpen
? <FolderOpen size={18} strokeWidth={1.8} />
@@ -213,9 +217,20 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
pressed={treeOpen}
data-testid="workspace-file-tree-toggle"
onClick={() => setTreeOpen((open) => !open)}
/>
/> : null}
{path ? (
<>
<IconButton
icon={previewVisible ? <PanelLeftClose size={18} /> : <PanelLeftOpen size={18} />}
label={t(previewVisible ? 'workspace.files.hidePreview' : 'workspace.files.showPreview')}
size="sm"
tone="muted"
pressed={previewVisible}
onClick={() => {
setHiddenPreviewTab(previewVisible ? tab : null)
if (previewVisible) setTreeOpen(true)
}}
/>
<span className="relative flex h-8 shrink-0 items-stretch rounded-[var(--radius-md)] border border-[var(--color-border)]">
<button
type="button"
@@ -268,7 +283,7 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
</div>
<div data-testid="workspace-file-body" className="relative flex min-h-0 flex-1">
<div key={viewKey} ref={fileContentRef} className="flex min-h-0 min-w-0 flex-1 flex-col" onScrollCapture={(event) => {
{path ? <div key={viewKey} ref={fileContentRef} data-testid="workspace-file-preview" hidden={!previewVisible} className={`${previewVisible ? 'flex' : 'hidden'} min-h-0 min-w-0 flex-1 flex-col`} onScrollCapture={(event) => {
const surface = event.target
if (!(surface instanceof HTMLElement) || !surface.hasAttribute('data-workspace-scroll-surface')) return
setConsumedReveal(revealKey)
@@ -278,13 +293,7 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
revealNonce: tab.reveal?.nonce,
})
}}>
{!path ? (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-5 text-center">
<FolderOpen size={32} strokeWidth={1.7} aria-hidden="true" className="mb-1 text-[var(--color-text-tertiary)]" />
<h3 className="text-[18px] font-medium text-[var(--color-text-primary)]">{t('workspace.files.openTitle')}</h3>
<p className="text-[16px] text-[var(--color-text-tertiary)]">{t('workspace.files.pickAFile')}</p>
</div>
) : !entry || entry.state === 'loading' ? (
{!entry || entry.state === 'loading' ? (
<PanelMessage icon="hourglass_empty" message={t('workspace.previewState.loading')} />
) : entry.state === 'missing' ? (
<PanelMessage icon="search_off" message={t('workspace.previewState.missing')} />
@@ -322,18 +331,20 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
{t('workspace.files.refreshFailed', { reason: entry.refreshError })}
</p>
) : null}
</div>
</div> : null}
<WorkspaceTreeSidebar open={treeOpen} onOpenChange={setTreeOpen} collapseOnNarrow={!!path}>
<WorkspaceTreeSidebar open={!previewVisible || treeOpen} onOpenChange={setTreeOpen} fullWidth={!previewVisible}>
<WorkspaceFileTreePane
sessionId={sessionId}
selectedPath={path || null}
autoFocus={!path}
onOpen={(nextPath) =>
onOpen={(nextPath) => {
setHiddenPreviewTab(null)
// A pick from the tree is a permanent tab of its own. Only the
// empty Files launcher this tree is hosted in gets replaced —
// everything else adds, so ten picks mean ten tabs.
workspaceOpen.file(sessionId, nextPath, { replaceBlankPlaceholder: true })}
workspaceOpen.file(sessionId, nextPath, { replaceBlankPlaceholder: true })
}}
/>
</WorkspaceTreeSidebar>
</div>
@@ -6,6 +6,8 @@ const mocks = vi.hoisted(() => ({
getWorkspaceTree: vi.fn(),
searchWorkspace: vi.fn(),
getWorkspaceStatus: vi.fn(),
openTarget: vi.fn().mockResolvedValue(undefined),
copyText: vi.fn().mockResolvedValue(true),
}))
vi.mock('../../api/sessions', () => ({
@@ -17,6 +19,16 @@ vi.mock('../../api/sessions', () => ({
},
}))
vi.mock('@/lib/clipboard', () => ({ copyTextToClipboard: mocks.copyText }))
vi.mock('@/stores/openTargetStore', () => {
const targets = [
{ id: 'system-default', kind: 'system_default', label: 'System default', platform: 'win32' },
{ id: 'explorer', kind: 'file_manager', label: 'Explorer', platform: 'win32' },
]
const state = { targets, getTargetsForPath: () => new Promise(() => {}), openTarget: mocks.openTarget }
return { useOpenTargetStore: Object.assign((select: (s: typeof state) => unknown) => select(state), { getState: () => state }) }
})
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
import { WorkspaceFileTreePane } from './WorkspaceFileTreePane'
import { useWorkspaceChatContextStore } from '@/stores/workspaceChatContextStore'
@@ -62,7 +74,7 @@ beforeEach(() => {
expandedBySession: {},
treeViewBySession: {},
fileViewByKey: {},
statusBySession: {},
statusBySession: { [SESSION]: { state: 'ok', workDir: '/repo', repoName: 'repo', branch: 'main', isGitRepo: true, changedFiles: [] } },
})
mocks.getWorkspaceTree.mockReset()
mocks.getWorkspaceTree.mockImplementation(async (_session: string, path: string) =>
@@ -482,7 +494,7 @@ describe('file chat references', () => {
const file = screen.getByTestId('workspace-tree-row-README.md')
fireEvent.keyDown(file, { key: 'F10', shiftKey: true })
expect(screen.getByRole('menuitem', { name: 'Add to chat' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('menuitem'), { key: 'Escape' })
fireEvent.keyDown(screen.getByRole('menuitem', { name: 'Add to chat' }), { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(useWorkspaceChatContextStore.getState().referencesBySession[SESSION]).toBeUndefined()
})
@@ -499,3 +511,39 @@ describe('file chat references', () => {
expect(useWorkspaceChatContextStore.getState().referencesBySession).toEqual({})
})
})
// #1361: the unified tree dropped the existing native-open and copy actions.
describe('file tree open actions', () => {
it.each([
['README.md', false], ['src', true],
])('opens and copies the exact absolute path for %s without previewing it', async (path, isDirectory) => {
mocks.openTarget.mockClear()
mocks.copyText.mockClear()
useWorkspaceContentStore.setState({ statusBySession: { [SESSION]: { state: 'ok', workDir: 'C:\\repo', repoName: 'repo', branch: 'main', isGitRepo: true, changedFiles: [] } } })
const { onOpen } = await renderPane()
const openMenu = async () => {
fireEvent.contextMenu(screen.getByTestId(`workspace-tree-row-${path}`))
await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Default application' })).toBeInTheDocument())
}
await openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Default application' }))
expect(mocks.openTarget).toHaveBeenLastCalledWith('system-default', `C:\\repo/${path}`)
expect(screen.queryByRole('menu')).toBeNull()
await openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Open containing folder' }))
expect(mocks.openTarget).toHaveBeenLastCalledWith('explorer', `C:\\repo/${path}`)
await openMenu()
if (isDirectory) expect(screen.queryByRole('menuitem', { name: 'Copy file contents' })).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy path' }))
expect(mocks.copyText).toHaveBeenLastCalledWith(`C:\\repo/${path}`)
expect(onOpen).not.toHaveBeenCalled()
})
it('routes a tree context-menu preview through the pane activation callback', async () => {
const { onOpen } = await renderPane()
fireEvent.contextMenu(screen.getByTestId('workspace-tree-row-README.md'))
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace preview' }))
expect(onOpen).toHaveBeenCalledWith('README.md')
expect(screen.queryByRole('menu')).toBeNull()
})
})
@@ -12,6 +12,8 @@ import { useRovingTree } from './treeKeyboard'
import { useWorkspaceChatContextStore } from '@/stores/workspaceChatContextStore'
import { useDismissable } from '@/hooks/useDismissable'
import { useAnchoredPosition } from '@/hooks/useAnchoredPosition'
import { WorkspaceFileOpenWith } from '@/components/workspace/WorkspaceFileOpenWith'
import { resolveAbsoluteOpenPath } from '@/lib/systemFileOpen'
import { useMenuKeyboard } from '@/components/workbench/menuKeyboard'
export type WorkspaceFileTreePaneProps = {
@@ -61,6 +63,11 @@ export function WorkspaceFileTreePane({
offset: 0,
clampHeight: true,
})
const workDir = useWorkspaceContentStore((state) => state.statusBySession[sessionId]?.workDir)
const loadStatus = useWorkspaceContentStore((state) => state.loadStatus)
useEffect(() => {
if (menu && !workDir) void loadStatus(sessionId)
}, [menu, workDir, loadStatus, sessionId])
const treeView = useWorkspaceContentStore((state) => state.treeViewBySession[sessionId] ?? EMPTY_WORKSPACE_TREE_VIEW)
const setTreeView = useWorkspaceContentStore((state) => state.setTreeView)
const { filter } = treeView
@@ -390,6 +397,16 @@ export function WorkspaceFileTreePane({
}}>
{t('workspace.addSelectionToChat')}
</Button>
{workDir ? (
<WorkspaceFileOpenWith
absolutePath={resolveAbsoluteOpenPath(menu.row.path, workDir)}
sessionId={sessionId}
workspacePath={menu.row.path}
isDirectory={menu.row.isDirectory}
onPreview={() => onOpen(menu.row.path)}
onAfterSelect={closeMenu}
/>
) : null}
</div>
) : null}
</div>
@@ -12,6 +12,22 @@ function Fixture() {
afterEach(() => vi.unstubAllGlobals())
describe('WorkspaceTreeSidebar', () => {
it('keeps tree-only mode full width and visible even in a narrow panel', () => {
let resize: ResizeObserverCallback = () => {}
vi.stubGlobal('ResizeObserver', class {
constructor(callback: ResizeObserverCallback) { resize = callback }
observe() {}
disconnect() {}
})
const onOpenChange = vi.fn()
render(<div><WorkspaceTreeSidebar open fullWidth onOpenChange={onOpenChange}>file rows</WorkspaceTreeSidebar></div>)
act(() => resize([{ contentRect: { width: 400 } } as ResizeObserverEntry], {} as ResizeObserver))
expect(onOpenChange).not.toHaveBeenCalled()
expect(screen.getByTestId('workspace-tree-sidebar')).toHaveAttribute('data-overlay', 'false')
expect(screen.getByTestId('workspace-tree-sidebar')).toHaveStyle({ width: '100%', maxWidth: '100%' })
expect(screen.queryByRole('separator')).toBeNull()
})
it('collapses at narrow content widths and can explicitly reopen as an overlay', () => {
let resize: ResizeObserverCallback = () => {}
const disconnect = vi.fn()
@@ -4,11 +4,12 @@ import { X } from 'lucide-react'
import { IconButton } from '@/components/ui/IconButton'
/** The tree collapses before the main split does, but its toolbar can always reopen it. */
export function WorkspaceTreeSidebar({ open, onOpenChange, children, collapseOnNarrow = true }: {
export function WorkspaceTreeSidebar({ open, onOpenChange, children, collapseOnNarrow = true, fullWidth = false }: {
open: boolean
onOpenChange: (open: boolean) => void
children: ReactNode
collapseOnNarrow?: boolean
fullWidth?: boolean
}) {
const t = useTranslation()
const ref = useRef<HTMLDivElement>(null)
@@ -24,12 +25,12 @@ export function WorkspaceTreeSidebar({ open, onOpenChange, children, collapseOnN
if (!entry || entry.contentRect.width === 0) return
const next = entry.contentRect.width < 660
setNarrow(next)
if (next && !wasNarrow && collapseOnNarrow) onOpenChange(false)
if (next && !wasNarrow && collapseOnNarrow && !fullWidth) onOpenChange(false)
wasNarrow = next
})
observer.observe(parent)
return () => observer.disconnect()
}, [collapseOnNarrow, onOpenChange])
}, [collapseOnNarrow, fullWidth, onOpenChange])
useEffect(() => {
const move = (event: PointerEvent) => {
@@ -46,26 +47,28 @@ export function WorkspaceTreeSidebar({ open, onOpenChange, children, collapseOnN
}
}, [])
const overlay = narrow && !fullWidth
return (
<div
ref={ref}
data-testid="workspace-tree-sidebar"
data-overlay={narrow}
data-overlay={overlay}
hidden={!open}
className={`${open ? 'flex' : 'hidden'} min-h-0 shrink-0 flex-col bg-[var(--color-surface)] ${narrow ? 'absolute inset-y-0 right-0 z-[var(--z-drawer)] shadow-[var(--shadow-dropdown)]' : 'relative'}`}
style={{ width, maxWidth: narrow ? '85%' : '48%' }}
className={`${open ? 'flex' : 'hidden'} min-h-0 shrink-0 flex-col bg-[var(--color-surface)] ${overlay ? 'absolute inset-y-0 right-0 z-[var(--z-drawer)] shadow-[var(--shadow-dropdown)]' : 'relative'}`}
style={{ width: fullWidth ? '100%' : width, maxWidth: fullWidth ? '100%' : overlay ? '85%' : '48%' }}
onKeyDown={(event) => {
if (open && narrow && event.key === 'Escape') {
if (open && overlay && event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
onOpenChange(false)
}
}}
>
{narrow ? <div className="flex shrink-0 justify-end border-b border-[var(--color-border)] px-2 py-1">
{overlay ? <div className="flex shrink-0 justify-end border-b border-[var(--color-border)] px-2 py-1">
<IconButton icon={<X size={14} />} size="xs" tone="muted" label={t('common.close')} data-testid="workspace-tree-overlay-close" onClick={() => onOpenChange(false)} />
</div> : null}
<div
{!fullWidth ? <div
role="separator"
aria-orientation="vertical"
aria-label={t('workspace.resizePanel')}
@@ -85,7 +88,7 @@ export function WorkspaceTreeSidebar({ open, onOpenChange, children, collapseOnN
}}
onDoubleClick={() => setWidth(300)}
className="absolute inset-y-0 -left-1 w-2 cursor-col-resize outline-none focus-visible:bg-[var(--color-border-focus)]"
/>
/> : null}
{children}
</div>
)
@@ -211,4 +211,12 @@ describe('WorkspaceFileOpenWith', () => {
expect(openPreview).toHaveBeenCalledWith('s1', 'report.md')
})
it('omits file-only actions for directories, even with a file-like name', () => {
const { queryByRole, getByRole } = render(<WorkspaceFileOpenWith absolutePath="/w/docs.html" workspacePath="docs.html" sessionId="s1" isDirectory />)
expect(queryByRole('menuitem', { name: 'openWith.copyFileContent' })).toBeNull()
expect(queryByRole('menuitem', { name: 'openWith.workspacePreview' })).toBeNull()
expect(queryByRole('menuitem', { name: 'openWith.inAppBrowser' })).toBeNull()
expect(getByRole('menuitem', { name: 'openWith.copyPath' })).toBeInTheDocument()
})
})
@@ -8,8 +8,10 @@ import { TargetIcon } from '@/components/composite/TargetIcon'
import type { OpenTarget } from '@/api/openTargets'
import { fileApplicationTargets, useWorkspaceFileOpenTargets } from '@/components/workspace/workspaceFileOpenTargets'
export function WorkspaceFileOpenWith({ absolutePath, sessionId, workspacePath, onAfterSelect, targets: suppliedTargets, loading, error, onRefresh }: {
export function WorkspaceFileOpenWith({ absolutePath, sessionId, workspacePath, onAfterSelect, targets: suppliedTargets, loading, error, onRefresh, isDirectory = false, onPreview }: {
absolutePath: string
isDirectory?: boolean
onPreview?: () => void
sessionId?: string
workspacePath?: string
onAfterSelect?: () => void
@@ -22,7 +24,7 @@ export function WorkspaceFileOpenWith({ absolutePath, sessionId, workspacePath,
const discovery = useWorkspaceFileOpenTargets(suppliedTargets === undefined ? absolutePath : null)
const targets = suppliedTargets ?? discovery.targets
const applications = fileApplicationTargets(absolutePath, targets)
const context = sessionId && workspacePath
const context = !isDirectory && sessionId && workspacePath
? openWithContextForWorkspaceFile(workspacePath, absolutePath, { sessionId, serverBaseUrl: getServerBaseUrl() })
: { kind: 'file' as const, absolutePath, previewable: false }
const actions = buildOpenWithMenuItems(context, targets, {
@@ -30,7 +32,9 @@ export function WorkspaceFileOpenWith({ absolutePath, sessionId, workspacePath,
}).filter((item) => item.icon === 'copy' || item.id === 'in-app' || item.id === 'preview')
// Previewing again inside an already-open file has no effect. HTML browser
// and clipboard actions remain available because they have distinct results.
const usefulActions = actions.filter((item) => item.id !== 'preview' || suppliedTargets === undefined)
const usefulActions = actions.filter((item) =>
(!isDirectory || item.id === 'copy-path') && (item.id !== 'preview' || suppliedTargets === undefined),
)
const folders = targets.filter((target) => target.kind === 'file_manager')
const selectTarget = (target: OpenTarget) => {
void useOpenTargetStore.getState().openTarget(target.id, absolutePath).catch(() => reportOpenFailure(absolutePath))
@@ -50,7 +54,7 @@ export function WorkspaceFileOpenWith({ absolutePath, sessionId, workspacePath,
))}
{applications.length > 0 && (folders.length > 0 || usefulActions.length > 0 || onRefresh) ? <div className="mx-2 my-1 border-t border-[var(--color-border)]" role="separator" /> : null}
{folders.map((target) => <button key={target.id} type="button" role="menuitem" onClick={() => selectTarget(target)} className={itemClass}>{t('workspace.files.openContainingFolder')}</button>)}
{usefulActions.map((item) => <button key={item.id} type="button" role="menuitem" onClick={() => { item.onSelect(); onAfterSelect?.() }} className={itemClass}><span className="truncate">{item.label}</span></button>)}
{usefulActions.map((item) => <button key={item.id} type="button" role="menuitem" onClick={() => { if (item.id === 'preview' && onPreview) onPreview(); else item.onSelect(); onAfterSelect?.() }} className={itemClass}><span className="truncate">{item.label}</span></button>)}
{onRefresh ? <button type="button" role="menuitem" className={itemClass} onClick={() => { onRefresh(); onAfterSelect?.() }}>{t('workspace.refresh')}</button> : null}
</>
)
+2
View File
@@ -703,6 +703,8 @@ export const en = {
'workspace.files.projectRoot': 'Project root',
'workspace.files.directoryMissing': 'Directory not found: {path}',
'workspace.files.directoryError': 'Unable to read directory: {path}',
'workspace.files.hidePreview': 'Hide preview',
'workspace.files.showPreview': 'Show preview',
'workspace.files.toggleTree': 'Toggle file tree',
'workspace.files.breadcrumb': 'File path',
'workspace.files.noSelection': 'No file selected',
+2
View File
@@ -705,6 +705,8 @@ export const jp: Record<TranslationKey, string> = {
'workspace.files.projectRoot': 'プロジェクトルート',
'workspace.files.directoryMissing': 'ディレクトリが見つかりません: {path}',
'workspace.files.directoryError': 'ディレクトリを読み込めません: {path}',
'workspace.files.hidePreview': 'プレビューを非表示',
'workspace.files.showPreview': 'プレビューを表示',
'workspace.files.toggleTree': 'ファイルツリーの表示切り替え',
'workspace.files.breadcrumb': 'ファイルパス',
'workspace.files.noSelection': 'ファイルが選択されていません',
+2
View File
@@ -707,6 +707,8 @@ export const kr: Record<TranslationKey, string> = {
'workspace.files.projectRoot': '프로젝트 루트',
'workspace.files.directoryMissing': '디렉터리를 찾을 수 없습니다: {path}',
'workspace.files.directoryError': '디렉터리를 읽을 수 없습니다: {path}',
'workspace.files.hidePreview': '미리보기 숨기기',
'workspace.files.showPreview': '미리보기 표시',
'workspace.files.toggleTree': '파일 트리 표시 전환',
'workspace.files.breadcrumb': '파일 경로',
'workspace.files.noSelection': '선택된 파일 없음',
+2
View File
@@ -705,6 +705,8 @@ export const zh: Record<TranslationKey, string> = {
'workspace.files.projectRoot': '專案根目錄',
'workspace.files.directoryMissing': '目錄不存在:{path}',
'workspace.files.directoryError': '無法讀取目錄:{path}',
'workspace.files.hidePreview': '隱藏預覽',
'workspace.files.showPreview': '顯示預覽',
'workspace.files.toggleTree': '顯示/隱藏檔案樹',
'workspace.files.breadcrumb': '檔案路徑',
'workspace.files.noSelection': '未選擇檔案',
+2
View File
@@ -704,6 +704,8 @@ export const zh: Record<TranslationKey, string> = {
'workspace.files.projectRoot': '项目根目录',
'workspace.files.directoryMissing': '目录不存在:{path}',
'workspace.files.directoryError': '无法读取目录:{path}',
'workspace.files.hidePreview': '隐藏预览',
'workspace.files.showPreview': '显示预览',
'workspace.files.toggleTree': '显示/隐藏文件树',
'workspace.files.breadcrumb': '文件路径',
'workspace.files.noSelection': '未选择文件',