mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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, {
|
||||
|
||||
@@ -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 } : {}),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user