feat(desktop): open every file-tree pick as its own tab

A single click in the file tree opened a VSCode-style preview tab, and the
next click replaced it — picking four files left one tab, which read as a
hard limit. Every pick now opens a permanent tab (repeat picks of one path
still dedup in the store), and the blank-placeholder takeover extends from
blank browser tabs to the empty Files launcher, so the first pick fills the
launcher tab instead of stranding it.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-14 10:43:07 +08:00
parent 1170f2ddc7
commit 57eac9fbd9
8 changed files with 75 additions and 56 deletions
@@ -20,12 +20,15 @@ vi.mock('../workspace/workspaceFileOpenTargets', () => ({ useWorkspaceFileOpenTa
vi.mock('./WorkspaceFileTreePane', () => ({
WorkspaceFileTreePane: ({ selectedPath, onOpen }: {
selectedPath: string | null
onOpen: (path: string, options: { preview: boolean }) => void
onOpen: (path: string) => void
}) => (
<div data-testid="file-tree-pane" data-selected={selectedPath ?? ''}>
<button type="button" onClick={() => onOpen('src/other.ts', { preview: true })}>
<button type="button" onClick={() => onOpen('src/other.ts')}>
tree row
</button>
<button type="button" onClick={() => onOpen('src/third.ts')}>
second tree row
</button>
</div>
),
}))
@@ -306,14 +309,31 @@ describe('file tree', () => {
expect(screen.getByTestId('file-tree-pane')).toBeInTheDocument()
})
it('opens a tree row as a replaceable preview tab', () => {
it('opens every tree pick as a permanent tab of its own', () => {
renderTab('src/a.ts')
fireEvent.click(screen.getByRole('button', { name: 'tree row' }))
fireEvent.click(screen.getByRole('button', { name: 'second tree row' }))
// Ten picks must mean ten tabs: the old preview slot replaced the previous
// pick, so a row of files collapsed into whichever was clicked last.
const tabs = useWorkspaceStore.getState().getTabs(SESSION, 'side')
expect(tabs).toHaveLength(2)
expect(tabs.map((tab) => tab.kind === 'file' && tab.path)).toEqual(['src/other.ts', 'src/third.ts'])
expect(tabs.every((tab) => !tab.preview)).toBe(true)
})
it('lets the first pick take over the empty Files tab instead of adding beside it', () => {
// Seed the launcher tab the way the "+" menu does, so the store has an
// active blank tab that the pick is expected to replace.
useWorkspaceStore.getState().openTarget(SESSION, { kind: 'file', path: '' }, { preview: true })
renderTab('')
fireEvent.click(screen.getByRole('button', { name: 'tree row' }))
const tabs = useWorkspaceStore.getState().getTabs(SESSION, 'side')
expect(tabs).toHaveLength(1)
expect(tabs[0]).toMatchObject({ kind: 'file', path: 'src/other.ts', preview: true })
expect(tabs[0]).toMatchObject({ kind: 'file', path: 'src/other.ts', preview: false })
})
})
@@ -329,8 +329,11 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
sessionId={sessionId}
selectedPath={path || null}
autoFocus={!path}
onOpen={(nextPath, options) =>
workspaceOpen.file(sessionId, nextPath, { preview: options.preview })}
onOpen={(nextPath) =>
// 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 })}
/>
</WorkspaceTreeSidebar>
</div>
@@ -276,26 +276,17 @@ describe('WorkspaceFileTreePane', () => {
expect(screen.getByTestId('workspace-tree-row-README.md')).toBeInTheDocument()
})
it('previews on a single click and pins on a double click', async () => {
vi.useFakeTimers()
it('opens on a single click, without waiting out a double-click window', async () => {
const onOpen = vi.fn()
render(<WorkspaceFileTreePane sessionId={SESSION} selectedPath={null} onOpen={onOpen} />)
await act(async () => { await Promise.resolve() })
fireEvent.click(screen.getByTestId('workspace-tree-row-README.md'))
act(() => { vi.advanceTimersByTime(300) })
expect(onOpen).toHaveBeenCalledWith('README.md', { preview: true })
onOpen.mockClear()
// A double click must pin *instead of* previewing first — otherwise the file
// is read twice and the tab flickers from italic to upright.
fireEvent.click(screen.getByTestId('workspace-tree-row-README.md'))
fireEvent.click(screen.getByTestId('workspace-tree-row-README.md'))
act(() => { vi.advanceTimersByTime(300) })
// Every click opens its own tab — a deferred preview that a second click
// replaces made ten picks collapse into one slot.
expect(onOpen).toHaveBeenCalledTimes(1)
expect(onOpen).toHaveBeenCalledWith('README.md', { preview: false })
vi.useRealTimers()
expect(onOpen).toHaveBeenCalledWith('README.md')
})
it('marks the row the content area is showing', async () => {
@@ -406,7 +397,6 @@ describe('keyboard', () => {
})
it('opens the focused file with Enter and with Space', async () => {
vi.useFakeTimers()
const onOpen = vi.fn()
render(<WorkspaceFileTreePane sessionId={SESSION} selectedPath={null} onOpen={onOpen} />)
await act(async () => { await Promise.resolve() })
@@ -414,14 +404,11 @@ describe('keyboard', () => {
const readme = screen.getByTestId('workspace-tree-row-README.md')
focusRow(readme)
fireEvent.keyDown(readme, { key: 'Enter' })
act(() => { vi.advanceTimersByTime(300) })
expect(onOpen).toHaveBeenCalledWith('README.md', { preview: true })
expect(onOpen).toHaveBeenCalledWith('README.md')
onOpen.mockClear()
fireEvent.keyDown(readme, { key: ' ' })
act(() => { vi.advanceTimersByTime(300) })
expect(onOpen).toHaveBeenCalledWith('README.md', { preview: true })
vi.useRealTimers()
expect(onOpen).toHaveBeenCalledWith('README.md')
})
it('gives the tab stop to the row the content area is showing', async () => {
@@ -14,8 +14,8 @@ export type WorkspaceFileTreePaneProps = {
sessionId: string
/** Highlighted row; the path of whatever the content area is showing. */
selectedPath: string | null
/** Single click previews (replaceable tab), double click pins. */
onOpen: (path: string, options: { preview: boolean }) => void
/** Every activation opens its own tab; repeat opens of one path dedup in the store. */
onOpen: (path: string) => void
autoFocus?: boolean
}
@@ -95,7 +95,6 @@ export function WorkspaceFileTreePane({
controller.abort()
}
}, [filter, sessionId])
const clickTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
const loadTree = useWorkspaceContentStore((state) => state.loadTree)
const toggleDirectory = useWorkspaceContentStore((state) => state.toggleDirectory)
const treeByKey = useWorkspaceContentStore((state) => state.treeByKey)
@@ -120,11 +119,6 @@ export function WorkspaceFileTreePane({
// scrolling elsewhere must remain under the user's control.
}, [filter, loadTree, selectedTreePath, sessionId, toggleDirectory])
useEffect(() => () => {
for (const timer of clickTimers.current.values()) clearTimeout(timer)
clickTimers.current.clear()
}, [sessionId])
const expanded = useMemo(
() => new Set(expandedBySession[sessionId] ?? []),
[expandedBySession, sessionId],
@@ -229,21 +223,7 @@ export function WorkspaceFileTreePane({
void toggleDirectory(sessionId, row.path)
return
}
// Defer the preview open by one double-click window so a double click
// pins instead of previewing-then-pinning, which would otherwise load the
// file twice.
const pending = clickTimers.current.get(row.path)
if (pending) {
clearTimeout(pending)
clickTimers.current.delete(row.path)
onOpen(row.path, { preview: false })
return
}
const timer = setTimeout(() => {
clickTimers.current.delete(row.path)
onOpen(row.path, { preview: true })
}, 220)
clickTimers.current.set(row.path, timer)
onOpen(row.path)
}
const { activePath, handleKeyDown, registerRow, setFocusedPath } = useRovingTree(rows, {
+2
View File
@@ -43,6 +43,7 @@ export const workspaceOpen = {
column?: number
preview?: boolean
background?: boolean
replaceBlankPlaceholder?: boolean
origin?: WorkspaceOrigin
}) {
return openWorkspaceTarget({
@@ -56,6 +57,7 @@ export const workspaceOpen = {
},
...(options?.preview ? { preview: true } : {}),
...(options?.background ? { background: true } : {}),
...(options?.replaceBlankPlaceholder ? { replaceBlankPlaceholder: true } : {}),
...(options?.origin ? { origin: options.origin } : {}),
})
},
+10 -4
View File
@@ -179,10 +179,11 @@ export type WorkspaceOpenOptions = {
*/
requestedBy?: 'user' | 'agent'
/**
* Let this open take over a blank new-tab page instead of adding a tab.
* Let this open take over a blank placeholder tab instead of adding a tab.
*
* Only the content picker sets it. The rule is narrow on purpose: a browser
* tab counts as "blank" until the host reports a committed URL, so between
* The rule is narrow on purpose: only the *active* tab is eligible, a browser
* tab counts as "blank" until the host reports a committed URL, and a file
* tab counts as "blank" while no path is chosen (the Files launcher). Between
* pressing Enter in the address bar and the page committing, *any* other open
* would otherwise destroy the page that is mid-load.
*/
@@ -230,11 +231,16 @@ export function isReviewTab(tab: WorkspaceTab): tab is WorkspaceReviewTab {
return tab.kind === 'review'
}
/** A blank browser tab is the one placeholder another kind may replace. */
/** A blank browser tab is one placeholder another kind may replace. */
export function isBlankBrowserTab(tab: WorkspaceTab): boolean {
return tab.kind === 'browser' && !tab.url
}
/** The Files launcher with nothing chosen yet is the other placeholder. */
export function isBlankFileTab(tab: WorkspaceTab): boolean {
return tab.kind === 'file' && tab.path === ''
}
export function basenameOf(path: string): string {
const segments = path.split(/[\\/]/).filter(Boolean)
return segments[segments.length - 1] ?? path
+19
View File
@@ -225,6 +225,25 @@ describe('preview and pinning', () => {
expect(mocks.releaseWorkspaceBrowserTab).toHaveBeenCalledTimes(1)
})
it('lets a tree pick take over the active empty Files tab', () => {
// The Files launcher with nothing chosen is the same kind of placeholder:
// the first pick loads into its slot instead of stranding an empty tab.
store().openTarget(SESSION, { kind: 'file', path: '' }, { preview: true })
store().openTarget(SESSION, { kind: 'file', path: 'a.ts' }, { replaceBlankPlaceholder: true })
expect(sideTabs()).toHaveLength(1)
expect(sideTabs()[0]).toMatchObject({ kind: 'file', path: 'a.ts', preview: false })
})
it('keeps the empty Files tab when the pick lands from a non-placeholder tab', () => {
openFile('a.ts')
store().openTarget(SESSION, { kind: 'file', path: '' }, { preview: true, background: true })
store().openTarget(SESSION, { kind: 'file', path: 'b.ts' }, { replaceBlankPlaceholder: true })
// The placeholder is not the active tab, so the open must add, not replace.
expect(sideTabs().map((tab) => (tab as { path: string }).path)).toEqual(['a.ts', '', 'b.ts'])
})
it('leaves a blank browser tab alone for every other opener', () => {
store().openTarget(SESSION, { kind: 'browser' })
openFile('a.ts')
+6 -4
View File
@@ -6,6 +6,7 @@ import {
DEFAULT_REVIEW_SOURCE,
basenameOf,
isBlankBrowserTab,
isBlankFileTab,
reviewSourceKey,
type WorkspaceBrowserTab,
type WorkspaceClosedGroup,
@@ -526,14 +527,15 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
}
// --- Creation --------------------------------------------------------
// A preview open takes over the existing preview slot. A blank browser
// page is the other replaceable tab: picking a different kind from the
// launcher should not leave an empty new-tab page behind.
// A preview open takes over the existing preview slot. Placeholder tabs
// are the other replaceable kind: picking a file while the empty Files
// launcher is showing, or picking a different kind on a blank browser
// page, should not leave the empty tab behind.
const previewVictim = preview
? dockTabs.find((tab) => tab.preview)
: undefined
const blankVictim = options.replaceBlankPlaceholder === true && !preview && target.kind !== 'browser'
? dockTabs.find((tab) => tab.id === activeId && isBlankBrowserTab(tab))
? dockTabs.find((tab) => tab.id === activeId && (isBlankBrowserTab(tab) || isBlankFileTab(tab)))
: undefined
const replaceTabId = previewVictim?.id ?? blankVictim?.id