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
+5 -3
View File
@@ -32,6 +32,8 @@ export interface WorkspaceUploadInput {
chunks: AsyncIterable<string | Uint8Array>
/** Byte cap; an oversized upload is refused without touching the target. */
limit: number
/** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */
fence?: boolean
}
/**
@@ -46,9 +48,9 @@ export interface WorkspaceUploadInput {
* failures; the temp file is always removed on failure.
*/
export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise<{ path: string; size: number }> {
const { cwd, dir, relativePath, chunks, limit } = input
const { cwd, dir, relativePath, chunks, limit, fence = true } = input
const base = requireAbsolute(dir)
await ensureWorkspacePath(cwd, base)
await ensureWorkspacePath(cwd, base, fence)
if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) {
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
}
@@ -57,7 +59,7 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
}
const target = join(base, ...segments)
const safeTarget = await ensureWorkspaceWritePath(cwd, target)
const safeTarget = await ensureWorkspaceWritePath(cwd, target, fence)
const tmp = join(dirname(safeTarget), `.${basename(safeTarget)}.dsh-upload-${randomUUID()}.tmp`)
await mkdir(dirname(safeTarget), { recursive: true })
const stream = createWriteStream(tmp, { flags: 'wx' })