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
+23 -2
View File
@@ -29,11 +29,13 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import { SiCursor, SiZedindustries } from 'react-icons/si'
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import { api, downloadUrl, type FsEntry } from './api.ts'
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
import type { OpenWithTarget } from './open-with.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
import type { SidebarStore } from './state.ts'
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
import css from './sidebar.module.css'
@@ -105,6 +107,8 @@ const ChatDropIllustration = () => (
export function FileTree(props: {
sessionId: string
cwd: string | undefined
/** The sidebar store: the fence-refusal notice writes the `workspaceFence` pref through it. */
store: SidebarStore
expanded: string[]
/** Files highlighted by a "Show in folder" reveal (absolute paths). */
revealed: string[]
@@ -136,7 +140,7 @@ export function FileTree(props: {
/** True while an upload is in flight (drops are ignored). */
busy: boolean
}) {
const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data)
/** The row whose path was just copied ("copied" label replaces its button). */
@@ -257,6 +261,13 @@ export function FileTree(props: {
})
}, [sessionId, cwd, storeLevel])
/** Drop one level from the cache and reload it (the fence notice's retry). */
const retryDir = useCallback((dir: string) => {
delete dataRef.current[dir]
setData({ ...dataRef.current })
loadDir(dir)
}, [loadDir])
// The caller's refresh tick wipes the cache (declared BEFORE the load
// effect so the reload below sees the empty cache).
const lastTick = useRef(refreshTick)
@@ -436,6 +447,16 @@ export function FileTree(props: {
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
}
if (level.error !== undefined) {
// The fence refusal becomes the friendly notice (reason + one-click
// global off + immediate retry of this directory), never the raw
// `path "..." is outside workspace` wire text.
if (isOutsideWorkspaceMessage(level.error)) {
return (
<div style={{ paddingLeft: depth * 22 + 6 }}>
<FenceErrorNotice store={store} onDisabled={() => { retryDir(dir) }} />
</div>
)
}
return (
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
{level.error}