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:
@@ -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
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user