/** * FileTree's PLUGIN open-with menu. Round three folded every application into * ONE submenu (`openWithMenu`), so with no host handle injected the level-1 * menu carries just that submenu row, and the plugin's targets (file manager / * VS Code / Cursor / Zed / custom editors) are its rows — each with the * per-row pushpin that toggles without selecting the row, SSH suffixes in * remote mode, and (without the host) the reveal target kept, so reveal is * never lost. The host/plugin VISIBILITY rules live in * file-tree-open-in-app.spec.tsx. */ // @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 { OpenWithTarget } from '../src/client/open-with.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: { fsTrees: async (_scope: unknown, paths: readonly string[]) => ({ levels: paths.map(path => ({ path, 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. gitStatus: async () => ({ isRepo: false, entries: [] }), }, downloadUrl: () => '/sidebar/file', isOutsideWorkspaceMessage: () => false, })) /** 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 onToggleOpenWithPin: ReturnType unmount: () => void } async function mountTree(overrides: { openWithTargets?: OpenWithTarget[] openWithPinned?: string[] openWithSsh?: boolean } = {}): Promise { 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', 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, })) await Promise.resolve() }) return { container, onOpenWith, onToggleOpenWithPin, unmount: () => { act(() => { root.unmount() }); container.remove() }, } } /** The file row of the one-level tree. */ function fileRow(container: HTMLDivElement): HTMLElement { const row = [...container.querySelectorAll('[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) }) } function menuItems(): HTMLElement[] { return [...document.querySelectorAll('[role="menuitem"]')] } function submenuRows(): HTMLElement[] { return [...document.querySelectorAll('[role="menu"] [role="menu"] [role="menuitem"]')] } function submenuParent(): HTMLElement { const parent = menuItems().find(item => item.getAttribute('aria-haspopup') === 'menu') if (parent === undefined) throw new Error('submenu parent not found') return parent } describe('FileTree plugin open-with menu', () => { let harness: Harness afterEach(() => { harness.unmount() document.body.innerHTML = '' document.body.removeAttribute('data-dsh-sidebar-submenu') }) it('keeps application rows out of level 1 and lists them under the open-with submenu', async () => { harness = await mountTree() openMenu(harness.container) // Level 1: the one submenu parent — no application name sits on it. expect(menuItems().map(item => item.textContent?.trim())).toContain('Open with') for (const name of ['File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf']) { expect(menuItems().map(item => item.textContent?.trim())).not.toContain(name) } const parent = submenuParent() // 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('lists every target in the submenu, each with a pin toggle', async () => { harness = await mountTree() openMenu(harness.container) act(() => { submenuParent().click() }) expect(submenuRows().map(item => item.textContent?.trim())).toEqual([ 'File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf', ]) expect(submenuRows().every(item => item.querySelector('[class*="openWithPin"]') !== null)).toBe(true) // Pinned state is carried per row by the pin's TITLE (it swaps the glyph // and the hover hint). const vscodeRow = submenuRows().find(item => item.textContent?.trim() === 'VS Code') const cursorRow = submenuRows().find(item => item.textContent?.trim() === 'Cursor') expect(vscodeRow?.querySelector('[title="Unpin"]')).not.toBeNull() expect(cursorRow?.querySelector('[title="Pin to menu"]')).not.toBeNull() }) it('keeps the pin a mouse hot zone, never a fake nested control', async () => { // The Menu renders each row as `