* feat(editor): merged explorer mode — path input, docked file tree, global name search
New editorExplorer pref (default on, editor card gear): the editor tab gains
a path-input header and a toggleable right-docked tree panel (per-tab state
in tab.meta.treeOpen; file tabs default closed, the seeded path-less home
tab defaults open). Fresh sessions seed that empty editor tab instead of
the explorer tab when merged mode is on; the standalone explorer tab stays
in the + menu unchanged.
- Extract FileTree.tsx from ExplorerView (shared by both hosts)
- Host fs.search route: recursive case-insensitive name search rooted at
the session cwd, budget-capped (200 matches / 100k visited), skips .git,
never descends symlink dirs
- EditorHost reads the pref reactively (boolean snapshot) and keeps the
pre-merge layout verbatim when off
* feat(settings): select row type for declarative settings; editorExplorer as iconed select
- SidebarSettingToggle gains type 'select' with options {value,title,desc,icon}
and multi (default false; stores the picked values as an array in options
order). Any option carrying an icon renders big-icon option cards in the
dropdown and the icon in the closed anchor; icon-less rows are one-line text.
- SelectRow in SideCardSection rides the primitives Menu (portal); single-pick
commits and closes, multi toggles and stays open. Wired for both host-prefs
toggles and plugin-owned pluginToggles via the new onSelectValue seam.
- The editorExplorer merged-mode pref is now an iconed select (merged vs
separate) instead of a switch; the stored value stays boolean.
- Advertise the capability as 'settingSelect' in SIDEBAR_FEATURES.
* feat(editor): unify explorer into the editor surface, resizable tree dock, hoisted toolbar
- One tree implementation, two presentations: the shared TreePanel (global
name search + refresh + FileTree) docks as the editor tab's right panel
and — in merged mode — fills the explorer tab's window; the pref-off
explorer keeps its legacy header shell.
- The docked panel drag-resizes from its left edge (160-480px clamp),
persisted as tab.meta.treeWidth alongside treeOpen.
- The text editor's preview/edit/save toolbar moves into the path-input
header row: FileViewerProps gains internal toolbar fields ('host' mode
skips the viewer's own row, reports EditorToolbarState and registers
EditorToolbarControls); non-merged mode is byte-identical to before.
* feat(editor): single files window with in-place switching; explorer descriptor removed
- editor tab replaces explorer on the + menu (order 10, Files); persisted
explorer tabs migrate to the editor home tab via sanitizeState
- editorExplorer now only controls open behavior: merged = in-place
updateTab, independent = openSidebarFile; both modes share the merged
chrome (path input + hoisted preview/edit/save toolbar + tree toggle)
- file tree context menu gains 'open in new tab' / 'open to the side'
(mintTabId avoids the id safety net; insertLeafAt splits right)
- tree dock resize uses pointer capture (touch-action none, 6px handle)
- defaults: openByDefault off, sidebar width 35%
- file tree clips horizontal overflow (no sideways scroll)
- e2e: mount lane arms the editor-chunk wait before goto and clicks the
tree row's left edge (hover @-reference button sits mid-row); drag lane
expands the panel first (collapsed push writes 0px), waits for the
slide-in to settle, and picks the leftmost col-resize strip; 'Files'
title assertions require draggable to skip the bottom panel's welcome
cards
* feat(editor): split mode turns the path-less window into the standalone explorer
In independent (split) mode a path-less files window renders ONLY the
tree panel (search + FileTree, full-window form) — no path input, no
hoisted toolbar, no tree toggle. File clicks open per-path editor tabs
(full chrome, tree docked closed by default) through openSidebarFile,
unchanged. Merged mode is untouched (chrome + docked tree, in-place
switching). TreePanel's previously reserved 'full' prop now has its
consumer.
152 lines
5.7 KiB
TypeScript
152 lines
5.7 KiB
TypeScript
/**
|
|
* fs-search: the host's recursive file-name search behind the editor side
|
|
* panel's search box. Matches are case-insensitive name substrings, reported
|
|
* RELATIVE to the root ('/'-separated); `.git` directories are skipped,
|
|
* symlinked directories are never descended (cycle safety), and the
|
|
* maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { searchFiles } from '../src/fs-search.ts'
|
|
|
|
/**
|
|
* Symlink creation needs extra privileges on Windows; the symlink case skips
|
|
* there rather than fails (mirror of the fs-tree symlink spec).
|
|
*/
|
|
const canSymlink = (() => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-probe-'))
|
|
try {
|
|
symlinkSync('target', join(dir, 'link'))
|
|
return true
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})()
|
|
|
|
/** A scratch tree: nested matches, a .git dir, and unrelated noise. */
|
|
function makeFixture(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-'))
|
|
mkdirSync(join(dir, 'src'))
|
|
mkdirSync(join(dir, 'docs'))
|
|
mkdirSync(join(dir, '.git'))
|
|
mkdirSync(join(dir, '.git', 'objects'))
|
|
writeFileSync(join(dir, 'README.md'), 'readme')
|
|
writeFileSync(join(dir, 'src', 'Index.TS'), 'code')
|
|
writeFileSync(join(dir, 'src', 'util.ts'), 'code')
|
|
writeFileSync(join(dir, 'docs', 'guide.md'), 'doc')
|
|
writeFileSync(join(dir, '.git', 'config'), 'git-internal')
|
|
writeFileSync(join(dir, '.git', 'objects', 'readme-pack'), 'git-internal')
|
|
return dir
|
|
}
|
|
|
|
describe('fs-search', () => {
|
|
it('matches name substrings and reports root-relative /-separated paths', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
const result = await searchFiles(dir, 'util')
|
|
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
|
|
// A multi-level match list is sorted and relative (never absolute).
|
|
const md = await searchFiles(dir, '.md')
|
|
expect(md.truncated).toBe(false)
|
|
expect(md.matches).toEqual(['README.md', 'docs/guide.md'])
|
|
for (const match of md.matches) {
|
|
expect(match.startsWith(dir)).toBe(false)
|
|
expect(match).not.toContain('\\')
|
|
}
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('matches case-insensitively on the entry name', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
expect((await searchFiles(dir, 'index.ts')).matches).toEqual(['src/Index.TS'])
|
|
expect((await searchFiles(dir, 'INDEX.TS')).matches).toEqual(['src/Index.TS'])
|
|
// Directory names match too (the client can hint where matches live).
|
|
expect((await searchFiles(dir, 'SRC')).matches).toEqual(['src'])
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('never descends into .git directories', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
// 'readme' would hit .git/objects/readme-pack if the walk entered .git.
|
|
expect((await searchFiles(dir, 'readme')).matches).toEqual(['README.md'])
|
|
expect((await searchFiles(dir, 'config')).matches).toEqual([])
|
|
expect((await searchFiles(dir, '.git')).matches).toEqual([])
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('an empty (or whitespace) query matches nothing without walking', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
expect(await searchFiles(dir, '')).toEqual({ matches: [], truncated: false })
|
|
expect(await searchFiles(dir, ' ')).toEqual({ matches: [], truncated: false })
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it.skipIf(!canSymlink)('does not descend into symlinked directories (cycle safety)', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
// A link back to the root would loop forever if descended; a link to
|
|
// src would duplicate its matches. Neither must be entered.
|
|
symlinkSync(dir, join(dir, 'loop'))
|
|
symlinkSync(join(dir, 'src'), join(dir, 'src-link'))
|
|
const result = await searchFiles(dir, 'util')
|
|
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('stops with truncated: true when the match budget is exceeded', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-cap-'))
|
|
try {
|
|
for (let index = 0; index < 5; index += 1) {
|
|
writeFileSync(join(dir, `match-${index}.txt`), 'x')
|
|
}
|
|
const result = await searchFiles(dir, 'match', { maxMatches: 2 })
|
|
expect(result.truncated).toBe(true)
|
|
expect(result.matches.length).toBe(2)
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('stops with truncated: true when the visited budget is exceeded', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-visited-'))
|
|
try {
|
|
for (let index = 0; index < 5; index += 1) {
|
|
writeFileSync(join(dir, `file-${index}.txt`), 'x')
|
|
}
|
|
// The walk visits more entries than the budget allows and gives up.
|
|
const result = await searchFiles(dir, 'nomatch', { maxVisited: 3 })
|
|
expect(result.truncated).toBe(true)
|
|
expect(result.matches).toEqual([])
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('an unreadable root yields no matches instead of throwing', async () => {
|
|
const dir = makeFixture()
|
|
try {
|
|
const missing = join(dir, 'does-not-exist')
|
|
expect(await searchFiles(missing, 'x')).toEqual({ matches: [], truncated: false })
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|