* feat(editor): file tree 'open with' submenu (external editors, pin, SSH-remote, custom editors) The file tree right-click menu gains an 'Open with >' submenu with the resource manager (reveal in file manager), VS Code, Cursor and Zed, plus user-defined custom editors (name + URL template + VSCode-family flag). Each submenu row carries a pushpin that promotes the target to a direct top-level menu item. A configurable SSH host (settings: Files card gear) switches VSCode-family openers to <scheme>://vscode-remote/ssh-remote+<host>/<path> and hides host-local targets (file manager / Zed / non-VSCode custom editors). External opens run through the new fenced /sidebar/api/open.external route which spawns the platform opener with an argv array (no shell); the client never navigates raw scheme URLs itself. The settings seam's render panel now appends after the declarative rows instead of replacing them. * feat(editor): polish open-with menu UI — brand icons, pushpin, chevron, row alignment - Submenu rows: the custom label is now a block-level flex row filling the primitives itemLabel, so the pushpin docks at the row's far right (matching the trailing check of every other menu); menu row icons normalized to 16px (DSH native menus use 16, ours were 14). - The submenu parent row gains a right-aligned chevron (the primitives Menu renders no arrow for submenu parents). - Icons via react-icons: codicons VscPin/VscPinned pushpins (replacing the hand-drawn pin), VscFile/VscFolder/VscFolderOpened tree glyphs (replacing the </> file mark), VscLinkExternal for the open-with parent and the browser tab's open-external button (globally), SiCursor/SiZedindustries brand silhouettes; the VS Code mark is inlined from simple-icons@11.0.0 (CC0) as IconVscode16 since later simple-icons releases dropped it. All monochrome currentColor to follow the active skin. - tsdown: pin react-icons si/vsc to their ESM entries via resolve.alias — the package's exports map lists require before import, so the shared conditionNames picked the unshakeable CJS entry and bloated the core bundle by ~6.4 MB; tree-shaken it now adds only ~12 KB.
112 lines
4.1 KiB
TypeScript
112 lines
4.1 KiB
TypeScript
/**
|
|
* 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'
|
|
|
|
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
|
|
|
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)
|
|
})
|
|
})
|