feat: workspace fence toggle (workspaceFence pref + one-click off in error surfaces)

- New persisted built-in pref workspaceFence (default true) under the
  files card's secondary settings modal; host reads it per request via
  the settings seam (fenceEnabledOf — absent/malformed stays ON).
- ensureWorkspacePath/ensureWorkspaceWritePath gain a fence param:
  disarmed skips the containment assert but keeps realpath resolution;
  wired through fs.tree/fs.read/fs.write, the media + html routes, and
  the upload path (writeWorkspaceUpload fence input).
- Editor load failures and file-tree level errors show the localized
  reason plus a one-click global off (FenceErrorNotice) that flips the
  pref, adopts it into the store, and auto-retries the failed operation
  (reloadSeq / retryDir) — the raw wire text is never shown.
- GitView's two client-side pre-checks (hidden open action + no-op
  defense) respect the pref; store threaded through TreePanel/FileTree.
- i18n: 4 new keys across all 21 dictionaries; tests: fence-off route
  coverage, prefs parsing, schema default, editor toggles list;
  docs: guide §8/§8.1 + plans/2026-08-29 design record.
This commit is contained in:
Menghuan1918
2026-08-29 15:49:19 +08:00
parent 2088ef6891
commit a9353ce627
44 changed files with 391 additions and 38 deletions
+4 -1
View File
@@ -86,7 +86,7 @@ describe('built-in tab registrations', () => {
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.map(t => t.key)).toEqual(['editorExplorer', 'workspaceFence'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
// The merged mode is an iconed select (merged vs separate), not a switch.
@@ -94,6 +94,9 @@ describe('built-in tab registrations', () => {
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 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()
+2
View File
@@ -14,6 +14,7 @@ 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 { TAB_DRAG_TYPE } from '../src/client/TabBar.tsx'
import type { UploadItem } from '../src/client/upload.ts'
@@ -55,6 +56,7 @@ async function mountTree(busy = false): Promise<Harness> {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
+2
View File
@@ -11,6 +11,7 @@ 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).
@@ -64,6 +65,7 @@ async function mountTree(overrides: {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed: [],
onToggle: () => {},
+2
View File
@@ -14,6 +14,7 @@ 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'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
@@ -54,6 +55,7 @@ async function mountTree(bodyScroll: { top: number; client: number; height: numb
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed,
onToggle: () => {},
+3
View File
@@ -10,6 +10,7 @@ import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { GitView } from '../src/client/GitView.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { api, type GitLogEntry, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
@@ -72,6 +73,7 @@ describe('GitView linked-worktree consistency', () => {
await act(async () => {
root.render(createElement(GitView, {
scope: { sessionId: 'session', cwd: MAIN },
store: createSidebarStore(),
onOpenFile: () => {},
onOpenDiff: () => {},
visible: false,
@@ -129,6 +131,7 @@ describe('GitView linked-worktree consistency', () => {
await act(async () => {
root.render(createElement(GitView, {
scope: { sessionId: 'session', cwd: MAIN },
store: createSidebarStore(),
onOpenFile: () => {},
onOpenDiff: () => {},
visible: false,
+4 -1
View File
@@ -88,12 +88,15 @@ describe('dsh-better-sidebar plugin export shape', () => {
// The separate file-window mode is the default (each file opens its own
// tab; the merged editor-explorer is opt-in).
expect(resolved.editorExplorer).toBe(false)
// The workspace fence (containment over the sidebar fs routes) defaults
// ON — the safe default never depends on the stored document.
expect(resolved.workspaceFence).toBe(true)
// A stored overridden value resolves through (the range contract is
// enforced by the settings service on write); the new pref keeps its
// default when the stored document predates it.
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
})
})
+17 -5
View File
@@ -43,6 +43,7 @@ describe('side card preferences', () => {
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: false,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
titleBarScheme: 'auto',
@@ -76,6 +77,7 @@ describe('side card preferences', () => {
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: false,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
titleBarScheme: 'auto',
@@ -109,6 +111,7 @@ describe('side card preferences', () => {
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: false,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
titleBarScheme: 'auto',
@@ -170,6 +173,15 @@ describe('side card preferences', () => {
expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true)
})
it('defaults workspaceFence to true; only an explicit false disarms the containment guard', async () => {
// Absent or malformed → on (the fs routes keep refusing outside paths).
expect((await loadPrefs(wire({}))).workspaceFence).toBe(true)
expect((await loadPrefs(wire({ workspaceFence: 'no' }))).workspaceFence).toBe(true)
expect((await loadPrefs(wire({ workspaceFence: 0 }))).workspaceFence).toBe(true)
// An explicit false survives (the one-click off in the fence error notice).
expect((await loadPrefs(wire({ workspaceFence: false }))).workspaceFence).toBe(false)
})
it('defaults the title-bar scheme to the conservative auto with no preset or custom CSS', async () => {
// Absent or malformed → auto (plain web keeps the untouched layout).
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
@@ -270,9 +282,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -308,7 +320,7 @@ describe('side card preferences', () => {
it('skips the default seed tab when the editor (files window) type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
store.setSession('no-editor')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -318,7 +330,7 @@ describe('side card preferences', () => {
// editorExplorer modes.
for (const editorExplorer of [true, false]) {
const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
openStore.setSession(`with-editor-${editorExplorer}`)
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
@@ -328,7 +340,7 @@ describe('side card preferences', () => {
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
for (const editorExplorer of [true, false]) {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setSession(`fresh-${editorExplorer}`)
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(tabs).toHaveLength(1)
+29
View File
@@ -943,6 +943,7 @@ describe('side card settings routes', () => {
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: false,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
titleBarCompat: false,
@@ -972,6 +973,34 @@ describe('side card settings routes', () => {
expect(view.revision).toBe(1)
})
it('disarms the workspace fence for the fs routes when the pref is off', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fence-off-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(workspace)
mkdirSync(outside)
writeFileSync(join(outside, 'secret.txt'), 'global instructions')
try {
const route = mountWithSettings(createFakeSettings())
// Default (fence on): the outside read is refused as usual…
const refused = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })
expect(refused).toMatchObject({ ok: false, error: { code: 'forbidden' } })
// …then the settings-page switch (or the fence notice's one-click off)
// disarms every fs route for paths outside the workspace.
const off = await invoke(route, 'settings.update', { patch: { workspaceFence: false } })
expect(off.ok).toBe(true)
const read = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })
expect(read).toMatchObject({ ok: true, value: { kind: 'text', content: 'global instructions' } })
const tree = await invoke(route, 'fs.tree', { sessionId: 'fence', cwd: workspace, path: outside })
expect(tree).toMatchObject({ ok: true })
const write = await invoke(route, 'fs.write', { sessionId: 'fence', cwd: workspace, path: join(outside, 'written.txt'), content: 'ok' })
expect(write).toMatchObject({ ok: true })
expect(readFileSync(join(outside, 'written.txt'), 'utf8')).toBe('ok')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('refuses a stale write with settings-conflict (409)', async () => {
const route = mountWithSettings(createFakeSettings())
await invoke(route, 'settings.update', { patch: { openByDefault: false } })