mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
Merge workspace tree and preview fixes for #1361
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ファイルが選択されていません',
|
||||
|
||||
@@ -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': '선택된 파일 없음',
|
||||
|
||||
@@ -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': '未選擇檔案',
|
||||
|
||||
@@ -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': '未选择文件',
|
||||
|
||||
Reference in New Issue
Block a user