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:
@@ -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()
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
Reference in New Issue
Block a user