Files
apes-sidebar/tests/builtins.spec.ts
T
Atlas Zeng 22a5d372be feat(editor): 文件树「在应用中打开」子菜单(外部编辑器/图钉固定/SSH 远端/自定义编辑器) (#334)
* 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.
2026-08-22 23:19:17 +08:00

272 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Built-in registration tests: the plugin registers 7 tabs and 6 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
* and the html sandbox settings pin the registry's behavior. (Office
* previews are NOT built in — they moved to the recommended office plugin,
* see src/client/plugins-viewers.ts.)
*/
import { describe, expect, it } from 'vitest'
// First import: browser globals before the xterm-carrying builtin graph loads.
import './browser-globals.ts'
import type { Context } from '../src/context-types.ts'
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { allLeaves } from '../src/client/state.ts'
import { registerBuiltins } from '../src/client/builtins/index.ts'
import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx'
import { parkSidechatReopen } from '../src/client/SideChatView.tsx'
import { t } from '../src/client/locales.ts'
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerBuiltins({} as Context, service, options)
return { service, store, dispose }
}
describe('built-in tab registrations', () => {
it('registers the 7 built-in tabs', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['browser', 'diff', 'editor', 'git', 'sidechat', 'subagent', 'terminal'],
)
})
it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => {
const { service } = setup()
expect(service.getTabs().filter(t => t.hidden).map(t => t.id)).toEqual(['diff'])
const editor = service.getTab('editor')
expect(editor?.hidden).toBe(false)
expect(editor?.order).toBe(10)
})
it('single-instance tabs use the single sugar', () => {
const { service } = setup()
for (const id of ['git', 'subagent']) {
expect(service.getTab(id)?.single).toBe(true)
}
})
it('the side chat tab sits between tasks and terminal in the + menu', () => {
const { service } = setup()
const sidechat = service.getTab('sidechat')
expect(sidechat?.order).toBe(35)
expect(sidechat?.hidden).not.toBe(true)
})
it('side chat mints one tab per thread (Codex-style multi-instance)', () => {
const { service } = setup()
const sidechat = service.getTab('sidechat')
expect(sidechat?.single).not.toBe(true)
// A plain open mints a fresh autoCreate tab (the view creates the
// thread on mount); two opens never collide. (createTab ignores the
// state argument for sidechat — the cast stands in for it.)
const first = sidechat?.createTab?.(undefined as never)
const second = sidechat?.createTab?.(undefined as never)
expect(first?.tab.meta).toEqual({ autoCreate: true })
expect(first?.tab.id).not.toBe(second?.tab.id)
// A parked reopen target mints the deterministic reattach tab, and
// dedupeKey focuses an already-open thread instead of duplicating it.
parkSidechatReopen('session-t1')
const reopen = sidechat?.createTab?.(undefined as never)
expect(reopen?.tab.id).toBe('sidechat:session-t1')
expect(reopen?.tab.meta).toEqual({ threadId: 'session-t1' })
expect(sidechat?.dedupeKey?.(reopen!.tab)).toBe('session-t1')
expect(sidechat?.dedupeKey?.(first!.tab)).toBeUndefined()
})
it('the subagent tab declares its auto-open related settings', () => {
const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
})
it('the editor tab declares its merged-mode (embedded file tree) setting', () => {
const { service } = setup()
const toggles = service.getTab('editor')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['editorExplorer'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
// The merged mode is an iconed select (merged vs separate), not a switch.
expect(toggles[0]?.type).toBe('select')
const options = toggles[0]?.options ?? []
expect(options.map(o => o.value)).toEqual([true, false])
expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true)
// The open-with configuration (SSH host + custom editors) is the custom
// panel rendered below the declarative rows.
expect(service.getTab('editor')?.settings?.render).toBeDefined()
})
it('the terminal tab declares the model terminal-tools, auto-terminal, shell and custom-font settings', () => {
const { service } = setup()
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalShell', 'terminalShellArgs', 'terminalFontFamily', 'terminalFontSize'])
// The shell rows are text inputs (empty = yaml/auto resolution), the
// font rows text/number inputs (not switches), with the size row bounded
// by the shared 9–32 contract.
expect(toggles[2]?.type).toBe('text')
expect(toggles[2]?.title).toBeDefined()
expect(toggles[2]?.placeholder).toBeDefined()
expect(toggles[3]?.type).toBe('text')
expect(toggles[3]?.title).toBeDefined()
expect(toggles[4]?.type).toBe('text')
expect(toggles[4]?.title).toBeDefined()
expect(toggles[4]?.placeholder).toBeDefined()
expect(toggles[5]?.type).toBe('number')
expect(toggles[5]?.min).toBe(9)
expect(toggles[5]?.max).toBe(32)
expect(toggles[5]?.unit).toBe('px')
// The first two rows stay plain boolean switches.
expect(toggles[0]?.type ?? 'switch').toBe('switch')
expect(toggles[1]?.type ?? 'switch').toBe('switch')
})
it('the browser tab declares its sandbox, link-takeover master and per-protocol settings', () => {
const { service } = setup()
const toggles = service.getTab('browser')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual([
'browserNoSandbox',
'browserInterceptLinks',
'browserInterceptHttp',
'browserInterceptHttps',
])
for (const toggle of toggles) {
expect(toggle.title).toBeDefined()
expect(toggle.desc).toBeDefined()
}
})
it('the browser createTab mints browser:<n> ids and bumps nextBrowser', () => {
const { service, store } = setup()
store.setSession('s1')
service.openTab({ type: 'browser' })
service.openTab({ type: 'browser' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.id).toBe('browser:1')
expect(tabs[1]!.id).toBe('browser:2')
expect(state.nextBrowser).toBe(3)
})
it('the terminal createTab uses shell-name titles and hidden uuid ids, allowing duplicates', () => {
const { service, store } = setup({ terminalTitle: () => 'bash' })
store.setSession('s1')
service.openTab({ type: 'terminal' })
service.openTab({ type: 'terminal' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.title).toBe('bash')
expect(tabs[1]!.title).toBe('bash')
expect(tabs[0]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
expect(tabs[1]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
expect(tabs[0]!.id).not.toBe(tabs[1]!.id)
})
it('the terminal createTab falls back to the localized terminal label before shell info resolves', () => {
const { service, store } = setup()
store.setSession('s1')
service.openTab({ type: 'terminal' })
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal')
expect(tab?.title).toBe(t('terminal'))
})
it('every built-in tab carries the settings-surface icon', () => {
const { service } = setup()
for (const tab of service.getTabs()) {
expect(tab.icon, tab.id).toBeDefined()
}
})
})
describe('built-in file viewer registrations', () => {
it('registers the 6 built-in file viewers (office previews live in the recommended office plugin)', () => {
const { service } = setup()
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
['binary-download', 'code', 'html', 'image', 'markdown', 'pdf'],
)
// Office previews are not built in: docx/xlsx/pptx files fall through to
// the download-only binary viewer (or a registered office plugin).
expect(service.getFileViewers().map(v => v.id)).not.toContain('docx')
expect(service.getFileViewers().map(v => v.id)).not.toContain('xlsx')
expect(service.getFileViewers().map(v => v.id)).not.toContain('pptx')
})
it('code is the catch-all at the lowest priority', () => {
const { service } = setup()
const code = service.getFileViewers().find(v => v.id === 'code')
expect(code?.exts).toEqual([])
expect(code?.priority).toBe(-100)
expect(code?.fetchStrategy).toBe('fsRead')
expect(service.matchFileViewer('anything.zzz')?.id).toBe('code')
})
it('markdown claims md/markdown before the catch-all', () => {
const { service } = setup()
expect(service.matchFileViewer('readme.md')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.markdown')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
})
it('html claims html/htm before the catch-all', () => {
const { service } = setup()
expect(service.matchFileViewer('index.html')?.id).toBe('html')
expect(service.matchFileViewer('page.htm')?.id).toBe('html')
expect(service.matchFileViewer('index.html', new Uint8Array([0x3c, 0x21]))?.id).toBe('html')
expect(service.matchFileViewer('index.HTML')?.id).toBe('html')
})
it('the html viewer declares its sandbox and default-unsafe related settings', () => {
const { service } = setup()
const toggles = service.getFileViewers().find(v => v.id === 'html')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['htmlViewerNoSandbox', 'htmlViewerDefaultUnsafe'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
expect(toggles[1]?.title).toBeDefined()
expect(toggles[1]?.desc).toBeDefined()
})
it('binary-download claims legacy office by extension (office previews are not built in)', () => {
const { service } = setup()
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
// Modern office files (zip containers, NUL-free) fall through to the
// catch-all code viewer without an office plugin registered.
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('code')
})
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
const { service } = setup()
// First match (no head) falls to the catch-all code viewer...
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
// ...but the head re-match (NUL probe) routes it to binary-download.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download')
// A NUL-free blob stays with code.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
})
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
const { service } = setup()
for (const viewer of service.getFileViewers()) {
expect(viewer.title, viewer.id).toBeDefined()
expect(viewer.icon, viewer.id).toBeDefined()
}
})
})
describe('built-in disposer', () => {
it('unregisters everything (HMR-safe)', () => {
const { service, dispose } = setup()
dispose()
expect(service.getTabs()).toHaveLength(0)
expect(service.getFileViewers()).toHaveLength(0)
// The disposer is idempotent.
dispose()
})
})