feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取

文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)

文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染

全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步

验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
This commit is contained in:
Menghuan1918
2026-09-28 00:27:31 +08:00
parent 606339f81e
commit 36fbc5ac7e
76 changed files with 6961 additions and 3944 deletions
+3 -3
View File
@@ -154,9 +154,9 @@ describe('built-in tab registrations', () => {
// The workspace fence switch rides the same card as a plain boolean row.
expect(toggles[1]?.title).toBeDefined()
expect(toggles[1]?.desc).toBeDefined()
// The open-with configuration (SSH host + custom editors) is the custom
// panel rendered below the declarative rows.
expect(service.getTab('editor')?.settings?.render).toBeDefined()
// External opening is the host's own open-in-app capability now: the
// editor card has no custom settings panel left to render.
expect(service.getTab('editor')?.settings?.render).toBeUndefined()
})
it('every built-in tab carries the settings-surface icon', () => {
+259 -23
View File
@@ -1,18 +1,23 @@
/**
* The unified changes tab shell: the lens switcher swaps the Git lens for
* the session lens, a Git-lens file row opens the shared preview pane
* (loaded through the mocked git API, rendered by the shared DiffFiles
* stack), and the session ops ride the mocked `changes.ops` poll — the
* event poll, the op fold and the badge cache all live in the tab.
* The unified changes tab shell: the 36px header's lens switcher swaps the
* Git lens for the session lens, a Git-lens file row opens the shared preview
* pane (loaded through the mocked git API, rendered by the shared DiffFiles
* stack), and the session ops ride the mocked `changes.ops` poll — the event
* poll, the op fold and the badge cache all live in the tab.
*
* The copy assertions below pin the zh strings (the test environment's
* navigator may be a real Node one with an en locale), so a bare substring
* check cannot accidentally pass on a different message.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { ChangesTab, opCountOf } from '../src/client/changes/ChangesTab.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { api, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
import { t } from '../src/client/locales.ts'
import type { Context } from '../src/context-types.ts'
import type { SidebarTab } from '../src/client/state.ts'
@@ -42,7 +47,7 @@ function mount(root: Root, tab: SidebarTab = { id: 'git', type: 'git', title: 'C
store: createSidebarStore(),
scope: { sessionId: 'session', cwd: MAIN },
tab,
visible: false,
visible: true,
onOpenFile: () => {},
onOpenDiff: () => {},
}))
@@ -60,11 +65,21 @@ function mockGit(entries: Array<{ path: string; xy: string }>): void {
vi.spyOn(api, 'gitLog').mockResolvedValue([])
}
/** One lens tab of the header's segmented control. */
function lensTab(container: HTMLElement, label: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll<HTMLButtonElement>('[role="tab"]')]
.find(tab => tab.textContent === label)
}
async function flushEffects(): Promise<void> {
await act(async () => { await Promise.resolve() })
await act(async () => { await Promise.resolve() })
}
beforeEach(() => {
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
@@ -91,10 +106,11 @@ describe('ChangesTab', () => {
mount(root)
await flushEffects()
// Git lens: the file row from the mocked status (badge letter + path).
const row = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.includes('src/a.ts'))
// Git lens: the changed-file row (badge letter + split name/directory).
const row = container.querySelector<HTMLButtonElement>('button[data-path="src/a.ts"]')
expect(row).toBeDefined()
expect(row!.textContent).toContain('a.ts')
expect(row!.textContent).toContain('src')
// Clicking the row previews inline (no diff tab minted): the shared
// renderer draws the added row.
@@ -103,11 +119,9 @@ describe('ChangesTab', () => {
expect(container.textContent).toContain('new')
// The preview survives a lens switch (it is a dock, not lens state).
const group = container.querySelector('[role="group"]')
const sessionButton = [...group!.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.getAttribute('aria-pressed') === 'false')
expect(sessionButton).toBeDefined()
await act(async () => { sessionButton!.click() })
const sessionTab = lensTab(container, t('changesSessionLens'))
expect(sessionTab).toBeDefined()
await act(async () => { sessionTab!.click() })
expect(container.textContent).toContain('src/a.ts')
} finally {
act(() => { root.unmount() })
@@ -127,13 +141,13 @@ describe('ChangesTab', () => {
mount(root)
await flushEffects()
// The tab pulled once on mount and published the op count.
// Opening the tab pulls once and publishes the op count (the tab-strip
// badge reads this cache).
expect(ops).toHaveBeenCalled()
expect(opCountOf('session')).toBe(2)
const group = container.querySelector('[role="group"]')
const buttons = [...group!.querySelectorAll<HTMLButtonElement>('button')]
await act(async () => { buttons[1]!.click() })
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
// Two files grouped, newest first; the write chip carries its count.
expect(container.textContent).toContain('src/b.ts')
expect(container.textContent).toContain('src/a.ts')
@@ -168,10 +182,8 @@ describe('ChangesTab', () => {
await flushEffects()
// Session lens: open the read op's preview.
const group = container.querySelector('[role="group"]')
const sessionButton = [...group!.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.getAttribute('aria-pressed') === 'false')
await act(async () => { sessionButton!.click() })
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
const row = container.querySelector<HTMLButtonElement>('button[data-op-kind="read"]')
expect(row).toBeDefined()
expect(container.textContent).toContain('notes.md')
@@ -201,4 +213,228 @@ describe('ChangesTab', () => {
container.remove()
}
})
it('renders exactly one clean empty state, and the history has its own', async () => {
mockGit([])
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
// A clean repository says it ONCE: both sections (unstaged/staged) used
// to print their own "no changes" line under the same message.
const empties = [...container.querySelectorAll<HTMLElement>('[data-kind="empty"]')]
.map(node => node.textContent)
expect(empties).toEqual([t('changesClean'), t('changesNoHistory')])
// The two section bands are not rendered at all when nothing changed.
expect(container.querySelectorAll('[data-path]')).toHaveLength(0)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('reports a failed stage action in the commit bar and catches it (no unhandled rejection)', async () => {
mockGit([{ path: 'src/a.ts', xy: ' M' }])
vi.spyOn(api, 'gitStage').mockRejectedValue(new Error('index.lock exists'))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
const stage = container.querySelector<HTMLButtonElement>(`button[aria-label="${t('stage')}"]`)
expect(stage).toBeDefined()
await act(async () => { stage!.click() })
await flushEffects()
const alert = [...container.querySelectorAll<HTMLElement>('[role="alert"]')]
.find(node => (node.textContent ?? '').includes(t('changesStageFailed', { message: 'index.lock exists' })))
expect(alert).toBeDefined()
// The ONE status line sits in the commit bar, directly under its input row.
const input = container.querySelector(`input[placeholder="${t('commitPlaceholder')}"]`)
expect(input).not.toBeNull()
expect(alert!.previousElementSibling?.contains(input)).toBe(true)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('re-derives the previewed op from the latest fold when it settles', async () => {
mockGit([])
vi.spyOn(api, 'changesOps')
// The call starts running: no result yet, so the write has no payload.
.mockResolvedValueOnce({
events: [{ type: 'tool/call', seq: 1, time: 1, data: { name: 'write', callId: 'w1', arguments: JSON.stringify({ file_path: 'notes.txt' }) } }],
lastSeq: 1,
})
// The result lands in a later delta and settles the same callId.
.mockResolvedValueOnce({
events: [{ type: 'tool/result', seq: 2, time: 2, data: { message: { source: { kind: 'tool', callId: 'w1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: 'settled-body' }] }] } } }],
lastSeq: 2,
})
.mockResolvedValue({ events: [], lastSeq: 2 })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
const row = container.querySelector<HTMLButtonElement>('button[data-op-kind="write"]')
expect(row).toBeDefined()
await act(async () => { row!.click() })
await flushEffects()
expect(container.textContent).toContain(t('changesPriorUnknown'))
expect(container.textContent).not.toContain('settled-body')
// The header's refresh pulls the delta; the SAME preview must pick the
// settled payload up without the user re-clicking the row.
const refresh = container.querySelector<HTMLButtonElement>(`button[aria-label="${t('refresh')}"]`)
expect(refresh).not.toBeNull()
await act(async () => { refresh!.click() })
await flushEffects()
expect(container.textContent).toContain('settled-body')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('shows a history failure instead of claiming there are no commits', async () => {
mockGit([])
vi.spyOn(api, 'gitLog').mockRejectedValue(new Error('bad object'))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
expect(container.textContent).toContain(t('historyLoadError'))
expect(container.textContent).not.toContain(t('changesNoHistory'))
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('keeps the preview redaction toggle working (a secret file masks by default)', async () => {
mockGit([])
localStorage.removeItem('dsh-sidebar:v1:redaction')
vi.spyOn(api, 'changesOps').mockResolvedValue({
events: [
{ type: 'tool/call', seq: 1, time: 1, data: { name: 'read', callId: 'r1', arguments: JSON.stringify({ file_path: 'C:/repo/main/.env' }) } },
{ type: 'tool/result', seq: 2, time: 2, data: { message: { source: { kind: 'tool', callId: 'r1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: '<content>1: OPENAI_API_KEY=sk-abcdefghijklmnop</content>' }] }] } } },
],
lastSeq: 2,
})
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
const row = container.querySelector<HTMLButtonElement>('button[data-op-kind="read"]')
await act(async () => { row!.click() })
await flushEffects()
// Redaction is on by default: the secret never reaches the DOM.
expect(container.textContent).toContain('[REDACTED]')
expect(container.textContent).not.toContain('sk-abcdefghijklmnop')
// The kit toggle (a Chip) turns it off, and only then does the raw value
// render; toggling back masks it again.
const byLabel = (label: string): HTMLButtonElement | undefined =>
[...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => (button.textContent ?? '') === label)
const on = byLabel(t('changesRedactOnLabel'))
expect(on).toBeDefined()
await act(async () => { on!.click() })
await flushEffects()
expect(container.textContent).toContain('sk-abcdefghijklmnop')
const off = byLabel(t('changesRedactOffLabel'))
expect(off).toBeDefined()
await act(async () => { off!.click() })
await flushEffects()
expect(container.textContent).not.toContain('sk-abcdefghijklmnop')
} finally {
act(() => { root.unmount() })
container.remove()
localStorage.removeItem('dsh-sidebar:v1:redaction')
}
})
it('clamps the restored preview height to the pane bounds', async () => {
mockGit([{ path: 'src/a.ts', xy: ' M' }])
vi.spyOn(api, 'gitDiff').mockResolvedValue({
diff: 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n',
})
/** Open the pane with one persisted height and read back the rendered one. */
const paneHeightFor = async (previewH: number): Promise<string | undefined> => {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root, { id: 'git', type: 'git', title: 'Changes', meta: { previewH } })
await flushEffects()
const row = container.querySelector<HTMLButtonElement>('button[data-path="src/a.ts"]')
expect(row).not.toBeNull()
await act(async () => { row!.click() })
await flushEffects()
return container.querySelector<HTMLElement>('[role="separator"]')?.parentElement?.style.height
} finally {
act(() => { root.unmount() })
container.remove()
}
}
// A height saved under a taller window cannot open past the pane's cap...
expect(await paneHeightFor(5000)).toBe(`${String(Math.round(window.innerHeight * 0.7))}px`)
// ...and an unusably small one is raised to the content minimum.
expect(await paneHeightFor(50)).toBe('140px')
})
it('polls the session events only while the session lens is on screen', async () => {
vi.useFakeTimers()
mockGit([])
const ops = vi.spyOn(api, 'changesOps').mockResolvedValue({ events: [], lastSeq: 0 })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mount(root)
await flushEffects()
// Opening the tab is one catch-up pull (the badge count follows it).
expect(ops).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
// The git lens has no use for the session event log: no cadence at all.
expect(ops).toHaveBeenCalledTimes(1)
const sessionTab = lensTab(container, t('changesSessionLens'))
await act(async () => { sessionTab!.click() })
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
// On screen: the 2.5s delta cadence runs.
expect(ops.mock.calls.length).toBeGreaterThan(1)
} finally {
act(() => { root.unmount() })
container.remove()
vi.useRealTimers()
}
})
})
+184
View File
@@ -0,0 +1,184 @@
/**
* The shared git-diff loader (`diff/use-git-diff.ts`) sits behind BOTH diff
* surfaces: the changes tab's inline preview pane and the dedicated diff tab.
* These tests drive each surface through the hook — the staged-side fallback,
* the untracked full-addition fallback, and the per-file fold cache two
* sibling folds share — so a future change to one surface cannot silently
* change the other.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { DiffPane } from '../src/client/changes/DiffPane.tsx'
import { DiffTab } from '../src/client/DiffTab.tsx'
import { api } from '../src/client/api.ts'
import type { SessionScope } from '../src/client/api.ts'
import type { SidebarDiffRef } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const SCOPE: SessionScope = { sessionId: 's1', cwd: '/p' }
const STAGED_PATCH = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1 +1 @@',
'-old',
'+staged-side',
].join('\n')
/** A three-hunk patch of one file: two gap folds (lines 2..11 and 13..29). */
const TWO_GAP_PATCH = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1 +1 @@',
'-old',
'+new',
'@@ -12 +12 @@',
'-tail',
'+tail2',
'@@ -30 +30 @@',
'-end',
'+end2',
].join('\n')
const CONTENT = Array.from({ length: 40 }, (_value, index) => `line-${String(index + 1)}`).join('\n')
async function flushEffects(): Promise<void> {
for (let round = 0; round < 4; round += 1) await act(async () => { await Promise.resolve() })
}
afterEach(() => { vi.restoreAllMocks() })
describe('useGitDiffTarget through the inline preview pane (DiffPane)', () => {
it('falls back to the other side when the requested side has no diff', async () => {
const worktree: SidebarDiffRef = { kind: 'worktree', path: 'src/a.ts', staged: false }
const gitDiff = vi.spyOn(api, 'gitDiff').mockImplementation(async (_scope, _path, staged) => ({
diff: staged ? STAGED_PATCH : '',
}))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffPane, {
target: { kind: 'git', ref: worktree },
scope: SCOPE,
height: 300,
onHeightCommit: () => {},
onClose: () => {},
onExpand: () => {},
}))
})
await flushEffects()
// The requested (unstaged) side came back empty, so the pane retried the
// staged side and rendered THAT patch.
expect(gitDiff).toHaveBeenNthCalledWith(1, expect.anything(), 'src/a.ts', false, undefined)
expect(gitDiff).toHaveBeenNthCalledWith(2, expect.anything(), 'src/a.ts', true, undefined)
expect(container.textContent).toContain('staged-side')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('renders an untracked file as a full-file addition from its content', async () => {
const worktree: SidebarDiffRef = { kind: 'worktree', path: 'src/new.ts', staged: false, untracked: true }
vi.spyOn(api, 'gitDiff').mockResolvedValue({ diff: '' })
const read = vi.spyOn(api, 'fsRead').mockResolvedValue({ kind: 'text', content: 'brand-new-line', truncated: false })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffPane, {
target: { kind: 'git', ref: worktree },
scope: SCOPE,
height: 300,
onHeightCommit: () => {},
onClose: () => {},
onExpand: () => {},
}))
})
await flushEffects()
expect(read).toHaveBeenCalledWith(expect.anything(), '/p/src/new.ts')
expect(container.textContent).toContain('brand-new-line')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
describe('useGitDiffTarget through the dedicated diff tab (DiffTab)', () => {
it('loads a commit patch into the shared renderer', async () => {
vi.spyOn(api, 'gitCommitDiff').mockResolvedValue({ diff: STAGED_PATCH })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffTab, {
sessionId: 's1',
cwd: '/p',
diff: { kind: 'commit', hash: 'abc1234', hashFull: 'a'.repeat(40), subject: 'subject' },
}))
})
await flushEffects()
expect(container.textContent).toContain('staged-side')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('fetches each side of a file ONCE for two sibling fold expansions', async () => {
vi.spyOn(api, 'gitDiff').mockResolvedValue({ diff: TWO_GAP_PATCH })
const show = vi.spyOn(api, 'gitShow').mockResolvedValue({ content: CONTENT })
const read = vi.spyOn(api, 'fsRead').mockResolvedValue({ kind: 'text', content: CONTENT, truncated: false })
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
act(() => {
root.render(createElement(DiffTab, {
sessionId: 's1',
cwd: '/p',
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
}))
})
await flushEffects()
const firstFold = container.querySelector<HTMLElement>('[data-expandable="true"]')
expect(firstFold).not.toBeNull()
await act(async () => { firstFold!.click() })
expect(show).toHaveBeenCalledTimes(1)
expect(read).toHaveBeenCalledTimes(1)
expect(container.textContent).toContain('line-5')
// The second gap is the SAME file: the cache serves it without a fetch.
// (An expanded fold keeps its marker in the DOM, so the second one is
// the second marker in document order.)
const folds = container.querySelectorAll<HTMLElement>('[data-expandable="true"]')
expect(folds).toHaveLength(2)
await act(async () => { folds[1]!.click() })
expect(show).toHaveBeenCalledTimes(1)
expect(read).toHaveBeenCalledTimes(1)
expect(container.textContent).toContain('line-20')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
+158
View File
@@ -0,0 +1,158 @@
/**
* The client half of the file tree's live refresh: one socket per session,
* the expanded set announced as `watch` frames, directories that left the
* set unwatched, and a `dir`-only frame routed to the staleness callback
* while a refusal (`ok: false`) is NOT (a fenced/unwatchable directory must
* not re-list forever).
*
* The identity guard: the hook derives its wanted set from the CONTENT of
* `dirs` (a NUL-joined key), so a caller re-rendering with a fresh array of
* the same directories reconciles nothing.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { useDirectoryWatch } from '../src/client/use-dir-watch.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
/** The smallest WebSocket the hook talks to: `send` records the frames. */
class FakeSocket {
static instances: FakeSocket[] = []
static readonly OPEN = 1
static readonly CONNECTING = 0
readonly url: string
readyState = FakeSocket.CONNECTING
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onclose: (() => void) | null = null
closed = false
constructor(url: string) {
this.url = url
FakeSocket.instances.push(this)
}
send(frame: string): void { this.sent.push(frame) }
close(): void { this.closed = true; this.readyState = 3 }
/** The host's connection acknowledgement. */
open(): void { this.readyState = FakeSocket.OPEN; this.onopen?.() }
/** One host frame (a watch confirmation, a refusal, or a stale notice). */
frame(payload: unknown): void { this.onmessage?.({ data: JSON.stringify(payload) }) }
/** The frames the hook sent, as parsed objects. */
frames(): { op: string; path: string }[] {
return this.sent.map(frame => JSON.parse(frame) as { op: string; path: string })
}
}
function Probe(props: { dirs: string[]; onStale: (dir: string) => void }): ReactNode {
useDirectoryWatch({ sessionId: 's1', root: '/tmp', dirs: props.dirs, onStale: props.onStale })
return null
}
interface Harness {
rerender: (dirs: string[]) => void
socket: () => FakeSocket
unmount: () => void
}
function mountProbe(onStale: (dir: string) => void): Harness {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const render = (dirs: string[]): void => { root.render(createElement(Probe, { dirs, onStale })) }
act(() => { render(['/tmp/a']) })
return {
rerender: (dirs: string[]) => { act(() => { render(dirs) }) },
socket: () => {
const socket = FakeSocket.instances[0]
if (socket === undefined) throw new Error('no socket opened')
return socket
},
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
beforeEach(() => {
FakeSocket.instances = []
vi.stubGlobal('WebSocket', FakeSocket)
})
afterEach(() => {
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
describe('useDirectoryWatch', () => {
it('announces the root plus the expanded set once the socket opens', () => {
const harness = mountProbe(() => {})
const socket = harness.socket()
expect(socket.url).toContain('/sidebar/ws/fs-watch')
expect(socket.url).toContain('sessionId=s1')
// Nothing is sent before the connection opens.
expect(socket.sent).toEqual([])
act(() => { socket.open() })
expect(socket.frames()).toEqual([
{ op: 'watch', path: '/tmp' },
{ op: 'watch', path: '/tmp/a' },
])
harness.unmount()
})
it('sends nothing when a re-render repeats the same directories in a fresh array', () => {
const harness = mountProbe(() => {})
const socket = harness.socket()
act(() => { socket.open() })
const before = socket.sent.length
harness.rerender(['/tmp/a'])
expect(FakeSocket.instances).toHaveLength(1)
expect(socket.sent.length).toBe(before)
harness.unmount()
})
it('watches the new directory and unwatches the one the host confirmed', () => {
const harness = mountProbe(() => {})
const socket = harness.socket()
act(() => { socket.open() })
// The host confirms both watches with its own resolved paths.
act(() => {
socket.frame({ dir: '/tmp', ok: true })
socket.frame({ dir: '/tmp/a', ok: true })
})
harness.rerender(['/tmp/b'])
expect(socket.frames().slice(-3)).toEqual([
{ op: 'watch', path: '/tmp' },
{ op: 'watch', path: '/tmp/b' },
{ op: 'unwatch', path: '/tmp/a' },
])
harness.unmount()
})
it('routes a stale notice to the callback and ignores a refusal', () => {
const onStale = vi.fn()
const harness = mountProbe(onStale)
const socket = harness.socket()
act(() => { socket.open() })
// A refusal (fenced path / watcher cap) must NOT re-list.
act(() => { socket.frame({ dir: '/tmp/a', ok: false }) })
expect(onStale).not.toHaveBeenCalled()
act(() => { socket.frame({ dir: '/tmp/a' }) })
expect(onStale).toHaveBeenCalledWith('/tmp/a')
// A malformed frame is ignored outright.
act(() => { socket.frame({ nope: 1 }) })
expect(onStale).toHaveBeenCalledTimes(1)
harness.unmount()
})
it('closes the socket on unmount', () => {
const harness = mountProbe(() => {})
const socket = harness.socket()
act(() => { socket.open() })
harness.unmount()
expect(socket.closed).toBe(true)
})
})
+3
View File
@@ -36,6 +36,9 @@ vi.mock('../src/client/api.ts', () => ({
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
],
}),
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about the drop surface).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
}))
+145
View File
@@ -0,0 +1,145 @@
/**
* FileTree git ink (VS Code style): the tree reads the plugin's SHARED
* `useGitStatus` store — a changed file's name takes its tone class and a
* letter badge whose tooltip is the status word, a directory with a change
* anywhere below it is tinted without a letter, a clean file stays plain, and
* a non-repo (or unavailable git) degrades silently to plain rows.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import type { GitStatusResult } from '../src/client/api.ts'
import { FileTree } from '../src/client/FileTree.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so the badge tooltip is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
const { gitStatus } = vi.hoisted(() => ({ gitStatus: vi.fn() }))
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: async () => ({
path: '/tmp',
entries: [
{ name: 'sub', path: '/tmp/sub', isDir: true },
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
],
truncated: false,
}),
gitStatus,
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
const REPO: GitStatusResult = {
isRepo: true,
root: '/tmp',
entries: [
{ path: 'a.ts', xy: ' M' },
// A change strictly BELOW /tmp/sub: the directory is tinted, the file
// itself is not rendered (its level is collapsed).
{ path: 'sub/inner.ts', xy: '??' },
],
}
interface Harness {
container: HTMLDivElement
unmount: () => void
}
async function mountTree(): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 'git-spec',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
// Settle the shared store's fetch (it starts in FileTree's mount effect).
await Promise.resolve()
await Promise.resolve()
})
return {
container,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** One tree row by its displayed name. */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
function nameSpan(row: HTMLElement): HTMLElement {
return row.querySelector<HTMLElement>('[class*="explorerName"]')!
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
gitStatus.mockReset()
})
describe('FileTree git ink', () => {
it('tints a changed file name and badges its porcelain letter', async () => {
gitStatus.mockResolvedValue(REPO)
harness = await mountTree()
const row = rowByName(harness.container, 'a.ts')
expect(nameSpan(row).getAttribute('data-git-tone')).toBe('modified')
expect(nameSpan(row).className).toContain('explorerGitName')
const badge = row.querySelector<HTMLElement>('[class*="statusBadge"]')
expect(badge?.textContent).toBe('M')
expect(badge?.getAttribute('data-tone')).toBe('modified')
expect(badge?.getAttribute('title')).toBe('Modified')
})
it('tints a directory that contains a change below it, without a letter', async () => {
gitStatus.mockResolvedValue(REPO)
harness = await mountTree()
const row = rowByName(harness.container, 'sub')
expect(nameSpan(row).className).toContain('explorerDirChanged')
expect(row.querySelector('[class*="statusBadge"]')).toBeNull()
})
it('leaves a clean file plain', async () => {
gitStatus.mockResolvedValue(REPO)
harness = await mountTree()
const row = rowByName(harness.container, 'b.ts')
expect(nameSpan(row).getAttribute('data-git-tone')).toBeNull()
expect(row.querySelector('[class*="statusBadge"]')).toBeNull()
})
it('degrades silently outside a repository', async () => {
gitStatus.mockResolvedValue({ isRepo: false, entries: [] })
harness = await mountTree()
const file = rowByName(harness.container, 'a.ts')
expect(nameSpan(file).getAttribute('data-git-tone')).toBeNull()
expect(nameSpan(file).className).not.toContain('explorerGitName')
const dir = rowByName(harness.container, 'sub')
expect(nameSpan(dir).className).not.toContain('explorerDirChanged')
})
})
+246
View File
@@ -0,0 +1,246 @@
/**
* FileTree multi-selection (VS Code semantics): Ctrl/Cmd+click toggles one
* row and seats the Shift anchor, Shift+click selects the visible range from
* that anchor, Escape and a blank click clear the selection, and a right
* click outside the selection collapses it onto the row. A non-empty
* selection shows the batch bar (count + copy paths / delete / clear); the
* batch delete confirms once, removes sequentially, and stops on the first
* failure with the error strip.
*
* The other half of the contract is what must NOT change: an unmodified click
* still opens a file / toggles a directory and drops the selection.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so the bar's copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
const { fsRemove, fsTree } = vi.hoisted(() => ({
fsRemove: vi.fn(async (_scope: unknown, path: string) => ({ path })),
fsTree: vi.fn(async () => ({
path: '/tmp',
entries: [
{ name: 'sub', path: '/tmp/sub', isDir: true },
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
],
truncated: false,
})),
}))
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree,
fsRemove,
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about the selection).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
interface Harness {
container: HTMLDivElement
opened: string[]
toggled: string[]
unmount: () => void
}
async function mountTree(expanded: string[] = []): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const opened: string[] = []
const toggled: string[] = []
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded,
revealed: [],
onToggle: (path: string) => { toggled.push(path) },
onOpenFile: (path: string) => { opened.push(path) },
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
})
return {
container,
opened,
toggled,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** One tree row by its displayed name. */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
function click(el: Element, init: MouseEventInit = {}): void {
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init })) })
}
function rightClick(el: Element): void {
act(() => {
el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }))
})
}
function pressEscape(el: Element): void {
act(() => { el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })) })
}
/** The batch bar (the kit's section band carries the page class). */
function selectionBar(container: HTMLElement): HTMLElement | null {
return container.querySelector<HTMLElement>('[class*="explorerSelectionBar"]')
}
/** The names of the rows currently rendered as selected. */
function selectedNames(container: HTMLElement): string[] {
return [...container.querySelectorAll<HTMLElement>('[class*="explorerRowSelected"]')]
.map(row => row.querySelector('[class*="explorerName"]')?.textContent ?? '')
}
/** One batch-bar action by its label. */
function barAction(container: HTMLElement, label: string): HTMLElement {
const button = [...(selectionBar(container)?.querySelectorAll<HTMLElement>('button') ?? [])]
.find(el => el.textContent === label)
if (button === undefined) throw new Error(`bar action not found: ${label}`)
return button
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fsRemove.mockReset()
fsRemove.mockImplementation(async (_scope: unknown, path: string) => ({ path }))
fsTree.mockClear()
})
describe('FileTree multi-selection', () => {
it('keeps the unmodified click semantics and drops the selection', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
expect(selectionBar(harness.container)).not.toBeNull()
// A plain click opens the file (and clears the batch).
click(rowByName(harness.container, 'b.ts'))
expect(harness.opened).toEqual(['/tmp/b.ts'])
expect(selectionBar(harness.container)).toBeNull()
// A plain click on a directory row still toggles it.
click(rowByName(harness.container, 'sub'))
expect(harness.toggled).toEqual(['/tmp/sub'])
expect(harness.opened).toEqual(['/tmp/b.ts'])
})
it('Ctrl/Cmd+click toggles rows and the bar counts them', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
expect(selectedNames(harness.container)).toEqual(['a.ts'])
expect(selectionBar(harness.container)?.textContent).toContain('1 selected')
click(rowByName(harness.container, 'b.ts'), { metaKey: true })
expect(selectedNames(harness.container)).toEqual(['a.ts', 'b.ts'])
expect(selectionBar(harness.container)?.textContent).toContain('2 selected')
// Toggling the same row off again leaves exactly the other one.
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
expect(selectedNames(harness.container)).toEqual(['a.ts'])
})
it('Shift+click selects the visible range from the Ctrl-seated anchor', async () => {
harness = await mountTree()
// The anchor is 'b.ts' (the last visible row), so the range reaches 'a.ts'.
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'a.ts'), { shiftKey: true })
expect(selectedNames(harness.container)).toEqual(['a.ts', 'b.ts'])
// A collapsed directory between two rows is part of the visible order.
click(rowByName(harness.container, 'sub'), { shiftKey: true })
expect(selectedNames(harness.container)).toEqual(['sub', 'a.ts', 'b.ts'])
})
it('Escape, the bar\'s Clear selection, and a click on the tree body all clear the selection', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
pressEscape(harness.container.querySelector('[class*="explorerBody"]')!)
expect(selectionBar(harness.container)).toBeNull()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
act(() => { barAction(harness.container, 'Clear selection').click() })
expect(selectionBar(harness.container)).toBeNull()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(harness.container.querySelector('[class*="explorerBody"]')!)
expect(selectionBar(harness.container)).toBeNull()
})
it('right-click collapses the selection onto an unselected row and keeps a selected one', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
// The row is already part of the selection: the whole batch survives.
rightClick(rowByName(harness.container, 'b.ts'))
expect(selectedNames(harness.container)).toEqual(['a.ts', 'b.ts'])
// An unselected row collapses the batch onto itself.
rightClick(rowByName(harness.container, 'sub'))
expect(selectedNames(harness.container)).toEqual(['sub'])
})
it('copies every selected absolute path, newline separated', async () => {
const writeText = vi.fn(async () => {})
Object.defineProperty(window.navigator, 'clipboard', { value: { writeText }, configurable: true })
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
await act(async () => { barAction(harness.container, 'Copy paths').click() })
expect(writeText).toHaveBeenCalledWith('/tmp/a.ts\n/tmp/b.ts')
})
it('deletes the whole selection through one confirmation, sequentially', async () => {
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
act(() => { barAction(harness.container, 'Delete selected').click() })
const dialog = document.querySelector('[role="dialog"]')
expect(dialog?.textContent).toContain('Delete 2 selected items?')
const confirm = [...document.querySelectorAll<HTMLElement>('button')]
.find(el => el.textContent === 'Delete selected' && el.closest('[role="dialog"]') !== null)!
await act(async () => { confirm.click() })
expect(fsRemove.mock.calls.map(call => call[1])).toEqual(['/tmp/a.ts', '/tmp/b.ts'])
expect(selectionBar(harness.container)).toBeNull()
})
it('stops the batch at the first failure and reports it in the strip', async () => {
// The first path lands; the second refuses — the walk must stop there.
fsRemove
.mockImplementationOnce(async (_scope: unknown, path: string) => ({ path }))
.mockRejectedValueOnce(new Error('boom: refused'))
harness = await mountTree()
click(rowByName(harness.container, 'a.ts'), { ctrlKey: true })
click(rowByName(harness.container, 'b.ts'), { ctrlKey: true })
act(() => { barAction(harness.container, 'Delete selected').click() })
const confirm = [...document.querySelectorAll<HTMLElement>('button')]
.find(el => el.textContent === 'Delete selected' && el.closest('[role="dialog"]') !== null)!
await act(async () => { confirm.click() })
// Two attempts, in selection order, and nothing after the refusal.
expect(fsRemove.mock.calls.map(call => call[1])).toEqual(['/tmp/a.ts', '/tmp/b.ts'])
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('boom: refused')
// The removed row left the selection; the refused one stays for a retry.
expect(selectedNames(harness.container)).toEqual(['b.ts'])
})
})
+253
View File
@@ -0,0 +1,253 @@
/**
* FileTree "new folder": the directory rows (and the workspace root row)
* offer it in the context menu; choosing it inserts the inline editor at the
* TOP of that level (expanding a collapsed directory first), and the editor
* follows the rename contract — Enter commits through `api.fsMkdir` with the
* parent directory + single-segment name, Escape cancels, blur commits, an
* invalid name reports `newFolderInvalid` and never reaches the API, and a
* server failure lands in the dismissable strip.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement, useState, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
const { fsMkdir, fsTree } = vi.hoisted(() => ({
fsMkdir: vi.fn(async (_scope: unknown, _path: string, name: string) => ({ path: `/tmp/${name}` })),
// `listingFor` is a hoisted function declaration, so the factory may
// reference it; it only runs when the mock is called (after module init).
fsTree: vi.fn(async (_scope: unknown, path: string) => listingFor(path)),
}))
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree,
fsMkdir,
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about folder creation).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
const ROOT_ENTRIES = [
{ name: 'sub', path: '/tmp/sub', isDir: true },
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
]
/** Per-directory listings: /tmp/sub must NOT list itself (a real host never
* returns a directory inside its own listing). */
function listingFor(path: string): { path: string; entries: typeof ROOT_ENTRIES; truncated: boolean } {
return {
path,
entries: path === '/tmp/sub' ? [{ name: 'inner.txt', path: '/tmp/sub/inner.txt', isDir: false }] : ROOT_ENTRIES,
truncated: false,
}
}
interface Harness {
container: HTMLDivElement
/** Directories the tree asked the caller to expand/collapse. */
toggled: string[]
unmount: () => void
}
/**
* The real caller owns the expansion set (EditorHost holds it in the store),
* so the harness does too: a toggle really expands the tree and loads the
* level beneath it.
*/
function mountTree(initialExpanded: string[] = []): Harness {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const toggled: string[] = []
function Host(): ReactNode {
const [expanded, setExpanded] = useState<string[]>(initialExpanded)
return createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded,
revealed: [],
onToggle: (path: string) => {
toggled.push(path)
setExpanded(current => current.includes(path) ? current.filter(item => item !== path) : [...current, path])
},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
})
}
act(() => { root.render(createElement(Host)) })
return {
container,
toggled,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** One tree row by its displayed name (the root row included). */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[class*="explorerRow"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
function openMenu(container: HTMLElement, name: string): void {
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })
act(() => { rowByName(container, name).dispatchEvent(event) })
}
function menuLabels(): string[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].map(el => el.textContent ?? '')
}
function clickMenuitem(label: string): void {
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(el => el.textContent === label)
if (item === undefined) throw new Error(`menuitem not found: ${label}`)
act(() => { item.click() })
}
/** Set a controlled input's value the React way (native setter + input). */
function setNativeValue(el: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
if (setter === undefined) throw new Error('no native value setter')
setter.call(el, value)
el.dispatchEvent(new Event('input', { bubbles: true }))
}
function pressKey(el: HTMLElement, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
}
function editorInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('input[class*="explorerRenameInput"]')
if (input === null) throw new Error('inline editor not rendered')
return input
}
/** The editor row's indent (depth * 22 + 6) — proof of WHICH level hosts it. */
function editorIndent(container: HTMLElement): string {
return editorInput(container).closest<HTMLElement>('[class*="explorerRow"]')?.style.paddingLeft ?? ''
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fsMkdir.mockReset()
fsMkdir.mockImplementation(async (_scope: unknown, _path: string, name: string) => ({ path: `/tmp/${name}` }))
fsTree.mockReset()
fsTree.mockImplementation(async (_scope: unknown, path: string) => listingFor(path))
})
describe('FileTree new folder', () => {
it('offers the entry on directory rows (the root included, never on files)', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'sub')
expect(menuLabels()).toContain('New folder')
openMenu(harness.container, 'tmp')
expect(menuLabels()).toContain('New folder')
openMenu(harness.container, 'a.ts')
expect(menuLabels()).not.toContain('New folder')
})
it('inserts the editor at the root level and commits through api.fsMkdir', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
expect(input.placeholder).toBe('Folder name')
// The root level's rows sit at depth 1 (22 + 6); the editor replaces the
// level's first row.
expect(editorIndent(harness.container)).toBe('28px')
setNativeValue(input, 'newdir')
const listings = fsTree.mock.calls.length
await act(async () => { pressKey(input, 'Enter') })
expect(fsMkdir).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp', 'newdir')
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
// The level was dropped and re-listed, so the new folder shows up.
expect(fsTree.mock.calls.length).toBeGreaterThan(listings)
})
it('expands a collapsed directory, edits at ITS level, and creates there', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'sub')
clickMenuitem('New folder')
// The collapsed directory is expanded so its level can host the editor.
expect(harness.toggled).toEqual(['/tmp/sub'])
expect(editorIndent(harness.container)).toBe('50px')
setNativeValue(editorInput(harness.container), 'inner')
await act(async () => { pressKey(editorInput(harness.container), 'Enter') })
expect(fsMkdir).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/sub', 'inner')
})
it('Escape cancels without touching the API', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
setNativeValue(input, 'newdir')
await act(async () => { pressKey(input, 'Escape') })
expect(fsMkdir).not.toHaveBeenCalled()
expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
})
it('rejects an invalid name client-side with the strip, no API call', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
setNativeValue(input, 'a/b')
await act(async () => { pressKey(input, 'Enter') })
expect(fsMkdir).not.toHaveBeenCalled()
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('Invalid folder name')
})
it('lands a server refusal in the dismissable strip', async () => {
fsMkdir.mockRejectedValueOnce(new Error('boom: exists'))
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
setNativeValue(input, 'sub')
await act(async () => { pressKey(input, 'Enter') })
expect(fsMkdir).toHaveBeenCalledTimes(1)
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('boom: exists')
})
it('commits on blur (the same contract the rename editor has)', async () => {
harness = mountTree()
await act(async () => {})
openMenu(harness.container, 'tmp')
clickMenuitem('New folder')
const input = editorInput(harness.container)
setNativeValue(input, 'blurred')
// React delegates onBlur from the native focusout event.
await act(async () => { input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) })
expect(fsMkdir).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp', 'blurred')
})
})
+291
View File
@@ -0,0 +1,291 @@
/**
* FileTree "open in app" menu (the host's own capability, replacing the
* plugin's removed open-with chain): a file row lists the host's registered
* applications — the default-application row first, then one row per app —
* followed by reveal-in-file-manager; a directory row lists the host's
* application catalogue plus reveal. Clicking a row hands the absolute path
* (plus the application id when one was chosen) to the injected handle, a
* listing that yields nothing degrades to one DISABLED row, a host that
* cannot open desktop paths hides the whole section, and a failure lands in
* the tree's error strip.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { createOpenInApp, type OpenInApp, type OpenInAppEntry } from '../src/client/open-in-app.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: async () => ({
path: '/tmp',
entries: [
{ name: 'sub', path: '/tmp/sub', isDir: true },
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
],
truncated: false,
}),
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about the open-in-app section).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
/** What the host reports for a file (the default entry first). */
const FILE_APPS: readonly OpenInAppEntry[] = [
{ id: 'textedit', name: 'TextEdit', icon: null, isDefault: true },
{ id: 'vscode', name: 'VS Code', icon: 'data:image/svg+xml,%3Csvg/%3E', isDefault: false },
]
/** What the host reports for a directory (no default concept). */
const DIRECTORY_APPS: readonly OpenInAppEntry[] = [
{ id: 'vscode', name: 'VS Code', icon: null, isDefault: false },
{ id: 'finder', name: 'Finder', icon: null, isDefault: false },
]
interface Handle {
openInApp: OpenInApp
open: ReturnType<typeof vi.fn>
reveal: ReturnType<typeof vi.fn>
fileApps: ReturnType<typeof vi.fn>
directoryApps: ReturnType<typeof vi.fn>
}
function makeHandle(overrides: Partial<OpenInApp> = {}): Handle {
const open = vi.fn(async () => true)
const reveal = vi.fn(async () => true)
const fileApps = vi.fn(async () => FILE_APPS)
const directoryApps = vi.fn(async () => DIRECTORY_APPS)
const openInApp: OpenInApp = {
available: () => true,
probe: async () => true,
directoryApps,
fileApps,
open,
reveal,
...overrides,
}
return { openInApp, open, reveal, fileApps, directoryApps }
}
interface Harness {
container: HTMLDivElement
unmount: () => void
}
async function mountTree(handle?: Handle): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onOpenFileNewTab: () => {},
onOpenFileSide: () => {},
...(handle !== undefined ? { openInApp: handle.openInApp } : {}),
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
// Settle the async app listing the row menu triggers.
await Promise.resolve()
await Promise.resolve()
})
return {
container,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** The row whose label text matches (rows are role="button" divs). */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
/** Open one row's context menu and settle the async app listing. */
async function openMenu(container: HTMLElement, name: string): Promise<void> {
await act(async () => {
rowByName(container, name).dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 }),
)
await Promise.resolve()
await Promise.resolve()
})
}
function menuItems(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
}
function menuLabels(): string[] {
return menuItems().map(item => item.textContent ?? '')
}
function clickMenuitem(label: string): void {
const item = menuItems().find(el => el.textContent === label)
if (item === undefined) throw new Error(`menuitem not found: ${label}`)
act(() => { item.click() })
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
})
describe('FileTree open-in-app menu', () => {
it('lists the default application, the registered apps and reveal for a file row', async () => {
const handle = makeHandle()
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
expect(handle.fileApps).toHaveBeenCalledWith('/tmp/a.ts')
// The section heading is a non-interactive label row.
expect(document.body.textContent).toContain('Open with')
expect(menuLabels()).toContain('Open with default app')
expect(menuLabels()).toContain('VS Code')
expect(menuLabels()).toContain('Reveal in File Manager')
// The default row's glyph is the default app's own icon slot.
expect(menuItems().some(item => item.querySelector('img[class*="explorerAppIcon"]') !== null)).toBe(true)
})
it('hands the path (and the chosen application id) to the open handle', async () => {
const handle = makeHandle()
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
clickMenuitem('Open with default app')
expect(handle.open).toHaveBeenCalledWith('/tmp/a.ts')
// Selecting closes the menu entirely.
expect(menuItems()).toHaveLength(0)
await openMenu(harness.container, 'a.ts')
clickMenuitem('VS Code')
expect(handle.open).toHaveBeenCalledWith('/tmp/a.ts', 'vscode')
await openMenu(harness.container, 'a.ts')
clickMenuitem('Reveal in File Manager')
expect(handle.reveal).toHaveBeenCalledWith('/tmp/a.ts')
})
it('lists the host catalogue for a directory row, with no default row', async () => {
const handle = makeHandle()
harness = await mountTree(handle)
await openMenu(harness.container, 'sub')
expect(handle.directoryApps).toHaveBeenCalled()
expect(handle.fileApps).not.toHaveBeenCalled()
expect(menuLabels()).not.toContain('Open with default app')
expect(menuLabels()).toEqual(expect.arrayContaining(['VS Code', 'Finder', 'Reveal in File Manager']))
clickMenuitem('Finder')
expect(handle.open).toHaveBeenCalledWith('/tmp/sub', 'finder')
})
it('degrades to one DISABLED row when the host knows no application', async () => {
const handle = makeHandle({ fileApps: async () => [] })
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
const empty = menuItems().find(item => item.textContent === 'No application can open it')
expect(empty).toBeDefined()
expect((empty as HTMLButtonElement).disabled).toBe(true)
})
it('reports a failed hand-off in the error strip', async () => {
const handle = makeHandle({ fileApps: async () => null })
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('Could not open: /tmp/a.ts')
})
it('reports a hand-off the host refused (open resolving false)', async () => {
const handle = makeHandle()
handle.open.mockResolvedValue(false)
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
await act(async () => { clickMenuitem('VS Code'); await Promise.resolve(); await Promise.resolve() })
expect(harness.container.querySelector('[role="alert"]')?.textContent).toContain('Could not open: /tmp/a.ts')
})
it('hides the whole section when the host cannot open desktop paths', async () => {
const handle = makeHandle({ available: () => false })
harness = await mountTree(handle)
await openMenu(harness.container, 'a.ts')
expect(handle.fileApps).not.toHaveBeenCalled()
expect(menuLabels()).not.toContain('Reveal in File Manager')
expect(document.body.textContent).not.toContain('Open with')
})
it('hides the section when no handle was injected at all', async () => {
harness = await mountTree()
await openMenu(harness.container, 'a.ts')
expect(menuLabels()).not.toContain('Reveal in File Manager')
expect(document.body.textContent).not.toContain('Open with')
})
it('probes the tri-state handle before deciding the section is unavailable', async () => {
// The REAL adapter starts with `available() === null` and only publishes
// an answer when `probe()` runs: a tree that reads the getter without
// probing would hide the section forever. This case pins that lifecycle
// with a handle that mimics it exactly.
let probed = false
let probes = 0
const handle = makeHandle({
available: () => (probed ? true : null),
probe: async () => { probed = true; probes += 1; return true },
})
harness = await mountTree(handle)
expect(handle.fileApps).not.toHaveBeenCalled()
await openMenu(harness.container, 'a.ts')
expect(handle.fileApps).toHaveBeenCalledWith('/tmp/a.ts')
expect(menuLabels()).toContain('Open with default app')
expect(menuLabels()).toContain('VS Code')
expect(probes).toBe(1)
})
it('drives the real host adapter end to end: probe, list, open, reveal', async () => {
// No hand-written handle here — the production adapter over a fake Host
// Remote, so the wiring (probe → fileApps → open/reveal) cannot drift.
const calls = { canOpen: 0, applications: [] as string[], open: [] as { path: string; application?: string }[] }
const openInApp = createOpenInApp({
get: (name) => name !== 'remote' ? undefined : {
session: {
canOpenWorkspacePath: async () => { calls.canOpen += 1; return { ok: true, value: true } },
workspacePathApplications: async (request: { path: string }) => {
calls.applications.push(request.path)
return { ok: true, value: [{ id: 'textedit', name: 'TextEdit', default: true, icon: null }] }
},
openWorkspacePath: async (request: { path: string; application?: string }) => {
calls.open.push({ path: request.path, ...(request.application !== undefined ? { application: request.application } : {}) })
return { ok: true }
},
},
},
})
expect(openInApp.available()).toBeNull()
harness = await mountTree({ openInApp } as Handle)
await openMenu(harness.container, 'a.ts')
expect(calls.canOpen).toBe(1)
expect(calls.applications).toEqual(['/tmp/a.ts'])
expect(menuLabels()).toContain('Open with default app')
await act(async () => { clickMenuitem('Open with default app'); await Promise.resolve() })
expect(calls.open).toEqual([{ path: '/tmp/a.ts' }])
})
})
-248
View File
@@ -1,248 +0,0 @@
/**
* FileTree "open with" context menu: a file row's right-click menu gains the
* pinned direct rows and the parent submenu row; the submenu lists every
* resolved target with a per-row pushpin, pinning never selects/closes the
* menu, and selecting a child invokes the caller's open handler with the
* row's absolute path. When the caller wires nothing, the section is absent.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import type { OpenWithTarget } from '../src/client/open-with.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: async () => ({
entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }],
}),
},
downloadUrl: () => '/sidebar/file',
}))
/** The resolved target list a caller (EditorHost) would hand FileTree. */
const targets: OpenWithTarget[] = [
{ id: 'explorer', nameKey: 'openWithExplorer', name: '', kind: 'reveal', isVscodeFamily: false, localOnly: true },
{ id: 'vscode', nameKey: 'openWithVscode', name: '', kind: 'url', urlTemplate: 'vscode://file/{path}', isVscodeFamily: true, localOnly: false },
{ id: 'cursor', nameKey: 'openWithCursor', name: '', kind: 'url', urlTemplate: 'cursor://file/{path}', isVscodeFamily: true, localOnly: false },
{ id: 'zed', nameKey: 'openWithZed', name: '', kind: 'url', urlTemplate: 'zed://file/{path}', isVscodeFamily: false, localOnly: true },
{ id: 'custom:w', name: 'Windsurf', kind: 'url', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false, localOnly: false },
]
interface Harness {
container: HTMLDivElement
onOpenWith: ReturnType<typeof vi.fn>
onToggleOpenWithPin: ReturnType<typeof vi.fn>
unmount: () => void
}
async function mountTree(overrides: {
openWithTargets?: OpenWithTarget[]
openWithPinned?: string[]
openWithSsh?: boolean
} = {}): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const onOpenWith = vi.fn()
const onToggleOpenWithPin = vi.fn()
// Property-presence semantics: an explicit `openWithTargets: undefined`
// must reach FileTree (the section hides) instead of falling back.
const has = (key: 'openWithTargets' | 'openWithPinned' | 'openWithSsh'): boolean =>
Object.prototype.hasOwnProperty.call(overrides, key)
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onOpenFileNewTab: () => {},
onOpenFileSide: () => {},
openWithTargets: has('openWithTargets') ? overrides.openWithTargets : targets,
openWithPinned: has('openWithPinned') ? overrides.openWithPinned : ['vscode'],
openWithSsh: has('openWithSsh') ? overrides.openWithSsh : false,
onOpenWith,
onToggleOpenWithPin,
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
}))
})
return {
container,
onOpenWith,
onToggleOpenWithPin,
unmount: () => { act(() => { root.unmount() }) },
}
}
/** The file row of the one-level tree (role="button" with the name span). */
function fileRow(container: HTMLDivElement): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === 'a.ts')
if (row === undefined) throw new Error('file row not found')
return row
}
/** Open the row's context menu at a fixed cursor position. */
function openMenu(container: HTMLDivElement): void {
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })
act(() => { fileRow(container).dispatchEvent(event) })
}
describe('FileTree open-with menu', () => {
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
})
it('renders the pinned direct row and the submenu parent for a file row', async () => {
harness = await mountTree()
openMenu(harness.container)
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
// The pinned VS Code sits at the top level (before the submenu parent).
expect(items.map(item => item.textContent?.trim())).toContain('VS Code')
const parent = items.find(item => item.getAttribute('aria-haspopup') === 'menu')
expect(parent?.textContent).toContain('Open with')
// The submenu parent carries the trailing chevron affordance (the
// primitives Menu renders no arrow of its own), right-aligned inside a
// full-width label row — the same structure the submenu children use.
expect(parent?.querySelector('[class*="openWithChevron"]')).not.toBeNull()
expect(parent?.querySelector('[class*="openWithLabel"]')).not.toBeNull()
})
it('opens the submenu on click and lists every target with pin toggles', async () => {
harness = await mountTree()
openMenu(harness.container)
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
expect(parent).toBeDefined()
act(() => { parent!.click() })
// Sub child rows = menuitems inside the nested menu.
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
expect(children.map(item => item.textContent?.trim())).toEqual([
'File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf',
])
expect(children.every(item => item.querySelector('[class*="openWithPin"]') !== null)).toBe(true)
// Pinned state is announced per row (and swaps the pushpin glyph).
const vscodeRow = children.find(item => item.textContent?.trim() === 'VS Code')
const cursorRow0 = children.find(item => item.textContent?.trim() === 'Cursor')
expect(vscodeRow?.querySelector('[aria-label="Unpin"]')).not.toBeNull()
expect(cursorRow0?.querySelector('[aria-label="Pin to menu"]')).not.toBeNull()
})
it('pin click toggles without selecting the row or closing the menu', async () => {
harness = await mountTree()
openMenu(harness.container)
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const cursorRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.find(item => item.textContent?.trim() === 'Cursor')
const pin = cursorRow!.querySelector<HTMLElement>('[class*="openWithPin"]')
expect(pin).not.toBeNull()
act(() => { pin!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) })
expect(harness.onToggleOpenWithPin).toHaveBeenCalledWith('cursor')
expect(harness.onOpenWith).not.toHaveBeenCalled()
// The menu (and the submenu) stayed open.
expect(document.querySelector('[role="menu"] [role="menu"]')).not.toBeNull()
})
it('selecting a submenu child invokes onOpenWith with the row path and closes the menu', async () => {
harness = await mountTree()
openMenu(harness.container)
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const zedRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.find(item => item.textContent?.trim() === 'Zed')
act(() => { zedRow!.click() })
expect(harness.onOpenWith).toHaveBeenCalledWith('zed', '/tmp/a.ts')
// Selecting closes the row menu entirely.
expect(document.querySelector('[role="menu"] [role="menu"]')).toBeNull()
expect(document.querySelector('[role="menuitem"]')).toBeNull()
})
it('appends the SSH hint to VSCode-family labels in remote mode', async () => {
harness = await mountTree({
openWithSsh: true,
openWithTargets: targets.filter(target => !target.localOnly),
openWithPinned: [],
})
openMenu(harness.container)
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
expect(children.map(item => item.textContent?.trim())).toEqual([
'VS Code (SSH)', 'Cursor (SSH)', 'Windsurf (SSH)',
])
})
it('hides the whole section when the caller wires no targets', async () => {
harness = await mountTree({ openWithTargets: undefined })
openMenu(harness.container)
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
expect(items.some(item => item.textContent?.includes('Open with'))).toBe(false)
expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
})
})
describe('FileTree open-with menu flip geometry (submenu clamping)', () => {
const ATTR = 'data-dsh-sidebar-submenu'
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
document.body.removeAttribute(ATTR)
})
it('publishes "down" for a top-of-tree opening and clears on selection', async () => {
harness = await mountTree()
// openMenu() right-clicks at (20, 30) — the upper half, plenty of right
// room in jsdom's default viewport.
openMenu(harness.container)
expect(document.body.getAttribute(ATTR)).toBe('down')
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const zedRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.find(item => item.textContent?.trim() === 'Zed')
act(() => { zedRow!.click() })
expect(document.querySelector('[role="menuitem"]')).toBeNull()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
it('adds "left" when the cursor sits within the right-hand submenu room', async () => {
harness = await mountTree()
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: window.innerWidth - 10, clientY: 30 })
act(() => { fileRow(harness.container).dispatchEvent(nearRight) })
expect(document.body.getAttribute(ATTR)).toBe('down left')
})
it('clears the attribute on unmount with the menu still open', async () => {
harness = await mountTree()
openMenu(harness.container)
expect(document.body.getAttribute(ATTR)).toBe('down')
harness.unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})
+3
View File
@@ -38,6 +38,9 @@ vi.mock('../src/client/api.ts', () => ({
}),
fsRename,
fsRemove,
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about rename/delete).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
+3
View File
@@ -34,6 +34,9 @@ vi.mock('../src/client/api.ts', () => ({
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
],
}),
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about the reveal scroll).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
}))
+134
View File
@@ -0,0 +1,134 @@
/**
* FileTree row memoization (the tree's performance contract): rows are
* `React.memo` components fed stable callbacks and per-row booleans, so an
* interaction that touches ONE row must re-render that row only — not the
* whole tree. The evidence is the icon resolver: it runs inside the row
* component, so counting its calls counts the rows that re-rendered.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import type { BetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so any copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: async () => ({
path: '/tmp',
entries: [
{ name: 'sub', path: '/tmp/sub', isDir: true },
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
{ name: 'b.ts', path: '/tmp/b.ts', isDir: false },
{ name: 'c.ts', path: '/tmp/c.ts', isDir: false },
],
truncated: false,
}),
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (its arrival must not defeat the memo bail-out either).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
/** The icon resolutions per render: one per row that actually re-rendered. */
const fileIcons: string[] = []
const folderIcons: string[] = []
/** A minimal registry service: the counters ARE the re-render probe. */
const service = {
subscribe: () => () => {},
fileIcon: (path: string) => { fileIcons.push(path); return null },
folderIcon: (path: string) => { folderIcons.push(path); return null },
} as unknown as BetterSidebarService
interface Harness {
container: HTMLDivElement
unmount: () => void
}
async function mountTree(): Promise<Harness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
await act(async () => {
root.render(createElement(FileTree, {
sessionId: 'memo-spec',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick: 0,
onUploadRequest: () => {},
busy: false,
service,
}))
await Promise.resolve()
await Promise.resolve()
})
return {
container,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** One tree row by its displayed name. */
function rowByName(container: HTMLElement, name: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
if (row === undefined) throw new Error(`row not found: ${name}`)
return row
}
function fireDragOver(el: Element): void {
const event = new Event('dragover', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { types: ['Files'], dropEffect: '' } })
act(() => { el.dispatchEvent(event) })
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fileIcons.length = 0
folderIcons.length = 0
})
describe('FileTree row memoization', () => {
it('re-renders only the toggled row on a Ctrl+click selection change', async () => {
harness = await mountTree()
fileIcons.length = 0
act(() => {
rowByName(harness.container, 'a.ts')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }))
})
// The other three file rows kept their last render (memo bailed out).
expect(fileIcons).toEqual(['/tmp/a.ts'])
})
it('re-renders only the hovered directory row while a drag retargets', async () => {
harness = await mountTree()
folderIcons.length = 0
fileIcons.length = 0
fireDragOver(rowByName(harness.container, 'sub'))
// Every parent render resolves the ROOT row's icon; the memoized dir row
// resolves its own exactly once (its unhovered render was cached), and
// the file rows (whose drop target is the untouched /tmp) never re-render.
expect(folderIcons.filter(path => path === '/tmp/sub')).toEqual(['/tmp/sub'])
expect(fileIcons).toEqual([])
})
})
+190
View File
@@ -0,0 +1,190 @@
/**
* The files panel's search surface and level-cache life-cycle:
*
* - Typing a query parks the tree (`hidden`) instead of unmounting it, so
* clearing the box restores the SAME level cache — `fs.tree` is not called
* again (the old conditional render dropped the tree and refetched the
* whole visible set on every query change).
* - The refresh affordance wipes the cache and reloads concurrently, and a
* response from the previous generation is DROPPED even when it lands last.
* - A level the host truncated says so instead of silently showing a partial
* directory.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { createSidebarStore } from '../src/client/state.ts'
import { TreePanel } from '../src/client/TreePanel.tsx'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so the panel's copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
interface Listing {
path: string
entries: { name: string; path: string; isDir: boolean }[]
truncated: boolean
}
/** The default host answer: one file at /tmp. */
function defaultListing(path: string): Listing {
return { path, entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }], truncated: false }
}
const { fsTree, fsSearch } = vi.hoisted(() => ({
fsTree: vi.fn(async (_scope: unknown, path: string) => defaultListing(path)),
fsSearch: vi.fn(async () => ({ matches: ['a.ts'], truncated: false })),
}))
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree,
fsSearch,
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain.
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
interface Harness {
container: HTMLDivElement
unmount: () => void
}
function mountPanel(): Harness {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
act(() => {
root.render(createElement(TreePanel, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
}))
})
return {
container,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/**
* The tree surface: the body that carries row-name spans (the search results
* panel is a body too, but its rows are plain buttons with no name span).
* Finding it while parked is itself the "still mounted" probe.
*/
function treeBody(container: HTMLElement): HTMLElement {
const body = [...container.querySelectorAll<HTMLElement>('div[class*="explorerBody"]')]
.find(el => el.querySelector('[class*="explorerName"]') !== null)
if (body === undefined) throw new Error('tree body not mounted')
return body
}
function resultsBody(container: HTMLElement): HTMLElement {
const tree = treeBody(container)
const body = [...container.querySelectorAll<HTMLElement>('div[class*="explorerBody"]')]
.find(el => el !== tree)
if (body === undefined) throw new Error('results body not mounted')
return body
}
/** Type a query the React way and let the 300ms debounce elapse. */
async function search(container: HTMLElement, query: string): Promise<void> {
const input = container.querySelector<HTMLInputElement>('input[class*="editorSearchInput"]')!
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
setter.call(input, query)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => { await new Promise<void>(resolve => { window.setTimeout(resolve, 350) }) })
}
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
fsTree.mockReset()
fsTree.mockImplementation(async (_scope: unknown, path: string) => defaultListing(path))
fsSearch.mockClear()
})
describe('TreePanel search keeps the tree mounted', () => {
it('parks the tree instead of unmounting it, so clearing the query keeps the level cache', async () => {
harness = mountPanel()
await act(async () => {})
expect(fsTree).toHaveBeenCalledTimes(1)
expect(treeBody(harness.container).hasAttribute('hidden')).toBe(false)
expect(harness.container.textContent).toContain('a.ts')
await search(harness.container, 'a')
expect(fsSearch).toHaveBeenCalledTimes(1)
// The tree is still in the document — just parked.
expect(treeBody(harness.container).hasAttribute('hidden')).toBe(true)
expect(resultsBody(harness.container).hasAttribute('hidden')).toBe(false)
expect(resultsBody(harness.container).textContent).toContain('a.ts')
await search(harness.container, '')
expect(treeBody(harness.container).hasAttribute('hidden')).toBe(false)
expect(resultsBody(harness.container).hasAttribute('hidden')).toBe(true)
// No second listing: the cache (and the expansion state) survived.
expect(fsTree).toHaveBeenCalledTimes(1)
})
})
describe('TreePanel refresh and truncated levels', () => {
it('drops a level response that lands after the cache was wiped', async () => {
// The FIRST listing never settles until the test resolves it; the refresh
// click starts the second one, which answers immediately.
let resolveStale: (listing: Listing) => void = () => {}
fsTree.mockImplementationOnce(async () => await new Promise<Listing>(resolve => { resolveStale = resolve }))
harness = mountPanel()
await act(async () => {})
// The pending level shows nothing yet (its rows are not known).
expect(harness.container.textContent).not.toContain('a.ts')
// Manual refresh: cache wiped, new generation, concurrent re-list.
const refresh = harness.container.querySelector<HTMLButtonElement>('button[aria-label="Refresh"]')!
await act(async () => {
refresh.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
await Promise.resolve()
})
expect(fsTree).toHaveBeenCalledTimes(2)
expect(harness.container.textContent).toContain('a.ts')
// The stale first answer arrives LAST and must be discarded.
await act(async () => {
resolveStale({ path: '/tmp', entries: [{ name: 'stale.ts', path: '/tmp/stale.ts', isDir: false }], truncated: false })
await Promise.resolve()
await Promise.resolve()
})
expect(harness.container.textContent).not.toContain('stale.ts')
expect(harness.container.textContent).toContain('a.ts')
})
it('surfaces a truncated level instead of hiding it', async () => {
fsTree.mockImplementation(async (_scope: unknown, path: string) => ({
path,
entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }],
truncated: true,
}))
harness = mountPanel()
await act(async () => {})
const notice = [...harness.container.querySelectorAll<HTMLElement>('[data-kind="hint"]')]
.find(el => el.textContent?.includes('too many entries'))
expect(notice).toBeDefined()
})
})
+82
View File
@@ -0,0 +1,82 @@
/**
* The host's "new folder" mutation (`fs.mkdir` route → mkdirWorkspaceEntry):
* shape rules, existence refusal, containment under the fence, the root row
* as a legal PARENT, and the happy path against a real temporary filesystem.
*/
import { mkdtemp, mkdir, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdirWorkspaceEntry } from '../src/fs-operations.ts'
let root: string
/** The workspace root as the host will report it (macOS /tmp is a symlink). */
let realRoot: string
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-mkdir-'))
realRoot = await realpath(root)
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
/** The wire code of a rejected call (the route maps it to an HTTP status). */
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run()
} catch (error) {
return (error as { code?: string }).code ?? 'no-code'
}
return 'resolved'
}
describe('mkdirWorkspaceEntry', () => {
it('creates one directory inside the named row and returns its absolute path', async () => {
await mkdir(join(root, 'sub'))
const result = await mkdirWorkspaceEntry({ cwd: root, path: join(root, 'sub'), name: 'fresh' })
expect(result.path).toBe(join(realRoot, 'sub', 'fresh'))
expect(await readdir(join(root, 'sub'))).toEqual(['fresh'])
})
it('accepts the workspace root itself as the parent', async () => {
const result = await mkdirWorkspaceEntry({ cwd: root, path: root, name: 'top' })
expect(result.path).toBe(join(realRoot, 'top'))
expect(await readdir(root)).toEqual(['top'])
})
it('refuses a name that is not a single path segment', async () => {
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: 'a/b' }))).toBe('bad-request')
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: '..' }))).toBe('bad-request')
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: '' }))).toBe('bad-request')
})
it('refuses an existing destination with the same conflict code as rename', async () => {
await mkdir(join(root, 'taken'))
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: root, name: 'taken' }))).toBe('fs-error')
})
it('refuses a parent outside the workspace while the fence is armed, and allows it when disarmed', async () => {
const outside = await mkdtemp(join(tmpdir(), 'dsh-outside-'))
try {
await writeFile(join(outside, 'marker.txt'), 'x')
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'nope' }))).toBe('forbidden')
// The fence is the guard, not the path shape: disarmed, the same call lands.
const result = await mkdirWorkspaceEntry({ cwd: root, path: outside, name: 'allowed', fence: false })
expect(result.path).toBe(join(await realpath(outside), 'allowed'))
} finally {
await rm(outside, { recursive: true, force: true })
}
})
it('refuses to create INSIDE a symlink that escapes the workspace', async () => {
const outside = await mkdtemp(join(tmpdir(), 'dsh-link-'))
try {
await symlink(outside, join(root, 'escape'))
expect(await codeOf(() => mkdirWorkspaceEntry({ cwd: root, path: join(root, 'escape'), name: 'nope' }))).toBe('forbidden')
} finally {
await rm(outside, { recursive: true, force: true })
}
})
})
+278 -22
View File
@@ -3,6 +3,11 @@
* and history must always come from the same checkout. A stale history row is
* especially dangerous because its revert/cherry-pick action targets the
* currently selected checkout.
*
* The status itself now comes from the shared git-status store (one snapshot
* per session+cwd+worktree, one poller), so these tests observe the selection
* through the rows it renders and through the scope each git call receives —
* including the `repoRoot` a container's worktree listing must carry.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -12,12 +17,15 @@ import { act } from 'react-dom/test-utils'
import { GitLens } from '../src/client/changes/GitLens.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { api, type GitLogEntry, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
import { t } from '../src/client/locales.ts'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const MAIN = 'C:/repo/main'
const AGENT = 'C:/repo/agent'
/** A second linked checkout some tests introduce on a later inventory pass. */
const AGENT_2 = 'C:/repo/agent-2'
const inventories: GitWorktree[] = [
{ path: MAIN, branch: 'main', current: true, changes: 0 },
@@ -25,6 +33,7 @@ const inventories: GitWorktree[] = [
]
function statusFor(target?: string): GitStatusResult {
if (target === AGENT_2) return { isRepo: true, branch: 'agent-2', entries: [{ path: 'agent-2-change.ts', xy: ' M' }] }
return target === AGENT
? { isRepo: true, branch: 'agent', entries: [{ path: 'agent-change.ts', xy: ' M' }] }
: { isRepo: true, branch: 'main', entries: [{ path: 'main-change.ts', xy: ' M' }] }
@@ -51,8 +60,29 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
}
async function flushEffects(): Promise<void> {
await act(async () => { await Promise.resolve() })
await act(async () => { await Promise.resolve() })
// Each round drains one promise hop: the refresh chain (listing → branch/log
// → state) is several hops deep, and a shallow flush would assert on rows
// that are still in flight.
for (let round = 0; round < 5; round += 1) await act(async () => { await Promise.resolve() })
}
/** Mount one GitLens. Visible by default: a hidden tab owns no live git data
* — the shared status store only polls/loads while a consumer is on screen. */
function mountGit(
root: Root,
options: { scope?: { sessionId: string; cwd?: string }; refreshTick?: number; visible?: boolean } = {},
): void {
act(() => {
root.render(createElement(GitLens, {
scope: options.scope ?? { sessionId: 'session', cwd: MAIN },
store: createSidebarStore(),
onOpenFile: () => {},
onPreview: () => {},
selectedRef: null,
visible: options.visible ?? true,
refreshTick: options.refreshTick ?? 0,
}))
})
}
afterEach(() => { vi.restoreAllMocks() })
@@ -71,16 +101,7 @@ describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
document.body.append(container)
const root: Root = createRoot(container)
try {
await act(async () => {
root.render(createElement(GitLens, {
scope: { sessionId: 'session', cwd: MAIN },
store: createSidebarStore(),
onOpenFile: () => {},
onPreview: () => {},
selectedRef: null,
visible: false,
}))
})
mountGit(root)
await flushEffects()
const selects = container.querySelectorAll<HTMLSelectElement>('select')
@@ -130,16 +151,7 @@ describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
document.body.append(container)
const root: Root = createRoot(container)
try {
await act(async () => {
root.render(createElement(GitLens, {
scope: { sessionId: 'session', cwd: MAIN },
store: createSidebarStore(),
onOpenFile: () => {},
onPreview: () => {},
selectedRef: null,
visible: false,
}))
})
mountGit(root)
await flushEffects()
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
@@ -164,4 +176,248 @@ describe('GitLens (changes tab, git lens) linked-worktree consistency', () => {
container.remove()
}
})
it('lists a selected child repository with its repoRoot, running one full refresh', async () => {
const WS = 'C:/ws'
const REPO_A = 'C:/ws/a'
const REPO_B = 'C:/ws/b'
const repoBWorktrees: GitWorktree[] = [
{ path: REPO_B, branch: 'b-main', current: true, changes: 0 },
{ path: 'C:/ws/b-agent', branch: 'b-agent', current: false, changes: 2 },
]
const worktrees = vi.spyOn(api, 'gitWorktrees').mockImplementation(async (scope) => (
scope.repoRoot === REPO_B ? repoBWorktrees : []
))
const status = vi.spyOn(api, 'gitStatus').mockImplementation(async (scope) => (
scope.cwd === REPO_B
? { isRepo: true, branch: 'b-main', entries: [], root: REPO_B, repositories: [REPO_B] }
: { isRepo: true, branch: 'a-main', entries: [], root: REPO_A, repositories: [REPO_A, REPO_B] }
))
vi.spyOn(api, 'gitBranch').mockImplementation(async (scope) => (
scope.repoRoot === REPO_B ? { current: 'b-main', names: ['b-main'] } : { current: 'a-main', names: ['a-main'] }
))
vi.spyOn(api, 'gitLog').mockResolvedValue([])
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root, { scope: { sessionId: 'session', cwd: WS } })
await flushEffects()
// The workspace container's two child repositories are the repo choices.
const repoSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
expect([...repoSelect.options].map(option => option.textContent)).toEqual(['a', 'b'])
const listingsBefore = worktrees.mock.calls.length
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(repoSelect, REPO_B)
repoSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
// The listing carries the selected repoRoot — a container's own listing
// is empty, so the child checkout's linked worktrees only appear when the
// selection rides along.
expect(worktrees.mock.calls.at(-1)![0]).toMatchObject({ sessionId: 'session', cwd: WS, repoRoot: REPO_B })
// Exactly ONE full refresh for the switch (not a burst).
expect(worktrees.mock.calls.length).toBe(listingsBefore + 1)
// The shared status follows the same selection.
expect(status.mock.calls.some(([scope]) => scope.cwd === REPO_B)).toBe(true)
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[1]!
expect(worktreeSelect.value).toBe(REPO_B)
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('never yanks the view off the auto-selected checkout on a later re-list', async () => {
const LATER: GitWorktree[] = [
{ path: MAIN, branch: 'main', current: true, changes: 0 },
{ path: AGENT, branch: 'agent', current: false, changes: 0 },
{ path: AGENT_2, branch: 'agent-2', current: false, changes: 5 },
]
vi.spyOn(api, 'gitWorktrees')
.mockResolvedValueOnce(inventories)
.mockResolvedValue(LATER)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
const worktreeSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
expect(worktreeSelect.value).toBe(AGENT)
// A later inventory pass prefers the brand-new dirty checkout; the user
// never chose a checkout, but the ONE automatic pass already ran.
mountGit(root, { refreshTick: 1 })
await flushEffects()
expect(worktreeSelect.value).toBe(AGENT)
expect(container.textContent).toContain('agent-change.ts')
expect(container.textContent).not.toContain('agent-2-change.ts')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('queues a manual refresh that lands while a refresh is in flight', async () => {
const held = deferred<GitWorktree[]>()
const worktrees = vi.spyOn(api, 'gitWorktrees')
.mockReturnValueOnce(held.promise)
.mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
expect(worktrees).toHaveBeenCalledTimes(1)
// The header's refresh action lands while the mount pass is in flight.
mountGit(root, { refreshTick: 1 })
await flushEffects()
// Queued, not dropped and not started in parallel.
expect(worktrees).toHaveBeenCalledTimes(1)
held.resolve([])
await flushEffects()
// The queued pass ran and its listing landed: the dirty linked checkout
// is selected and its rows are the ones on screen.
expect(worktrees).toHaveBeenCalledTimes(2)
expect(container.textContent).toContain('agent-change.ts')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('stays quiet while hidden and catches up when the tab becomes visible', async () => {
const worktrees = vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
const status = vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
// Mounted in a background pane: no fetch at all (no git processes for a
// tab nobody is looking at).
mountGit(root, { visible: false })
await flushEffects()
expect(worktrees).not.toHaveBeenCalled()
expect(status).not.toHaveBeenCalled()
// Bringing the tab on screen loads the lens through one pass.
mountGit(root, { visible: true })
await flushEffects()
expect(worktrees).toHaveBeenCalledTimes(1)
expect(status).toHaveBeenCalled()
expect(container.textContent).toContain('agent-change.ts')
expect(container.textContent).toContain('Agent checkout commit')
} finally {
act(() => { root.unmount() })
container.remove()
}
})
it('hands the inventory poll its AbortSignal and stops it when hidden', async () => {
vi.useFakeTimers()
const worktrees = vi.spyOn(api, 'gitWorktrees').mockResolvedValue(inventories)
vi.spyOn(api, 'gitStatus').mockImplementation(async (_scope, target) => statusFor(target))
vi.spyOn(api, 'gitBranch').mockImplementation(async (_scope, target) => ({
current: target === AGENT ? 'agent' : 'main',
names: target === AGENT ? ['agent'] : ['main'],
}))
vi.spyOn(api, 'gitLog').mockImplementation(async (_scope, _count, _skip, target) => logFor(target))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
expect(worktrees).toHaveBeenCalledTimes(1)
// The 30s inventory cadence fires and carries the poller's signal.
await act(async () => { await vi.advanceTimersByTimeAsync(30_000) })
await flushEffects()
expect(worktrees.mock.calls.length).toBe(2)
const signal = worktrees.mock.calls[1]![1]
expect(signal).toBeInstanceOf(AbortSignal)
// Hiding the tab tears the poll down and aborts its signal: a slow host
// answer can no longer publish state.
mountGit(root, { visible: false })
await flushEffects()
expect((signal as AbortSignal).aborted).toBe(true)
const callsWhileHidden = worktrees.mock.calls.length
await act(async () => { await vi.advanceTimersByTimeAsync(120_000) })
expect(worktrees.mock.calls.length).toBe(callsWhileHidden)
} finally {
act(() => { root.unmount() })
container.remove()
vi.useRealTimers()
}
})
it('keeps branch switch and history failures out of the commit bar', async () => {
vi.spyOn(api, 'gitWorktrees').mockResolvedValue([
{ path: MAIN, branch: 'main', current: true, changes: 1 },
])
vi.spyOn(api, 'gitStatus').mockResolvedValue({
isRepo: true, branch: 'main', entries: [{ path: 'src/a.ts', xy: ' M' }],
})
vi.spyOn(api, 'gitBranch').mockResolvedValue({ current: 'main', names: ['main', 'feature'] })
vi.spyOn(api, 'gitLog').mockResolvedValue([])
vi.spyOn(api, 'gitCheckout').mockRejectedValue(new Error('dirty worktree'))
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
try {
mountGit(root)
await flushEffects()
const branchSelect = container.querySelectorAll<HTMLSelectElement>('select')[0]!
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')!.set!.call(branchSelect, 'feature')
branchSelect.dispatchEvent(new Event('change', { bubbles: true }))
})
await flushEffects()
const banner = [...container.querySelectorAll<HTMLElement>('[role="alert"]')]
.find(node => (node.textContent ?? '').includes(`${t('checkoutError')}: dirty worktree`))
expect(banner).toBeDefined()
const input = container.querySelector(`input[placeholder="${t('commitPlaceholder')}"]`)
expect(input).not.toBeNull()
// The commit bar owns only its own status line: the branch failure is
// rendered ABOVE it, never under the commit input.
expect(banner!.compareDocumentPosition(input!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
} finally {
act(() => { root.unmount() })
container.remove()
}
})
})
-122
View File
@@ -1,122 +0,0 @@
/**
* The context-menu submenu flip geometry: the pure token computation behind
* the body attribute layout.css consumes (see src/client/menu-flip.ts for
* the contract) plus the attribute lifecycle of the hook itself.
*/
// @vitest-environment jsdom
import { createElement, useState } from 'react'
import { readFileSync } from 'node:fs'
import { act } from 'react-dom/test-utils'
import { describe, expect, it } from 'vitest'
import { renderRoot, setupReactAct } from './test-utils.ts'
import { SUBMENU_FLIP_ATTR, submenuFlipTokens, useSubmenuFlip } from '../src/client/menu-flip.ts'
setupReactAct()
describe('layout.css contract', () => {
it('consumes the exact attribute name menu-flip.ts publishes', () => {
// The two halves of the flip mechanism can drift silently: renaming the
// attribute here would leave layout.css matching nothing (submenus revert
// to overflowing) with every test still green. Tie them together. Specs
// run from the repo root (chunk-artifact.spec.ts reads lib/ the same way).
const css = readFileSync('src/client/layout.css', 'utf8')
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="down"] div[role="menu"] div[role="menu"]`)
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]`)
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]::before`)
})
it('keeps the submenu visual tether rules (parent highlight + tight gap)', () => {
// Part 1 keeps the parent row lit while its submenu is open (the Menu
// marks it aria-expanded); part 2 tightens the card gap to 4px with the
// hover corridor narrowed to match. Losing either sends the submenu back
// to reading as an unrelated floating card.
const css = readFileSync('src/client/layout.css', 'utf8')
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}] div[role="menu"] button[aria-expanded="true"]`)
expect(css).toContain('left: calc(100% + 4px)')
expect(css).toContain('right: calc(100% + 4px)')
expect(css).toContain('width: 4px')
})
})
describe('submenuFlipTokens', () => {
// Default geometry (1024x768): the panel is docked right, so the
// interesting split is the right-hand 400px strip and the vertical half.
it('flips down for a cursor in the upper half, keeps primitive defaults below', () => {
expect(submenuFlipTokens(500, 20, 1024, 768)).toBe('down')
expect(submenuFlipTokens(500, 700, 1024, 768)).toBe('')
})
it('flips left when the right-hand room cannot fit card + submenu', () => {
// 700 + 400 > 1024 → left; the cursor is low, so no down token.
expect(submenuFlipTokens(700, 700, 1024, 768)).toBe('left')
// Both tokens compose for a top-right opening.
expect(submenuFlipTokens(700, 20, 1024, 768)).toBe('down left')
})
it('uses the exact boundary inclusively (midpoint and width-minus-room still flip)', () => {
expect(submenuFlipTokens(500, 384, 1024, 768)).toBe('')
expect(submenuFlipTokens(500, 383, 1024, 768)).toBe('down')
expect(submenuFlipTokens(624, 700, 1024, 768)).toBe('')
expect(submenuFlipTokens(625, 700, 1024, 768)).toBe('left')
})
it('flips left throughout a narrow viewport', () => {
// A 600px window: any cursor past x=200 flips; the left strip cannot
// fit a right-growing submenu beside the 218px card either way.
expect(submenuFlipTokens(300, 100, 600, 800)).toBe('down left')
expect(submenuFlipTokens(500, 700, 600, 800)).toBe('left')
})
})
describe('useSubmenuFlip', () => {
const ATTR = 'data-dsh-sidebar-submenu'
/** Mount a probe whose menu state the spec drives via the returned setter. */
function mountFlipProbe(initial: { x: number; y: number } | null): { setMenu: (next: { x: number; y: number } | null) => void, unmount: () => void } {
let applyMenu: (next: { x: number; y: number } | null) => void = () => {}
function Probe(): React.ReactElement {
const [menu, setMenu] = useState<{ x: number; y: number } | null>(initial)
applyMenu = setMenu
useSubmenuFlip(menu)
return <div />
}
const rendered = renderRoot(createElement(Probe))
return {
setMenu: (next) => { act(() => { applyMenu(next) }) },
unmount: rendered.unmount,
}
}
it('publishes tokens while the menu is open and clears on close', () => {
const probe = mountFlipProbe({ x: 700, y: 20 })
try {
expect(document.body.getAttribute(ATTR)).toBe('down left')
probe.setMenu(null)
expect(document.body.hasAttribute(ATTR)).toBe(false)
} finally {
probe.unmount()
}
})
it('tracks the position across consecutive openings', () => {
const probe = mountFlipProbe({ x: 100, y: 20 })
try {
expect(document.body.getAttribute(ATTR)).toBe('down')
// Still open, now lower-left: the attribute stays (menu lifetime) but
// carries no token, so the primitive's own geometry applies.
probe.setMenu({ x: 100, y: 700 })
expect(document.body.getAttribute(ATTR)).toBe('')
probe.setMenu({ x: 700, y: 700 })
expect(document.body.getAttribute(ATTR)).toBe('left')
} finally {
probe.unmount()
}
})
it('clears the attribute when the owner unmounts with the menu still open', () => {
const probe = mountFlipProbe({ x: 100, y: 20 })
expect(document.body.getAttribute(ATTR)).toBe('down')
probe.unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})
-98
View File
@@ -1,98 +0,0 @@
/**
* Client/host routing for the file tree's external-open action. Remote
* VSCode-family SSH URLs must launch on the browser machine, while local
* editor URLs and reveal actions keep using the DSH host opener.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/client/api.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
function hostOk(): ReturnType<typeof vi.fn> {
return vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true, value: { started: true } }),
}))
}
describe('api.openExternal', () => {
it('launches an SSH remote-editor URL on the client without calling the host', async () => {
const assign = vi.fn()
const fetchMock = hostOk()
vi.stubGlobal('window', { location: { assign } })
vi.stubGlobal('fetch', fetchMock)
const url = 'vscode://vscode-remote/ssh-remote+dev/home/u/f.ts'
await expect(api.openExternal({ action: 'url', url })).resolves.toEqual({ started: true })
expect(assign).toHaveBeenCalledOnce()
expect(assign).toHaveBeenCalledWith(url)
expect(fetchMock).not.toHaveBeenCalled()
})
it('launches Cursor/custom VSCode-family SSH URLs on the client too', async () => {
const assign = vi.fn()
const fetchMock = hostOk()
vi.stubGlobal('window', { location: { assign } })
vi.stubGlobal('fetch', fetchMock)
await api.openExternal({
action: 'url',
url: 'cursor://vscode-remote/ssh-remote+dev/home/u/f.ts',
})
await api.openExternal({
action: 'url',
url: 'myfork://vscode-remote/ssh-remote+dev/home/u/f.ts',
})
expect(assign).toHaveBeenCalledTimes(2)
expect(fetchMock).not.toHaveBeenCalled()
})
it('keeps local editor URLs on the host opener', async () => {
const assign = vi.fn()
const fetchMock = hostOk()
vi.stubGlobal('window', { location: { assign } })
vi.stubGlobal('fetch', fetchMock)
await expect(api.openExternal({
action: 'url',
url: 'vscode://file//tmp/a.ts',
})).resolves.toEqual({ started: true })
expect(assign).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock.mock.calls[0]?.[0]).toBe('/sidebar/api/open.external')
})
it('keeps reveal actions and http(s) lookalikes on the host opener', async () => {
const assign = vi.fn()
const fetchMock = hostOk()
vi.stubGlobal('window', { location: { assign } })
vi.stubGlobal('fetch', fetchMock)
await api.openExternal({ action: 'reveal', path: '/tmp/a.ts' })
await api.openExternal({
action: 'url',
url: 'https://vscode-remote/ssh-remote+dev/home/u/f.ts',
})
expect(assign).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('returns a rejected promise when client navigation throws', async () => {
const error = new Error('navigation failed')
const fetchMock = hostOk()
vi.stubGlobal('window', { location: { assign: vi.fn(() => { throw error }) } })
vi.stubGlobal('fetch', fetchMock)
await expect(api.openExternal({
action: 'url',
url: 'vscode://vscode-remote/ssh-remote+dev/home/u/f.ts',
})).rejects.toBe(error)
expect(fetchMock).not.toHaveBeenCalled()
})
})
-73
View File
@@ -1,73 +0,0 @@
/**
* Host external-open helpers: per-platform opener commands (pure, injected
* platform) and the URL validation that guards the spawn route. The actual
* spawns are not exercised — the route runs in the DSH host process and the
* OS outcome is not testable here.
*/
import { describe, expect, it } from 'vitest'
import { launchExternal, revealCommand, urlCommand, validateExternalUrl } from '../src/open-external.ts'
import { SidebarError } from '../src/wire.ts'
describe('revealCommand', () => {
it('darwin: `open -R <path>` selects the file in Finder', () => {
expect(revealCommand('/a/b.txt', 'darwin')).toEqual({ command: 'open', args: ['-R', '/a/b.txt'] })
})
it('win32: passes /select,<path> as one shell-free Explorer argument', () => {
expect(revealCommand('C:\\work\\two words\\a.txt', 'win32')).toEqual({
command: 'explorer.exe',
args: ['/select,C:\\work\\two words\\a.txt'],
})
})
it('linux: `xdg-open` opens the containing directory (no common select protocol)', () => {
expect(revealCommand('/a/b.txt', 'linux')).toEqual({ command: 'xdg-open', args: ['/a'] })
expect(revealCommand('/', 'linux')).toEqual({ command: 'xdg-open', args: ['/'] })
})
})
describe('urlCommand', () => {
it('darwin: `open <url>` launches the registered protocol handler', () => {
expect(urlCommand('vscode://file/x', 'darwin')).toEqual({ command: 'open', args: ['vscode://file/x'] })
})
it('win32: rundll32 url.dll,FileProtocolHandler <url>', () => {
expect(urlCommand('cursor://file/x', 'win32')).toEqual({
command: 'rundll32.exe',
args: ['url.dll,FileProtocolHandler', 'cursor://file/x'],
})
})
it('linux: `xdg-open <url>` launches the registered protocol handler', () => {
expect(urlCommand('zed://file/x', 'linux')).toEqual({ command: 'xdg-open', args: ['zed://file/x'] })
})
})
describe('validateExternalUrl', () => {
it('accepts custom-scheme URLs (incl. the SSH-remote form)', () => {
expect(validateExternalUrl('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts'))
.toBe('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts')
expect(validateExternalUrl('myapp://file/{path}')).toBe('myapp://file/{path}')
})
it('rejects http/https (only custom schemes make sense here)', () => {
expect(() => validateExternalUrl('https://example.com')).toThrow(SidebarError)
expect(() => validateExternalUrl('http://example.com')).toThrow(SidebarError)
})
it('rejects non-URL / non-`scheme://` strings', () => {
expect(() => validateExternalUrl('/home/u/f.ts')).toThrow(SidebarError)
expect(() => validateExternalUrl('a:file/x')).toThrow(SidebarError)
expect(() => validateExternalUrl('')).toThrow(SidebarError)
})
})
describe('launchExternal validation (pre-spawn)', () => {
it('rejects relative reveal paths before anything is spawned', () => {
expect(() => launchExternal('reveal', 'relative/path')).toThrow(SidebarError)
})
it('rejects invalid URLs before anything is spawned', () => {
expect(() => launchExternal('url', 'https://example.com')).toThrow(SidebarError)
})
})
+171
View File
@@ -0,0 +1,171 @@
/**
* The client adapter over DSH's open-in-app capability: which host facility
* each gesture uses, the availability gate, and the degradation path when the
* deployment has no desktop or no Remote namespace.
*
* The host's own behaviour is NOT re-tested here — only the adapter's
* routing (Remote vs catalog route), its caching, and its fail-closed
* answers.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createOpenInApp, type OpenInAppEntry } from '../src/client/open-in-app.ts'
interface RemoteCalls {
canOpen: number
applications: { path: string }[]
open: { path: string; action?: 'reveal'; application?: string }[]
}
/** A fake `ctx.remote.session` recording every call. */
function fakeCtx(options: {
canOpen?: boolean
apps?: readonly { id: string; name: string; default: boolean; icon: string | null }[]
openResult?: boolean
revealResult?: boolean
noRemote?: boolean
} = {}): { ctx: { get(name: string): unknown }; calls: RemoteCalls } {
const calls: RemoteCalls = { canOpen: 0, applications: [], open: [] }
const session = {
canOpenWorkspacePath: async () => {
calls.canOpen += 1
return { ok: true, value: options.canOpen ?? true }
},
workspacePathApplications: async (request: { path: string }) => {
calls.applications.push(request)
if (options.apps === undefined) return { ok: false }
return { ok: true, value: options.apps }
},
openWorkspacePath: async (request: { path: string; action?: 'reveal'; application?: string }) => {
calls.open.push(request)
if (request.action === 'reveal') return { ok: options.revealResult ?? true }
return { ok: options.openResult ?? true }
},
}
return { ctx: { get: (name) => (name === 'remote' ? { session } : undefined) }, calls }
}
/** Install a fetch stub answering the host's catalog/launch routes. */
function stubFetch(options: { apps?: readonly string[]; appsStatus?: number; openStatus?: number } = {}) {
const calls: { url: string; body?: unknown }[] = []
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
calls.push({ url: String(url), body: init?.body === undefined ? undefined : JSON.parse(String(init.body)) })
if (String(url).includes('/open-in-app/apps')) {
const status = options.appsStatus ?? 200
return new Response(JSON.stringify({ apps: options.apps ?? [] }), { status })
}
return new Response('{}', { status: options.openStatus ?? 200 })
})
return calls
}
afterEach(() => {
vi.unstubAllGlobals()
})
/** The adapter built over a ctx that cannot reach the Remote at all. */
function withoutRemote() {
return createOpenInApp({ get: () => undefined })
}
describe('createOpenInApp', () => {
it('reports unavailable and degrades every gesture when the Remote is absent', async () => {
const adapter = withoutRemote()
expect(adapter.available()).toBeNull()
expect(await adapter.probe()).toBe(false)
expect(adapter.available()).toBe(false)
expect(await adapter.fileApps('/tmp/a.ts')).toBeNull()
expect(await adapter.open('/tmp/a.ts')).toBe(false)
expect(await adapter.reveal('/tmp/a.ts')).toBe(false)
})
it('probes availability once and shares the answer', async () => {
const { ctx, calls } = fakeCtx({ canOpen: true })
const adapter = createOpenInApp(ctx)
expect(await adapter.probe()).toBe(true)
expect(await adapter.probe()).toBe(true)
expect(adapter.available()).toBe(true)
expect(calls.canOpen).toBe(1)
})
it('maps the OS handlers of one file, default included', async () => {
const { ctx, calls } = fakeCtx({
apps: [{ id: '/Applications/VSCode.app', name: 'Visual Studio Code', default: true, icon: 'data:image/png;base64,AA' }],
})
const adapter = createOpenInApp(ctx)
const apps = await adapter.fileApps('/tmp/a.ts')
expect(apps).toEqual([{ id: '/Applications/VSCode.app', name: 'Visual Studio Code', icon: 'data:image/png;base64,AA', isDefault: true }])
expect(calls.applications).toEqual([{ path: '/tmp/a.ts' }])
})
it('answers null for a failed handler query, never an empty list', async () => {
const { ctx } = fakeCtx({ apps: undefined })
expect(await createOpenInApp(ctx).fileApps('/tmp/a.ts')).toBeNull()
})
it('opens a path through the Remote, default or explicit application', async () => {
const { ctx, calls } = fakeCtx()
const adapter = createOpenInApp(ctx)
expect(await adapter.open('/tmp/a.ts')).toBe(true)
expect(await adapter.open('/tmp/a.ts', '/Applications/VSCode.app')).toBe(true)
expect(calls.open).toEqual([
{ path: '/tmp/a.ts' },
{ path: '/tmp/a.ts', application: '/Applications/VSCode.app' },
])
})
it('falls back to the host catalog route when a catalog id is not a file handler', async () => {
const fetchCalls = stubFetch()
// The Remote refuses the catalog id (it is not registered for this path).
const { ctx, calls } = fakeCtx({ openResult: false })
const adapter = createOpenInApp(ctx)
expect(await adapter.open('/tmp/ws', 'vscode')).toBe(true)
expect(calls.open).toEqual([{ path: '/tmp/ws', application: 'vscode' }])
expect(fetchCalls).toEqual([{ url: '/open-in-app/open', body: { app: 'vscode', path: '/tmp/ws' } }])
})
it('reports a launch failure instead of pretending success', async () => {
stubFetch({ openStatus: 400 })
const { ctx } = fakeCtx({ openResult: false })
expect(await createOpenInApp(ctx).open('/tmp/ws', 'vscode')).toBe(false)
})
it('reveals through the Remote action', async () => {
const { ctx, calls } = fakeCtx()
expect(await createOpenInApp(ctx).reveal('/tmp/a.ts')).toBe(true)
expect(calls.open).toEqual([{ path: '/tmp/a.ts', action: 'reveal' }])
})
it('lists the directory catalog from the host route, with icon URLs', async () => {
const fetchCalls = stubFetch({ apps: ['vscode', 'finder'] })
const { ctx } = fakeCtx()
const apps: readonly OpenInAppEntry[] = await createOpenInApp(ctx).directoryApps()
expect(apps.map(app => app.id)).toEqual(['vscode', 'finder'])
expect(apps[0]?.icon).toBe('/open-in-app/icon/vscode')
// Without the host locale namespace the label falls back to the id itself.
expect(apps[0]?.name).toBe('Vscode')
expect(fetchCalls[0]?.url).toBe('/open-in-app/apps')
})
it('answers an empty catalog when the host route fails or the desktop is gone', async () => {
stubFetch({ appsStatus: 500 })
const { ctx } = fakeCtx()
expect(await createOpenInApp(ctx).directoryApps()).toEqual([])
const offline = fakeCtx({ canOpen: false })
expect(await createOpenInApp(offline.ctx).directoryApps()).toEqual([])
})
})
describe('createOpenInApp (host inject guard)', () => {
it('treats a ctx that THROWS on service access as unavailable instead of crashing the caller', async () => {
// Cordis raises `cannot get property "remote.session" without inject`
// when the bundle forgets the inject entry; a render path must survive it.
const adapter = createOpenInApp({
get: () => { throw new Error('cannot get property "remote.session" without inject') },
})
expect(await adapter.probe()).toBe(false)
expect(await adapter.fileApps('/tmp/a.ts')).toBeNull()
expect(await adapter.open('/tmp/a.ts')).toBe(false)
expect(await adapter.reveal('/tmp/a.ts')).toBe(false)
})
})
-112
View File
@@ -1,112 +0,0 @@
/**
* The editor's "open with" settings panel: the SSH host row and the custom
* editor list (name + URL template + VSCode-family flag, add/remove). Every
* edit commits the whole `openWith` blob through the popup's
* updatePluginSetting; removing an editor also prunes its pinned id.
*/
// @vitest-environment jsdom
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { OpenWithSettings } from '../src/client/open-with-settings.tsx'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
interface Harness {
container: HTMLDivElement
update: ReturnType<typeof vi.fn>
unmount: () => void
}
function mountSettings(rawOpenWith: unknown): Harness {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const update = vi.fn()
act(() => {
root.render(createElement(OpenWithSettings, {
pluginSettings: { openWith: rawOpenWith },
updatePluginSetting: update,
}))
})
return {
container,
update,
unmount: () => { act(() => { root.unmount() }) },
}
}
const baseConfig = {
sshHost: 'dev',
customEditors: [
{ id: 'a', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
],
pinned: ['vscode', 'custom:a'],
}
describe('OpenWithSettings', () => {
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
})
it('seeds the SSH host input from the persisted blob and commits edits', () => {
harness = mountSettings(baseConfig)
const input = harness.container.querySelector<HTMLInputElement>('input:not([type])')
expect(input?.value).toBe('dev')
// The native value setter (React's value tracker must see the change).
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
act(() => {
setter.call(input, 'prod')
input!.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(harness.update.mock.calls[0]?.[0]).toBe('openWith')
expect(harness.update.mock.calls[0]?.[1]).toMatchObject({ sshHost: 'prod' })
})
it('adds a custom editor row and commits the extended list', () => {
harness = mountSettings(baseConfig)
const add = [...harness.container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === 'Add')
expect(add).toBeDefined()
act(() => { add!.click() })
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
expect(blob.customEditors).toHaveLength(2)
expect(blob.pinned).toEqual(['vscode', 'custom:a'])
})
it('removes an editor and prunes its pinned id', () => {
harness = mountSettings(baseConfig)
const remove = harness.container.querySelector<HTMLButtonElement>('button[aria-label="Remove"]')
expect(remove).toBeDefined()
act(() => { remove!.click() })
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
expect(blob.customEditors).toHaveLength(0)
expect(blob.pinned).toEqual(['vscode'])
})
it('shows the validity hint when a row is incomplete', () => {
harness = mountSettings({
sshHost: '',
customEditors: [{ id: 'x', name: 'Bad', urlTemplate: 'x://file/x', isVscodeFamily: true }],
pinned: [],
})
expect(harness.container.textContent).toContain('are not shown in the menu')
})
it('edits the VSCode-family flag of one row', () => {
harness = mountSettings(baseConfig)
const checkbox = harness.container.querySelector<HTMLInputElement>('input[type="checkbox"]')
expect(checkbox?.checked).toBe(false)
act(() => { checkbox!.click() })
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: { isVscodeFamily: boolean }[] }
expect(blob.customEditors[0]?.isVscodeFamily).toBe(true)
})
})
-150
View File
@@ -1,150 +0,0 @@
/**
* The file tree "open with" pure logic: config parsing (tolerant), target
* resolution (built-ins + custom, SSH filtering), URL building (local file
* URLs and VSCode-family SSH-remote URLs) and the custom-editor validators.
*/
import { describe, expect, it } from 'vitest'
import {
isValidCustomEditor,
newCustomEditorId,
normalizeUrlPath,
openWithSshActive,
openWithUrl,
parseOpenWithConfig,
resolveOpenWithTargets,
type CustomEditor,
type OpenWithTarget,
} from '../src/client/open-with.ts'
describe('parseOpenWithConfig', () => {
it('returns the defaults for null / non-object / array values', () => {
expect(parseOpenWithConfig(null)).toEqual({ sshHost: '', customEditors: [], pinned: [] })
expect(parseOpenWithConfig('junk')).toEqual({ sshHost: '', customEditors: [], pinned: [] })
expect(parseOpenWithConfig([])).toEqual({ sshHost: '', customEditors: [], pinned: [] })
})
it('keeps structurally valid custom editors (incl. in-progress rows) and drops junk', () => {
const config = parseOpenWithConfig({
sshHost: 'dev',
customEditors: [
{ id: 'a', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
{ id: 'b', name: '', urlTemplate: 'b://file/{path}', isVscodeFamily: true },
{ id: 'c', name: 'Trae', urlTemplate: 'trae://file/{path}', isVscodeFamily: true },
'junk',
null,
42,
],
pinned: ['vscode', 'custom:a', 42, '', 'custom:c'],
})
expect(config.sshHost).toBe('dev')
expect(config.customEditors.map(editor => editor.id)).toEqual(['a', 'b', 'c'])
expect(config.pinned).toEqual(['vscode', 'custom:a', 'custom:c'])
})
})
describe('resolveOpenWithTargets', () => {
const localCustom: CustomEditor = { id: 'e1', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false }
const familyCustom: CustomEditor = { id: 'e2', name: 'Zed-like fork', urlTemplate: 'x-zed://file/{path}', isVscodeFamily: true }
it('lists built-ins first, then custom editors (local mode)', () => {
const targets = resolveOpenWithTargets(parseOpenWithConfig({ customEditors: [localCustom] }))
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'zed', 'custom:e1'])
expect(targets[0]?.kind).toBe('reveal')
expect(targets[1]?.urlTemplate).toBe('vscode://file/{path}')
})
it('excludes incomplete custom-editor rows from the menu (they stay in the blob)', () => {
const targets = resolveOpenWithTargets(parseOpenWithConfig({
customEditors: [
{ id: 'e1', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
{ id: 'e2', name: '', urlTemplate: 'incomplete://file/{path}', isVscodeFamily: true },
],
}))
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'zed', 'custom:e1'])
})
it('drops local-only targets in SSH mode (file manager, Zed, non-VSCode custom)', () => {
const config = parseOpenWithConfig({ sshHost: 'dev', customEditors: [localCustom, familyCustom] })
const targets = resolveOpenWithTargets(config)
expect(targets.map(target => target.id)).toEqual(['vscode', 'cursor', 'custom:e2'])
})
it('prunes unknown pinned ids only when resolving (the parse keeps them)', () => {
const config = parseOpenWithConfig({ pinned: ['ghost'] })
expect(config.pinned).toEqual(['ghost'])
expect(resolveOpenWithTargets(config).map(target => target.id)).toContain('vscode')
})
})
describe('openWithUrl', () => {
const targets = resolveOpenWithTargets(parseOpenWithConfig({}))
const byId = (id: string): OpenWithTarget => {
const target = targets.find(item => item.id === id)
if (target === undefined) throw new Error(`no target ${id}`)
return target
}
it('builds local file URLs with the raw path (backslashes normalized)', () => {
expect(openWithUrl(byId('vscode'), '/home/u/f.ts', parseOpenWithConfig({}))).toBe('vscode://file//home/u/f.ts')
expect(openWithUrl(byId('vscode'), 'C:\\Users\\u\\f.ts', parseOpenWithConfig({}))).toBe('vscode://file/C:/Users/u/f.ts')
expect(openWithUrl(byId('zed'), '/x/y', parseOpenWithConfig({}))).toBe('zed://file//x/y')
})
it('builds SSH-remote URLs for VSCode-family targets in SSH mode', () => {
const config = parseOpenWithConfig({ sshHost: 'dev' })
expect(openWithUrl(byId('vscode'), '/home/u/f.ts', config))
.toBe('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts')
expect(openWithUrl(byId('cursor'), '/home/u/f.ts', config))
.toBe('cursor://vscode-remote/ssh-remote+dev/home/u/f.ts')
})
it('keeps the local URL form for non-VSCode-family targets even in SSH mode', () => {
// (Such targets are hidden from the SSH menu; the URL builder stays honest.)
expect(openWithUrl(byId('zed'), '/x', parseOpenWithConfig({ sshHost: 'dev' }))).toBe('zed://file//x')
})
it('returns undefined for reveal targets and malformed templates', () => {
expect(openWithUrl(byId('explorer'), '/x', parseOpenWithConfig({}))).toBeUndefined()
const noScheme: OpenWithTarget = { id: 'x', name: '', kind: 'url', urlTemplate: 'not-a-url {path}', isVscodeFamily: false, localOnly: false }
expect(openWithUrl(noScheme, '/x', parseOpenWithConfig({}))).toBeUndefined()
const noSlash: OpenWithTarget = { id: 'y', name: '', kind: 'url', urlTemplate: 'a:file/{path}', isVscodeFamily: false, localOnly: false }
expect(openWithUrl(noSlash, '/x', parseOpenWithConfig({}))).toBeUndefined()
})
it('uses the custom template scheme for SSH URLs of VSCode-family custom editors', () => {
const config = parseOpenWithConfig({
sshHost: 'dev',
customEditors: [
{ id: 'e', name: 'Fork', urlTemplate: 'myfork://file/{path}', isVscodeFamily: true },
],
})
const targets = resolveOpenWithTargets(config)
expect(openWithUrl(targets[targets.length - 1]!, '/r/f.ts', config))
.toBe('myfork://vscode-remote/ssh-remote+dev/r/f.ts')
})
})
describe('helpers', () => {
it('normalizeUrlPath converts backslashes to slashes', () => {
expect(normalizeUrlPath('a\\b\\c')).toBe('a/b/c')
})
it('openWithSshActive trims the host', () => {
expect(openWithSshActive(parseOpenWithConfig({ sshHost: ' ' }))).toBe(false)
expect(openWithSshActive(parseOpenWithConfig({ sshHost: ' dev ' }))).toBe(true)
})
it('isValidCustomEditor requires a name, a {path} placeholder and a scheme:// prefix', () => {
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a://file/{path}' })).toBe(true)
expect(isValidCustomEditor({ name: '', urlTemplate: 'a://file/{path}' })).toBe(false)
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a://file/x' })).toBe(false)
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a:file/{path}' })).toBe(false)
})
it('newCustomEditorId yields unique non-empty ids', () => {
const first = newCustomEditorId()
const second = newCustomEditorId()
expect(first).not.toBe(second)
expect(first.length).toBeGreaterThan(0)
})
})
+250
View File
@@ -0,0 +1,250 @@
/**
* The global UI kit and the shared git-status store.
*
* The porcelain mapping and the store's contract are the two things every
* page now depends on (the file tree colors rows from the same snapshot the
* changes page lists), so both are pinned here: one fetch per key, the
* visible-only poll, the queued refresh, and the lazy path index.
*/
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { setupReactAct } from './test-utils.ts'
import { Chip, ConfirmDialog, IconButton, Notice, SectionHeader, StatusBadge } from '../src/client/ui/kit.tsx'
import { invalidateGitStatus, statusOfXY, useGitStatus, type GitStatusView } from '../src/client/ui/git-status.ts'
import type { GitStatusResult } from '../src/client/api.ts'
setupReactAct()
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
const { gitStatus } = vi.hoisted(() => ({ gitStatus: vi.fn() }))
vi.mock('../src/client/api.ts', () => ({ api: { gitStatus } }))
afterEach(() => {
gitStatus.mockReset()
})
/** Mount one element and hand back its container plus an unmount. */
async function mount(node: ReactNode): Promise<{ container: HTMLDivElement; unmount: () => Promise<void> }> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
await act(async () => { root.render(node) })
return {
container,
unmount: async () => { await act(async () => { root.unmount() }); container.remove() },
}
}
describe('statusOfXY', () => {
it('maps the worktree/index letter pairs to the shared tone vocabulary', () => {
expect(statusOfXY(' M')).toMatchObject({ letter: 'M', tone: 'modified', staged: false, unstaged: true })
expect(statusOfXY('M ')).toMatchObject({ letter: 'M', tone: 'modified', staged: true, unstaged: false })
expect(statusOfXY('MM')).toMatchObject({ tone: 'modified', staged: true, unstaged: true })
expect(statusOfXY('A ')).toMatchObject({ letter: 'A', tone: 'added' })
expect(statusOfXY(' D')).toMatchObject({ letter: 'D', tone: 'deleted' })
expect(statusOfXY('R ')).toMatchObject({ letter: 'R', tone: 'renamed' })
expect(statusOfXY('C ')).toMatchObject({ letter: 'C', tone: 'copied' })
expect(statusOfXY('UU')).toMatchObject({ letter: 'U', tone: 'conflict', staged: true, unstaged: true })
})
it('reads untracked as its own tone and ignores clean/ignored rows', () => {
expect(statusOfXY('??')).toEqual({ letter: 'U', tone: 'untracked', staged: false, unstaged: true })
expect(statusOfXY(' ')).toBeUndefined()
expect(statusOfXY('!!')).toBeUndefined()
})
})
/** A snapshot with one changed file under `<root>/src` and one at the root. */
function snapshot(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
return {
isRepo: true,
branch: 'main',
root: '/ws',
entries: [
{ path: 'src/a.ts', xy: ' M' },
{ path: 'top.md', xy: '??' },
],
...overrides,
}
}
/** A probe component exposing the hook's view to the test body. */
function Probe(props: { scope: { sessionId: string; cwd?: string }; visible?: boolean; onView(view: GitStatusView): void }): ReactNode {
const view = useGitStatus(props.scope, { visible: props.visible ?? true })
props.onView(view)
return createElement('span', null, String(view.snapshot?.entries.length ?? -1))
}
describe('useGitStatus', () => {
it('fetches once per key and indexes paths and their ancestor directories', async () => {
gitStatus.mockResolvedValue(snapshot())
let view: GitStatusView | undefined
const { unmount } = await mount(createElement(Probe, { scope: { sessionId: 's1', cwd: '/ws' }, onView: (next) => { view = next } }))
expect(gitStatus).toHaveBeenCalledTimes(1)
expect(view?.snapshot?.isRepo).toBe(true)
expect(view?.statusOf('/ws/src/a.ts')?.tone).toBe('modified')
expect(view?.statusOf('/ws/top.md')?.tone).toBe('untracked')
// Case- and separator-insensitive lookup, so a Windows host's mixed
// separators still color the row.
expect(view?.statusOf('\\ws\\SRC\\a.ts')?.letter).toBe('M')
expect(view?.statusOf('/ws/src/other.ts')).toBeUndefined()
expect(view?.dirHasChanges('/ws/src')).toBe(true)
expect(view?.dirHasChanges('/ws')).toBe(true)
expect(view?.dirHasChanges('/ws/docs')).toBe(false)
await unmount()
})
it('stays unfetched while invisible and polls once visible again', async () => {
gitStatus.mockResolvedValue(snapshot())
const { unmount } = await mount(createElement(Probe, { scope: { sessionId: 's2' }, visible: false, onView: () => {} }))
expect(gitStatus).not.toHaveBeenCalled()
await unmount()
})
it('drops the snapshot when the last consumer leaves, so a remount re-reads', async () => {
gitStatus.mockResolvedValue(snapshot())
const first = await mount(createElement(Probe, { scope: { sessionId: 's3' }, onView: () => {} }))
expect(gitStatus).toHaveBeenCalledTimes(1)
await first.unmount()
// A kept snapshot would let the store serve the old answer with no fetch.
const second = await mount(createElement(Probe, { scope: { sessionId: 's3' }, onView: () => {} }))
expect(gitStatus).toHaveBeenCalledTimes(2)
await second.unmount()
})
it('coalesces a refresh that lands mid-flight instead of dropping it', async () => {
let resolveFirst: ((value: GitStatusResult) => void) | undefined
gitStatus
.mockImplementationOnce(() => new Promise<GitStatusResult>((resolve) => { resolveFirst = resolve }))
.mockResolvedValue(snapshot({ entries: [{ path: 'src/b.ts', xy: ' M' }] }))
let view: GitStatusView | undefined
const { unmount } = await mount(createElement(Probe, { scope: { sessionId: 's4' }, onView: (next) => { view = next } }))
await act(async () => {
view?.refresh()
view?.refresh()
})
expect(gitStatus).toHaveBeenCalledTimes(1)
await act(async () => {
resolveFirst?.(snapshot())
await Promise.resolve()
await Promise.resolve()
})
expect(gitStatus).toHaveBeenCalledTimes(2)
await unmount()
})
it('surfaces a failed read as an error without inventing a status', async () => {
gitStatus.mockRejectedValue(new Error('no git'))
let view: GitStatusView | undefined
const { unmount } = await mount(createElement(Probe, { scope: { sessionId: 's5' }, onView: (next) => { view = next } }))
expect(view?.error).toBe(true)
expect(view?.snapshot).toBeNull()
expect(view?.statusOf('/ws/a.ts')).toBeUndefined()
await unmount()
})
it('serves two consumers of the same key from ONE fetch (the tree and the changes lens)', async () => {
gitStatus.mockResolvedValue(snapshot())
let first: GitStatusView | undefined
let second: GitStatusView | undefined
const { unmount } = await mount(createElement('div', null,
createElement(Probe, { scope: { sessionId: 's-shared', cwd: '/ws' }, onView: (view) => { first = view } }),
createElement(Probe, { scope: { sessionId: 's-shared', cwd: '/ws' }, onView: (view) => { second = view } }),
))
expect(gitStatus).toHaveBeenCalledTimes(1)
expect(first?.statusOf('/ws/src/a.ts')?.letter).toBe('M')
expect(second?.statusOf('/ws/src/a.ts')?.letter).toBe('M')
await unmount()
})
it('invalidateGitStatus re-reads every live key of one session', async () => {
gitStatus.mockResolvedValue(snapshot())
const { unmount } = await mount(createElement(Probe, { scope: { sessionId: 's6' }, onView: () => {} }))
expect(gitStatus).toHaveBeenCalledTimes(1)
await act(async () => {
invalidateGitStatus('s6')
await Promise.resolve()
})
expect(gitStatus).toHaveBeenCalledTimes(2)
await unmount()
})
})
describe('ui kit atoms', () => {
it('IconButton carries its accessible name, pressed state and disabled lock', async () => {
const onClick = vi.fn()
const { container, unmount } = await mount(createElement(IconButton, { icon: createElement('i'), label: 'Refresh', active: true, onClick }))
const button = container.querySelector('button')!
expect(button.getAttribute('aria-label')).toBe('Refresh')
expect(button.getAttribute('title')).toBe('Refresh')
expect(button.getAttribute('aria-pressed')).toBe('true')
await act(async () => { button.click() })
expect(onClick).toHaveBeenCalledTimes(1)
await unmount()
})
it('Chip shows its count and reflects selection', async () => {
const onClick = vi.fn()
const { container, unmount } = await mount(createElement(Chip, { active: true, count: 3, onClick, children: 'Write' }))
const chip = container.querySelector('button')!
expect(chip.textContent).toBe('Write3')
expect(chip.getAttribute('data-active')).toBe('true')
await unmount()
})
it('SectionHeader renders label, count and its one action slot', async () => {
const { container, unmount } = await mount(
createElement(SectionHeader, {
label: 'Staged',
count: 2,
action: createElement('button', null, 'Go'),
children: createElement('span', null, 'x'),
}),
)
expect(container.textContent).toContain('Staged')
expect(container.textContent).toContain('2')
expect(container.textContent).toContain('x')
expect(container.querySelector('button')?.textContent).toBe('Go')
await unmount()
})
it('Notice renders one line per kind and announces errors', async () => {
const { container, unmount } = await mount(createElement(Notice, { kind: 'error', children: 'boom' }))
const node = container.querySelector('[data-kind="error"]')!
expect(node.getAttribute('role')).toBe('alert')
expect(node.textContent).toBe('boom')
await unmount()
})
it('StatusBadge exposes the tone as data for the sheet', async () => {
const { container, unmount } = await mount(createElement(StatusBadge, { tone: 'untracked', title: 'Untracked', children: 'U' }))
const badge = container.querySelector('[data-tone="untracked"]')!
expect(badge.getAttribute('title')).toBe('Untracked')
await unmount()
})
it('ConfirmDialog routes both actions', async () => {
const onConfirm = vi.fn()
const onClose = vi.fn()
const { unmount } = await mount(createElement(ConfirmDialog, {
open: true, title: 'Delete?', description: 'gone forever', confirmLabel: 'Delete', cancelLabel: 'Cancel',
danger: true, onConfirm, onClose,
}))
const buttons = [...document.querySelectorAll('button')]
const confirm = buttons.find(button => button.textContent === 'Delete')
const cancel = buttons.find(button => button.textContent === 'Cancel')
expect(confirm).toBeDefined()
expect(cancel).toBeDefined()
await act(async () => { confirm?.click() })
await act(async () => { cancel?.click() })
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
await unmount()
})
})