perf(sidebar): fs.tree 22.8ms→3.8ms + 删除工作区路径检测(放开权限)+ 树展开状态按会话

用户实测反馈:

1) `/sidebar/api/fs.tree` 性能极差 —— 先测后改(10k 条目目录、热缓存、中位数):
   - 冷调用 cap=1000:22.76ms → **3.77ms**;TTL 内重复:≈23ms → **0.000ms**
   - 改法:opendir for-await → readdir(withFileTypes) 一次取回;只为 cap 条建行
     (slice + 定长数组);路径用字符串拼接而非 path.join;排序弃 localeCompare
     (14.6ms/10k → 0.35ms)改小写比较 + 原名码点兜底;新增 1.5s 目录级缓存
     (键 绝对路径\0cap,上限 512),由 fs.write/rename/remove/mkdir/upload 与
     fs-watch 的变更通知显式失效
   - 契约逐字不变;新增 tests/fs-tree.spec.ts 6 例 + fs-watch 失效用例(判别性验证)

2) 删除工作区路径检测(默认不检测、放开权限):
   - path-security 变纯词法解析(realpath/包含性判定删除)、fs-operations 去 fence
     分支、所有 fs 路由不再读 fence、403 分支消失、config 删 workspaceFence 字段
     (schemastery 容忍未知键,旧 profile 不会加载失败)、settings 开关删除
   - ⚠️ 安全影响:插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/
     upload/archive)现在可读写宿主用户可访问的任意路径,只受 OS 权限约束;
     指南 §8.1 已加醒目声明
   - 死代码清理:FenceErrorNotice 组件 + EditorHost 分支 + 4 个词条(settingsFence*、
     fenceErrorReason、fenceDisableAction)+ isOutsideWorkspace* 助手 + TreePanel 的
     store prop + 相关 CSS 块

3) 批量列举路由 `fs.trees`(单层失败只在该层带 error)+ 客户端批量加载:
   挂载/刷新时的 N+1 请求变为 1 个(8 个展开目录:9 → 1,探针断言)
   refreshTick 不再先清缓存(旧行保留到新答案;失败保留旧行 + warn 提示)

4) 修复「关闭文件后返回文件树,展开状态被重置」:
   原生承载面的展开集合原来存在每个 tab 的记录里(drop 即丢),改为**每会话状态**
   的投影(ensure 从会话态读、toggleExpanded 写回会话态、registerNativeSurface 用
   attachStore 订阅);同一会话多 tab 共享、不同会话隔离;revealed 保持 per-tab

验证:typecheck / lint / test(136 文件 / 1484 通过 / 9 跳过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
版本镜像同步(dsh.plugin.json + SIDEBAR_SERVICE_VERSION)
This commit is contained in:
Menghuan1918
2026-09-28 20:37:56 +08:00
parent f29743f03f
commit 5c95002641
68 changed files with 1313 additions and 747 deletions
+2 -7
View File
@@ -27,9 +27,8 @@ import { createElement } from 'react'
import clsx from 'clsx'
import { IconCheckOutlineRegular, IconFolderOpenRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { api, isOutsideWorkspaceMessage, mediaUrl, type SessionScope } from './api.ts'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { baseName } from './FileTree.tsx'
import { createFrameBatcher } from './frame-batcher.ts'
@@ -417,7 +416,6 @@ export function EditorHost(props: {
<TreePanel
full
visible={visible}
store={store}
sessionId={scope.sessionId}
cwd={folderRoot ?? scope.cwd}
expanded={expanded}
@@ -514,9 +512,7 @@ export function EditorHost(props: {
<div className={css.editorMain}>
{showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
{!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{!showEmpty && load.status === 'error' && (isOutsideWorkspaceMessage(load.message)
? <FenceErrorNotice store={store} onDisabled={() => { setReloadSeq(sequence => sequence + 1) }} />
: <div className={css.editorError}>{load.message}</div>)}
{!showEmpty && load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
ctx, store, scope, path, title,
@@ -545,7 +541,6 @@ export function EditorHost(props: {
/>
<TreePanel
visible={visible}
store={store}
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded}
-40
View File
@@ -1,40 +0,0 @@
/**
* The workspace-fence refusal surface. The raw wire text (`path "..." is
* outside workspace`) is never shown as-is: the editor / file-tree error
* slots render the localized reason plus a one-click global off — the click
* flips the `workspaceFence` pref through the settings route, adopts the
* returned document into the store (so every prefs reader — the changes tab's open
* guard, the settings page — flips with it), and calls `onDisabled` so the
* caller retries the failed operation immediately.
*/
import { useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { t } from './locales.ts'
import { parsePrefs } from './prefs.ts'
import css from './sidebar.module.css'
import type { SidebarStore } from './state.ts'
export function FenceErrorNotice(props: { store: SidebarStore; onDisabled: () => void }) {
const { store, onDisabled } = props
const [busy, setBusy] = useState(false)
const disable = (): void => {
if (busy) return
setBusy(true)
api.settingsUpdate({ workspaceFence: false }).then((view) => {
store.setPrefs(parsePrefs(view.value))
onDisabled()
}).catch((error: unknown) => {
// The fence stays armed on failure (the notice remains, button
// re-enabled); the console carries the cause.
console.error('workspace fence disable failed', error)
setBusy(false)
})
}
return (
<div className={css.fenceError}>
<span>{t('fenceErrorReason')}</span>
<Button variant="outline" disabled={busy} onClick={disable}>{t('fenceDisableAction')}</Button>
</div>
)
}
+88 -65
View File
@@ -49,11 +49,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import { SiCursor, SiZedindustries } from 'react-icons/si'
import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import {
api, archiveBuild, archiveDownloadUrl, archiveStatus, downloadUrl, isOutsideWorkspaceMessage,
type FsEntry,
} from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { api, archiveBuild, archiveDownloadUrl, archiveStatus, downloadUrl, type FsEntry } from './api.ts'
import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx'
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
import { isImeComposition } from './ime-guard.ts'
@@ -63,7 +59,6 @@ import type { OpenWithTarget } from './open-with.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
import type { BetterSidebarService } from './service.ts'
import type { SidebarStore } from './state.ts'
import {
Chip, ConfirmDialog, IconButton, Notice, StatusBadge, useGitStatus,
type GitTone, type StatusTone,
@@ -406,8 +401,6 @@ const RootRow = memo(function RootRow(props: RootRowProps): ReactNode {
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[]
@@ -481,7 +474,7 @@ export function FileTree(props: {
// callbacks must not change identity when the caller re-renders).
// `onReferenceFile` is read through `propsRef` like the other row callbacks.
const {
sessionId, cwd, store, expanded, revealed, onOpenFileNewTab, onOpenFileSide,
sessionId, cwd, expanded, revealed, onOpenFileNewTab, onOpenFileSide,
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
openWithShowPluginTargets,
onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service,
@@ -506,8 +499,6 @@ export function FileTree(props: {
const dirRowIcon = (path: string, open: boolean): ReactNode =>
service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14)
const dataRef = useRef(data)
/** Every in-flight level fetch, keyed by directory (aborted on refresh/unmount). */
const controllersRef = useRef(new Map<string, AbortController>())
/** Bumped whenever the cache is wiped: a response from an older generation is dropped. */
const generationRef = useRef(0)
/** The row whose path was just copied ("copied" label replaces its button). */
@@ -531,6 +522,8 @@ export function FileTree(props: {
const [deletingSelected, setDeletingSelected] = useState(false)
/** The last mutation failure (dismissable strip above the tree). */
const [actionError, setActionError] = useState<string | null>(null)
/** A failed re-list that kept the previous rows on screen (hint line). */
const [loadError, setLoadError] = useState<string | null>(null)
/** The multi-selection (absolute paths) and its Shift anchor. */
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const anchorRef = useRef<string | null>(null)
@@ -640,34 +633,72 @@ export function FileTree(props: {
}, [])
/**
* List one directory into the cache. Levels already loaded (including the
* empty placeholder of an in-flight fetch) are left alone; a stale response
* — the cache was wiped by a refresh tick, or a newer fetch for the same
* directory started — is dropped instead of overwriting fresher data.
* List a SET of directories into the cache with ONE `fs.trees` request.
*
* The visible set (the workspace root plus every expanded directory) is
* always loaded this way, so mounting the tree, a refresh tick and expanding
* a directory each cost exactly one POST — not one per level (the N+1 the
* file list was reported for). Levels already loaded (including the empty
* placeholder of an in-flight fetch) are left alone, so an expand only asks
* for what is genuinely missing; a stale response — the cache was wiped by a
* refresh tick — is dropped instead of overwriting fresher data.
*
* A level the host failed to read comes back with its own `error` and is
* stored on that level alone (the other levels render normally). When the
* WHOLE request fails, the levels that already had a listing keep it (plus a
* hint) and only the levels with nothing to show carry the message.
*
* `force` re-lists levels even when they are cached — the refresh tick's
* path — while leaving their current rows untouched until the answer lands.
*/
const loadDir = useCallback((dir: string) => {
if (dataRef.current[dir] !== undefined) return
const loadLevels = useCallback((paths: readonly string[], options?: { force?: boolean }) => {
const force = options?.force === true
const wanted = force
? [...new Set(paths)]
: paths.filter(path => dataRef.current[path] === undefined)
if (wanted.length === 0) return
const generation = generationRef.current
controllersRef.current.get(dir)?.abort()
const controller = new AbortController()
controllersRef.current.set(dir, controller)
storeLevel(dir, {})
api.fsTree({ sessionId, cwd }, dir, controller.signal).then((listing) => {
if (controller.signal.aborted || generation !== generationRef.current) return
storeLevel(dir, { entries: listing.entries, truncated: listing.truncated })
// A FORCED re-list (a refresh tick) keeps the listing on screen until the
// fresh one arrives: no blank frame, and a failed refresh degrades to the
// previous listing plus a hint instead of an empty tree.
if (!force) for (const path of wanted) storeLevel(path, {})
// The route takes no signal: the GENERATION counter is the staleness guard
// (a refresh tick or an unmount bumps it, so a late answer is dropped).
api.fsTrees({ sessionId, cwd }, wanted).then((result) => {
if (generation !== generationRef.current) return
setLoadError(null)
for (const level of result.levels) {
storeLevel(level.path, {
entries: level.entries,
truncated: level.truncated,
...(level.error !== undefined ? { error: level.error } : {}),
})
}
}).catch((error: unknown) => {
if (controller.signal.aborted || generation !== generationRef.current) return
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
if (generation !== generationRef.current) return
const message = error instanceof Error ? error.message : String(error)
let keptListing = false
for (const path of wanted) {
const level = dataRef.current[path]
if (level?.entries !== undefined) {
keptListing = true
continue
}
storeLevel(path, { error: message })
}
// Levels that had a listing keep it; the hint explains why they are not
// fresher. With nothing to keep, the per-level rows already say it.
setLoadError(keptListing ? message : null)
})
}, [sessionId, cwd, storeLevel])
/** Drop one level from the cache and reload it (the fence notice's retry,
* a watch notice, or a landed mutation). */
/** Drop one level from the cache and reload it (a watch notice, or the
* parent of a landed mutation). */
const retryDir = useCallback((dir: string) => {
delete dataRef.current[dir]
setData({ ...dataRef.current })
loadDir(dir)
}, [loadDir])
loadLevels([dir])
}, [loadLevels])
/**
* Settle the tree after one rename/delete landed at `prefix`: drop every
@@ -712,38 +743,34 @@ export function FileTree(props: {
})
}
// The caller's refresh tick wipes the cache (declared BEFORE the load
// effect so the reload below sees the empty cache) and invalidates every
// in-flight response, so a slow answer for the old generation can never
// land on top of the fresh listing.
// The caller's refresh tick invalidates every in-flight response (a slow
// answer for the old generation can never land on top of the fresh listing)
// and flags the next load as FORCED: the visible levels are re-listed in one
// batch, but their current rows stay on screen until the answer arrives.
// Declared BEFORE the load effect so that effect sees the flag.
const lastTick = useRef(refreshTick)
const forceNextLoad = useRef(false)
useEffect(() => {
if (lastTick.current === refreshTick) return
lastTick.current = refreshTick
generationRef.current += 1
for (const controller of controllersRef.current.values()) controller.abort()
controllersRef.current.clear()
dataRef.current = {}
setData({})
forceNextLoad.current = true
}, [refreshTick])
useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are
// not refetched. Only the refresh tick wipes the cache. Every level
// starts its own fetch, so a tick reloads them all concurrently.
// Load the visible set in ONE batch request; already-loaded levels (kept
// in the cache) are not refetched, so this asks only for what is new — a
// refresh tick re-lists root + every expanded directory in one POST.
const root = cwd
if (root === undefined) return
loadDir(root)
for (const dir of expanded) loadDir(dir)
}, [cwd, expanded, refreshTick, loadDir])
const force = forceNextLoad.current
forceNextLoad.current = false
loadLevels([root, ...expanded], { force })
}, [cwd, expanded, refreshTick, loadLevels])
// A torn-down tree must not leave requests running against an unmounted
// component (their stores would be dropped anyway — this is lifecycle, not
// correctness, but it keeps the network honest).
useEffect(() => () => {
for (const controller of controllersRef.current.values()) controller.abort()
controllersRef.current.clear()
}, [])
// A torn-down tree must not write the answer of an in-flight batch into a
// dead component: bumping the generation makes every late response stale.
useEffect(() => () => { generationRef.current += 1 }, [])
// Live refresh: the host watches the folders this tree has expanded and
// names the one that changed, so exactly that level is dropped and
@@ -1502,23 +1529,14 @@ export function FileTree(props: {
)
}
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.
// A level the host could not read: the raw message on the level that
// failed, never on its siblings (the batch reports errors per level).
return (
<>
{head}
{isOutsideWorkspaceMessage(level.error)
? (
<div style={{ paddingLeft: depth * 22 + 6 }}>
<FenceErrorNotice store={store} onDisabled={() => { retryDir(dir) }} />
</div>
)
: (
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
{level.error}
</div>
)}
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
{level.error}
</div>
</>
)
}
@@ -1617,6 +1635,11 @@ export function FileTree(props: {
strip (the two CAN coexist — a stale error stays readable while a
new attempt is in flight). `role="status"` makes it a polite live
region, so progress is announced without stealing focus. */}
{/* A refresh whose batch request failed: the rows above are the
PREVIOUS listing, so the hint explains why nothing changed. */}
{loadError !== null && (
<Notice kind="warn" tone="inline">{loadError}</Notice>
)}
{archiveBusy && (
<Notice kind="loading" tone="inline" role="status">
{archiveProgress === null
+1 -5
View File
@@ -23,7 +23,6 @@ import clsx from 'clsx'
import { IconFolderOpenRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import type { BetterSidebarService } from './service.ts'
import type { SidebarStore } from './state.ts'
import { FileTree } from './FileTree.tsx'
import { IconUploadOutline16 } from './icons.tsx'
import type { OpenInApp } from './open-in-app.ts'
@@ -51,8 +50,6 @@ interface UploadSession {
export function TreePanel(props: {
sessionId: string
cwd: string | undefined
/** The sidebar store (passed through to the tree's fence-refusal notice). */
store: SidebarStore
expanded: string[]
revealed: string[]
onToggle: (path: string) => void
@@ -90,7 +87,7 @@ export function TreePanel(props: {
service?: BetterSidebarService
}) {
const {
sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide,
sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide,
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
openWithShowPluginTargets,
onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service,
@@ -287,7 +284,6 @@ export function TreePanel(props: {
<FileTree
sessionId={sessionId}
cwd={cwd}
store={store}
expanded={expanded}
revealed={revealed}
onToggle={onToggle}
+16 -16
View File
@@ -27,22 +27,6 @@ export class SidebarApiError extends Error {
}
}
/**
* Whether a wire failure is the workspace fence refusing a path outside the
* session workspace (the host message reads `path "..." is outside
* workspace`). The request-trust fence answers code `forbidden` with the
* bare message 'forbidden', so the message fragment — not the code alone —
* identifies this case.
*/
export function isOutsideWorkspaceError(error: unknown): boolean {
return error instanceof SidebarApiError && isOutsideWorkspaceMessage(error.message)
}
/** Message-level variant for surfaces that stored the raw text (file-tree level errors). */
export function isOutsideWorkspaceMessage(message: string): boolean {
return message.includes('outside workspace')
}
/** Explorer row (host fs-tree shape). */
export interface FsEntry {
name: string
@@ -55,6 +39,15 @@ export interface FsEntry {
broken: boolean
}
/** One level of a `fs.trees` batch: a listing, or that level's failure. */
export interface FsLevel {
path: string
entries: FsEntry[]
truncated: boolean
/** Present only when THIS level failed (the batch itself succeeded). */
error?: string
}
/** Git status entry (host git shape). */
export interface GitStatusEntry {
path: string
@@ -290,6 +283,13 @@ export const api = {
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
/**
* Batch listing: every requested level in ONE request (the tree's mount and
* refresh send the expanded set instead of N `fsTree` calls). A level that
* failed carries `error` in place — the batch itself still succeeds.
*/
fsTrees: (scope: SessionScope, paths: readonly string[], signal?: AbortSignal) =>
call<{ levels: FsLevel[] }>('fs.trees', scopePayload(scope, { paths: [...paths] }), signal),
/** Global recursive file-name search rooted at the session cwd (the editor
* side panel's search box); matches are cwd-relative '/'-separated paths. */
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
+4 -9
View File
@@ -43,11 +43,10 @@ export function builtinTabs(): readonly TabDescriptor[] {
dedupeKey: (tab) => tab.path,
// Declarative settings: the file-open behavior picker (in-place switch
// vs per-path windows) renders as an iconed select row under the
// editor card's gear in the Side card settings page, followed by the
// workspace fence switch (the host's containment guard over every
// sidebar filesystem route); the "open with" configuration (SSH host +
// custom editors) is the custom panel BELOW those rows — the settings
// seam renders rows first, custom panel after.
// editor card's gear in the Side card settings page; the "open with"
// configuration (SSH host + custom editors) is the custom panel BELOW
// those rows — the settings seam renders rows first, custom panel after.
// The workspace-fence switch is GONE: there is no containment to toggle.
settings: {
toggles: [{
key: 'editorExplorer',
@@ -68,10 +67,6 @@ export function builtinTabs(): readonly TabDescriptor[] {
desc: () => t('editorExplorerSplitDesc'),
},
],
}, {
key: 'workspaceFence',
title: () => t('settingsFenceTitle'),
desc: () => t('settingsFenceDesc'),
}],
// Plugin-owned rows (values live in `pluginSettings['editor']`): the
// plugin's own open-with targets are shown only when the host reports
+10 -16
View File
@@ -36,7 +36,7 @@ import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, Session
import { api } from '../api.ts'
import { builtinFileIcon, builtinFolderIcon } from '../file-icons.tsx'
import { usePolling } from '../use-polling.ts'
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
import { baseName, relativeTo } from '../paths.ts'
import { resolveSidebarPath } from '../paths.ts'
import { relativeTime, t } from '../locales.ts'
import type { SidebarDiffRef, SidebarStore } from '../state.ts'
@@ -249,7 +249,8 @@ const DirRow = memo(function DirRow(props: {
export interface GitLensProps {
scope: SessionScope
/** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */
/** @deprecated The sidebar store is no longer read here (the fence guard it
* fed is gone); the prop stays for the caller's shape. */
store: SidebarStore
onOpenFile: (path: string) => void
/** Preview one change in the shared bottom pane (worktree or commit ref). */
@@ -266,7 +267,7 @@ export interface GitLensProps {
}
export function GitLens(props: GitLensProps) {
const { scope, store, onOpenFile, onPreview, selectedRef, visible, refreshTick = 0, ctx } = props
const { scope, onOpenFile, onPreview, selectedRef, visible, refreshTick = 0, ctx } = props
const service = ctx?.get('betterSidebar')
/** The file row's artwork: a registered icon wins, else the host's own
@@ -952,13 +953,10 @@ export function GitLens(props: GitLensProps) {
open={fileMenu !== null}
onClose={() => { setFileMenu(null) }}
items={[
// A linked worktree outside the session workspace cannot be
// opened in the editor while the host's workspace fence is
// armed: it rejects every path under that checkout. Hide the
// action for that checkout so the menu does not offer a no-op
// that confuses the user; with the fence disarmed (the
// `workspaceFence` pref) the open is allowed through.
...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.path)))
// The open action is always offered: the host's workspace fence
// is gone, so a path under any checkout (linked worktree
// included) opens like any other.
...(fileMenu !== null
? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutlineRegular size={14} /> }]
: []),
fileMenu?.staged === true
@@ -977,12 +975,8 @@ export function GitLens(props: GitLensProps) {
setFileMenu(null)
if (id === 'open') {
const resolved = resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.path)
// Defense-in-depth: the menu hides this action when the
// resolved path escapes the session workspace, but a
// racing repo switch could still reach here with a path
// the host would reject. No-op in that case — unless the
// workspace fence is disarmed by pref.
if (store.getPrefs().workspaceFence !== false && !isWithinWorkspace(scope.cwd ?? '', resolved)) return
// The host accepts any path now (no containment), so the open
// guard that mirrored the old fence is gone with it.
onOpenFile(resolved)
return
}
-4
View File
@@ -263,10 +263,6 @@ export const ar: Record<string, string> = {
settingsJobsDesc: 'تُفعَّل صفحة المهام كلما ظهرت مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُطلق التفعيل)؛ في الشاشات العريضة تُوسَّع البطاقة الجانبية أيضاً، أما في الشاشات الضيقة فلا يُفرض فتح الدرج بملء الشاشة؛ عند إيقاف الخيار يصبح الفتح يدوياً',
settingsToolsTitle: 'حقن أدوات الطرفية للنموذج',
settingsToolsDesc: 'عند التفعيل، يمكن للنموذج إنشاء وتشغيل طرفيات الشريط الجانبي عبر أدوات terminal_* الثمانية (معطّل افتراضياً)',
settingsFenceTitle: 'فحص مسارات مساحة العمل',
settingsFenceDesc: 'عند التفعيل، تصل وظائف الملفات في الشريط الجانبي إلى مسارات داخل مساحة عمل الجلسة فقط (الافتراضي)؛ عند الإيقاف تصبح ملفات المضيف كلها متاحة — وتحصل سكربتات الصفحة على النفاذ نفسه طوال ذلك',
fenceErrorReason: 'هذا المسار خارج مساحة عمل الجلسة وقد حجبه الفحص',
fenceDisableAction: 'إيقاف فحص مساحة العمل',
settingsBottomTerminalTitle: 'فتح طرفية تلقائياً عند أول توسيع للوحة السفلية',
settingsBottomTerminalDesc: 'عند توسيع اللوحة السفلية لأول مرة في الجلسة، حاول فتح تبويب طرفية جديد هناك (حصة الطرفيات ما زالت تسري؛ مفعّل افتراضياً)',
settingsFontFamilyTitle: 'عائلة خط الطرفية',
-4
View File
@@ -248,10 +248,6 @@ export const de: Record<string, string> = {
settingsJobsDesc: 'Die Aufgaben-Seite aktivieren, sobald für die aktuelle Unterhaltung eine neue Hintergrundaufgabe erscheint (jede neue Aufgabe löst dies aus); auf breiten Ansichten wird zusätzlich die Seitenkarte ausgeklappt, auf schmalen Ansichten wird das Öffnen der Vollbild-Schublade nicht erzwungen; ausschalten, um sie manuell zu öffnen',
settingsToolsTitle: 'Terminal-Werkzeuge für das Modell bereitstellen',
settingsToolsDesc: 'Wenn aktiviert, kann das Modell über die 8 terminal_*-Werkzeuge Terminale in der Seitenleiste erstellen und steuern (standardmäßig deaktiviert)',
settingsFenceTitle: 'Workspace-Pfadkontrolle',
settingsFenceDesc: 'Wenn aktiv, erreichen die Dateifunktionen der Seitenleiste nur Pfade innerhalb des Session-Workspaces (Standard); ausgeschaltet sind Dateien überall auf dem Host erreichbar — Skripte der Seite erhalten solange dieselbe Reichweite',
fenceErrorReason: 'Dieser Pfad liegt außerhalb des Session-Workspaces und wurde von der Workspace-Kontrolle blockiert',
fenceDisableAction: 'Workspace-Kontrolle ausschalten',
settingsBottomTerminalTitle: 'Beim ersten Ausklappen des unteren Panels automatisch ein Terminal öffnen',
settingsBottomTerminalDesc: 'Wird das untere Panel in einer Sitzung zum ersten Mal ausgeklappt, wird dort versucht, einen neuen Terminal-Tab zu öffnen (das Terminal-Kontingent gilt weiterhin; standardmäßig aktiviert)',
settingsFontFamilyTitle: 'Terminal-Schriftart',
-4
View File
@@ -255,10 +255,6 @@ export const fr: Record<string, string> = {
settingsJobsDesc: 'À chaque nouvelle tâche d’arrière-plan dans la session actuelle, activer la page Gestion des tâches (chaque nouvelle tâche déclenche l’activation) ; sur les écrans larges, déployer également la carte latérale ; sur les écrans étroits, ne pas forcer l’ouverture du tiroir plein écran ; une fois l’option désactivée, l’ouverture est manuelle',
settingsToolsTitle: 'Injecter des outils de terminal au modèle',
settingsToolsDesc: 'Une fois activé, le modèle peut créer et piloter des terminaux de la barre latérale via les 8 outils terminal_* (désactivé par défaut)',
settingsFenceTitle: 'Contrôle des chemins de l\'espace de travail',
settingsFenceDesc: 'Activé, les fonctions fichiers de la barre latérale n\'atteignent que les chemins dans l\'espace de travail de la session (par défaut) ; désactivé, les fichiers de tout l\'hôte deviennent accessibles — les scripts de la page obtiennent la même portée pendant ce temps',
fenceErrorReason: 'Ce chemin est hors de l\'espace de travail de la session et a été bloqué par le contrôle',
fenceDisableAction: 'Désactiver le contrôle de l\'espace de travail',
settingsBottomTerminalTitle: 'Ouvrir automatiquement un terminal au premier déploiement du panneau inférieur',
settingsBottomTerminalDesc: 'Lors du premier déploiement du panneau inférieur dans une session, tenter d’y ouvrir automatiquement un nouvel onglet de terminal (la limite du nombre de terminaux s’applique toujours ; activé par défaut)',
settingsFontFamilyTitle: 'Police du terminal',
-4
View File
@@ -262,10 +262,6 @@ export const hi: Record<string, string> = {
settingsJobsDesc: 'वर्तमान वार्तालाप में हर नया बैकग्राउंड कार्य आने पर कार्य पृष्ठ सक्रिय करें (हर नया कार्य ट्रिगर करता है); चौड़ी स्क्रीन पर साइड कार्ड भी विस्तृत होता है, जबकि संकीर्ण स्क्रीन पर पूर्ण-स्क्रीन दराज को जबरन नहीं खोला जाता; विकल्प बंद होने पर इसे मैन्युअल रूप से खोलना होगा',
settingsToolsTitle: 'मॉडल के लिए टर्मिनल टूल इंजेक्ट करें',
settingsToolsDesc: 'सक्षम होने पर, मॉडल 8 terminal_* टूल्स से साइडबार टर्मिनल बना और चला सकता है (डिफ़ॉल्ट रूप से बंद)',
settingsFenceTitle: 'वर्कस्पेस पाथ जाँच',
settingsFenceDesc: 'चालू होने पर साइडबार की फ़ाइल सुविधाएँ केवल सेशन वर्कस्पेस के भीतर के पाथ तक पहुँचती हैं (डिफ़ॉल्ट); बंद करने पर होस्ट की कोई भी फ़ाइल खोली जा सकती है — उस दौरान पेज के स्क्रिप्ट को भी वही पहुँच मिल जाती है',
fenceErrorReason: 'यह पाथ सेशन वर्कस्पेस के बाहर है और जाँच द्वारा अवरुद्ध किया गया',
fenceDisableAction: 'वर्कस्पेस जाँच बंद करें',
settingsBottomTerminalTitle: 'निचला पैनल पहली बार खुलने पर स्वतः टर्मिनल खोलें',
settingsBottomTerminalDesc: 'सत्र में निचला पैनल पहली बार विस्तृत होने पर वहाँ नया टर्मिनल टैब खोलने का प्रयास (टर्मिनल कोटा लागू; डिफ़ॉल्ट रूप से चालू)',
settingsFontFamilyTitle: 'टर्मिनल फ़ॉन्ट परिवार',
-4
View File
@@ -260,10 +260,6 @@ export const id: Record<string, string> = {
settingsJobsDesc: 'Aktifkan halaman Tasks setiap kali tugas latar baru muncul untuk obrolan saat ini (setiap tugas baru memicu aktivasi); pada layar lebar, kartu samping juga dibentangkan; pada layar sempit, laci layar penuh tidak dipaksa terbuka; matikan untuk membukanya secara manual',
settingsToolsTitle: 'Suntik alat terminal untuk model',
settingsToolsDesc: 'Saat diaktifkan, model dapat membuat dan mengendalikan terminal sidebar melalui 8 alat terminal_* (nonaktif secara default)',
settingsFenceTitle: 'Pemeriksaan path workspace',
settingsFenceDesc: 'Saat aktif, fitur berkas sidebar hanya menjangkau path di dalam workspace sesi (default); saat nonaktif, berkas di mana pun di host dapat diakses — skrip halaman memperoleh jangkauan yang sama selama itu',
fenceErrorReason: 'Path ini berada di luar workspace sesi dan diblokir oleh pemeriksaan',
fenceDisableAction: 'Matikan pemeriksaan workspace',
settingsBottomTerminalTitle: 'Buka terminal otomatis saat panel bawah pertama kali dibentangkan',
settingsBottomTerminalDesc: 'Saat panel bawah pertama kali dibentangkan dalam sesi, coba buka tab terminal baru di panel bawah (kuota terminal tetap berlaku; aktif secara default)',
settingsFontFamilyTitle: 'Keluarga font terminal',
-4
View File
@@ -253,10 +253,6 @@ export const it: Record<string, string> = {
settingsJobsDesc: 'Quando nella conversazione attuale compare una nuova attività in background, attiva automaticamente la pagina Attività (l’attivazione avviene per ogni nuova attività); sugli schermi ampi espande anche la barra laterale, mentre sugli schermi stretti non forza l’apertura del pannello a schermo intero; disattiva per selezionare la pagina manualmente',
settingsToolsTitle: 'Inietta strumenti del terminale per il modello',
settingsToolsDesc: 'Se attivato, il modello può creare e gestire terminali della barra laterale attraverso gli 8 strumenti terminal_* (disattivato per impostazione predefinita)',
settingsFenceTitle: 'Controllo dei percorsi del workspace',
settingsFenceDesc: 'Attivo, le funzioni file della barra laterale raggiungono solo i percorsi nel workspace di sessione (predefinito); disattivo, ogni file dell\'host è raggiungibile — gli script della pagina ottengono la stessa portata nel frattempo',
fenceErrorReason: 'Questo percorso è fuori dal workspace di sessione ed è stato bloccato dal controllo',
fenceDisableAction: 'Disattiva il controllo del workspace',
settingsBottomTerminalTitle: 'Apri automaticamente un terminale alla prima espansione del pannello inferiore',
settingsBottomTerminalDesc: 'Quando il pannello inferiore viene espanso per la prima volta in una sessione, prova ad aprire una nuova scheda terminale nel pannello inferiore (il limite di terminali si applica comunque; attivo per impostazione predefinita)',
settingsFontFamilyTitle: 'Famiglia di caratteri del terminale',
-4
View File
@@ -262,10 +262,6 @@ export const ja: Record<string, string> = {
settingsJobsDesc: '現在の会話で新しいバックグラウンドタスクが発生するたびにタスク管理ページをアクティブ化する(新規タスクごとに発火)。広い画面ではサイドカードも展開するが、狭い画面では全画面ドロワーを強制的に開かない。オフにすると手動で開く必要がある',
settingsToolsTitle: 'モデルにターミナルツールを注入',
settingsToolsDesc: 'オンにすると、モデルが terminal_create 等 8 個のツールでサイドバーターミナルを作成・操作可能(デフォルトオフ)',
settingsFenceTitle: 'ワークスペースパスガード',
settingsFenceDesc: 'オンの場合、サイドバーのファイル機能はセッションワークスペース内のパスのみアクセス可能(デフォルト)。オフにするとホスト上の任意のファイルにアクセスできるが、オフの間はページ内スクリプトも同じ権限を得る',
fenceErrorReason: 'このパスはセッションワークスペースの外にあり、ワークスペースガードによりブロックされました',
fenceDisableAction: 'ワークスペースガードをオフにする',
settingsBottomTerminalTitle: '下パネル初回展開時に自動でターミナルを開く',
settingsBottomTerminalDesc: '会話ごとに下パネルを初めて展開した際、下パネルに新しいターミナルタブを自動で開く(ターミナル数上限は依然適用。デフォルトオン)',
settingsFontFamilyTitle: 'ターミナルフォント',
-4
View File
@@ -254,10 +254,6 @@ export const ko: Record<string, string> = {
settingsJobsDesc: '현재 대화에 새 백그라운드 작업이 생길 때마다 작업 관리 페이지를 활성화합니다(새 작업마다 트리거됨). 넓은 화면에서는 사이드 카드도 펼치지만, 좁은 화면에서는 전체 화면 드로어를 강제로 열지 않습니다. 끄면 수동으로 열어야 합니다',
settingsToolsTitle: '모델에 터미널 도구 주입',
settingsToolsDesc: '켜면 모델이 terminal_create 등 8개 도구로 사이드바 터미널을 만들고 조작할 수 있습니다(기본 꺼짐)',
settingsFenceTitle: '워크스페이스 경로 검사',
settingsFenceDesc: '켜면 사이드바의 파일 기능이 세션 워크스페이스 내 경로만 접근합니다(기본값). 끄면 호스트의 모든 파일에 접근할 수 있으며, 꺼져 있는 동안 페이지 내 스크립트도 동일한 권한을 얻습니다',
fenceErrorReason: '이 경로는 세션 워크스페이스 밖에 있어 검사에 의해 차단되었습니다',
fenceDisableAction: '워크스페이스 검사 끄기',
settingsBottomTerminalTitle: '하단 패널을 처음 펼칠 때 터미널 자동 열기',
settingsBottomTerminalDesc: '세션에서 하단 패널을 처음 펼칠 때 하단 패널에 새 터미널 탭을 자동으로 열려고 시도합니다(터미널 개수 상한은 여전히 적용됩니다. 기본 켜짐)',
settingsFontFamilyTitle: '터미널 글꼴',
-4
View File
@@ -260,10 +260,6 @@ export const nl: Record<string, string> = {
settingsJobsDesc: 'Wanneer er voor de huidige conversatie een nieuwe achtergrondtaak verschijnt, wordt de Taken-pagina automatisch geactiveerd (dit gebeurt bij elke nieuwe taak); op brede schermen wordt ook de zijkaart uitgeklapt, terwijl op smalle schermen het schuifpaneel op volledig scherm niet geforceerd wordt geopend; schakel uit om de pagina handmatig te selecteren',
settingsToolsTitle: 'Terminaltools voor het model injecteren',
settingsToolsDesc: 'Indien ingeschakeld kan het model zijbalk-terminals aanmaken en besturen via de 8 terminal_*-tools (standaard uit)',
settingsFenceTitle: 'Workspace-padcontrole',
settingsFenceDesc: 'Aan: de bestandsfuncties van de zijbalk bereiken alleen paden binnen de sessie-workspace (standaard); uit: elk bestand op de host is bereikbaar — paginascripts krijgen dat bereik zolang het uit staat',
fenceErrorReason: 'Dit pad ligt buiten de sessie-workspace en is geblokkeerd door de controle',
fenceDisableAction: 'Workspace-controle uitzetten',
settingsBottomTerminalTitle: 'Automatisch een terminal openen bij de eerste uitklap van het onderpaneel',
settingsBottomTerminalDesc: 'Wanneer het onderpaneel voor het eerst in een sessie wordt uitgeklapt, wordt geprobeerd daar een nieuw terminaltabblad te openen (het terminalquotum blijft gelden; standaard aan)',
settingsFontFamilyTitle: 'Terminallettertype-familie',
-4
View File
@@ -264,10 +264,6 @@ export const pl: Record<string, string> = {
settingsJobsDesc: 'Gdy w bieżącej rozmowie pojawi się nowe zadanie w tle, automatycznie aktywuj stronę „Zadania” (akcja jest wyzwalana przez każde nowe zadanie); na szerokich ekranach rozwiń także panel boczny, a na wąskich nie wymuszaj otwarcia pełnoekranowego panelu; wyłącz, aby wybierać stronę ręcznie',
settingsToolsTitle: 'Wstrzyknij narzędzia terminala dla modelu',
settingsToolsDesc: 'Po włączeniu model może tworzyć i sterować terminalami panelu bocznego przez 8 narzędzi terminal_* (domyślnie wyłączone)',
settingsFenceTitle: 'Kontrola ścieżek obszaru roboczego',
settingsFenceDesc: 'Włączona: funkcje plików paska bocznego sięgają tylko ścieżek w obszarze roboczym sesji (domyślnie); wyłączona: dostępny jest dowolny plik hosta — skrypty strony zyskują ten sam zasięg przez ten czas',
fenceErrorReason: 'Ta ścieżka jest poza obszarem roboczym sesji i została zablokowana przez kontrolę',
fenceDisableAction: 'Wyłącz kontrolę obszaru roboczego',
settingsBottomTerminalTitle: 'Automatycznie otwieraj terminal przy pierwszym rozwinięciu panelu dolnego',
settingsBottomTerminalDesc: 'Gdy panel dolny jest rozwijany po raz pierwszy w sesji, spróbuj otworzyć nową kartę terminala tam (limit terminali nadal obowiązuje; domyślnie włączone)',
settingsFontFamilyTitle: 'Krój czcionki terminala',
-4
View File
@@ -245,10 +245,6 @@ export const pt: Record<string, string> = {
settingsJobsDesc: 'Ativar a página de Tarefas sempre que uma nova tarefa em segundo plano aparecer na conversa atual (cada nova tarefa aciona a ativação); em telas largas, expandir também o cartão lateral; em telas estreitas, não forçar a abertura da gaveta em tela cheia; desative para abri-la manualmente',
settingsToolsTitle: 'Injetar ferramentas de terminal para o modelo',
settingsToolsDesc: 'Quando ativado, o modelo pode criar e operar terminais da barra lateral por meio das 8 ferramentas terminal_* (desativado por padrão)',
settingsFenceTitle: 'Controle de caminhos do workspace',
settingsFenceDesc: 'Ativado, os recursos de arquivos da barra lateral só alcançam caminhos dentro do workspace da sessão (padrão); desativado, arquivos de todo o host ficam acessíveis — scripts da página ganham o mesmo alcance enquanto isso',
fenceErrorReason: 'Este caminho está fora do workspace da sessão e foi bloqueado pelo controle',
fenceDisableAction: 'Desativar o controle do workspace',
settingsBottomTerminalTitle: 'Abrir um terminal automaticamente na primeira expansão do painel inferior',
settingsBottomTerminalDesc: 'Quando o painel inferior for expandido pela primeira vez em uma sessão, tente abrir uma nova aba de terminal lá (a cota de terminais ainda se aplica; ativado por padrão)',
settingsFontFamilyTitle: 'Família de fonte do terminal',
-4
View File
@@ -259,10 +259,6 @@ export const ru: Record<string, string> = {
settingsJobsDesc: 'При появлении новой фоновой задачи в текущей сессии автоматически активируется страница «Задачи» (срабатывает для каждой новой задачи); на широких экранах также разворачивается боковая панель, а на узких экранах полноэкранная выдвижная панель не открывается принудительно; отключите, чтобы выбирать страницу вручную',
settingsToolsTitle: 'Внедрить терминальные инструменты для модели',
settingsToolsDesc: 'Если включено, модель может через 8 инструментов (terminal_create и др.) создавать терминалы боковой панели и управлять ими (по умолчанию выключено)',
settingsFenceTitle: 'Контроль путей рабочей области',
settingsFenceDesc: 'Включено — файловые функции боковой панели достигают только путей внутри рабочей области сессии (по умолчанию); выключено — доступны любые файлы хоста, а скрипты страницы получают тот же доступ всё это время',
fenceErrorReason: 'Этот путь вне рабочей области сессии и заблокирован контролем',
fenceDisableAction: 'Отключить контроль рабочей области',
settingsBottomTerminalTitle: 'Автооткрытие терминала при первом разворачивании нижней панели',
settingsBottomTerminalDesc: 'При первом разворачивании нижней панели в сессии попытаться автоматически открыть новую вкладку терминала в ней (лимит терминалов всё равно применяется; по умолчанию включено)',
settingsFontFamilyTitle: 'Шрифт терминала',
-4
View File
@@ -245,10 +245,6 @@ export const sv: Record<string, string> = {
settingsJobsDesc: 'När ett nytt bakgrundsjobb dyker upp för den aktuella konversationen aktiveras sidan Uppgifter automatiskt (detta sker för varje nytt jobb); på breda skärmar expanderas även sidokortet, medan helskärmspanelen inte tvingas öppnas på smala skärmar; stäng av för att välja sidan manuellt',
settingsToolsTitle: 'Injicera terminalverktyg för modellen',
settingsToolsDesc: 'När aktiverat kan modellen skapa och styra sidopanelsterminaler via de 8 terminal_*-verktygen (av som standard)',
settingsFenceTitle: 'Kontroll av workspace-sökvägar',
settingsFenceDesc: 'På: sidopanelens filfunktioner når bara sökvägar i sessionens workspace (standard); av: vilken fil som helst på värden kan nås — sidans skript får samma räckvidd så länge',
fenceErrorReason: 'Denna sökväg ligger utanför sessionens workspace och blockerades av kontrollen',
fenceDisableAction: 'Stäng av workspace-kontrollen',
settingsBottomTerminalTitle: 'Öppna automatiskt en terminal vid bottenpanelens första expandering',
settingsBottomTerminalDesc: 'När bottenpanelen expanderas för första gången i en session, försök öppna en ny terminalflik där (terminalgränsen gäller fortfarande; på som standard)',
settingsFontFamilyTitle: 'Terminalens teckensnittsfamilj',
-4
View File
@@ -262,10 +262,6 @@ export const th: Record<string, string> = {
settingsJobsDesc: 'เมื่อมีงานเบื้องหลังใหม่ปรากฏในแชทปัจจุบัน ระบบจะเลือกหน้า Tasks โดยอัตโนมัติ (ทุกงานใหม่จะทริกเกอร์); ในมุมมองแบบกว้าง ระบบจะขยายการ์ดด้านข้างด้วย ส่วนในมุมมองแบบแคบ ระบบจะไม่บังคับเปิดลิ้นชักเต็มหน้าจอ; ปิดเพื่อเลือกหน้าด้วยตนเอง',
settingsToolsTitle: 'ฉีดเครื่องมือเทอร์มินัลสำหรับโมเดล',
settingsToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถสร้างและควบคุมเทอร์มินัลแถบด้านข้างผ่านเครื่องมือ terminal_* 8 ตัว (ปิดเป็นค่าเริ่มต้น)',
settingsFenceTitle: 'การตรวจสอบเส้นทางเวิร์กสเปซ',
settingsFenceDesc: 'เมื่อเปิด ฟีเจอร์ไฟล์ของแถบข้างเข้าถึงได้เฉพาะเส้นทางในเวิร์กสเปซของเซสชัน (ค่าเริ่มต้น) เมื่อปิด จะเข้าถึงไฟล์ใดก็ได้บนโฮสต์ — สคริปต์ในหน้าก็ได้ขอบเขตเดียวกันตลอดช่วงนั้น',
fenceErrorReason: 'เส้นทางนี้อยู่นอกเวิร์กสเปซของเซสชันและถูกบล็อกโดยการตรวจสอบ',
fenceDisableAction: 'ปิดการตรวจสอบเวิร์กสเปซ',
settingsBottomTerminalTitle: 'เปิดเทอร์มินัลอัตโนมัติเมื่อพาเนลล่างขยายครั้งแรก',
settingsBottomTerminalDesc: 'เมื่อพาเนลล่างถูกขยายครั้งแรกในเซสชัน ลองเปิด tab เทอร์มินัลใหม่ที่นั่น (โควต้าเทอร์มินัลยังคงมีผล; เปิดเป็นค่าเริ่มต้น)',
settingsFontFamilyTitle: 'ฟอนต์เทอร์มินัล',
-4
View File
@@ -262,10 +262,6 @@ export const tr: Record<string, string> = {
settingsJobsDesc: 'Geçerli sohbette her yeni arka plan işi belirdiğinde Görevler sayfasını etkinleştir (her yeni iş tetikler); geniş ekranlarda yan kart da genişletilir, dar ekranlarda tam ekran çekmecesi zorla açılmaz; kapalıyken elle açılması gerekir',
settingsToolsTitle: 'Model için terminal araçları enjekte et',
settingsToolsDesc: 'Etkinleştirildiğinde model, 8 terminal_* aracı üzerinden kenar çubuğu terminalleri oluşturup kullanabilir (varsayılan kapalı)',
settingsFenceTitle: 'Çalışma alanı yol denetimi',
settingsFenceDesc: 'Açıkken kenar çubuğunun dosya işlevleri yalnızca oturum çalışma alanındaki yollara erişir (varsayılan); kapatıldığında ana makinedeki her dosyaya erişilebilir — o süre boyunca sayfadaki betikler de aynı erişimi kazanır',
fenceErrorReason: 'Bu yol oturum çalışma alanının dışında ve denetim tarafından engellendi',
fenceDisableAction: 'Çalışma alanı denetimini kapat',
settingsBottomTerminalTitle: 'Alt panelin ilk genişletilmesinde otomatik terminal aç',
settingsBottomTerminalDesc: 'Bir oturumda alt panel ilk genişletildiğinde orada yeni bir terminal sekmesi açmayı dene (terminal kotası yine geçerli; varsayılan açık)',
settingsFontFamilyTitle: 'Terminal yazı tipi ailesi',
-4
View File
@@ -262,10 +262,6 @@ export const vi: Record<string, string> = {
settingsJobsDesc: 'Mỗi khi phiên hiện tại có tác vụ nền mới, kích hoạt trang Quản lý tác vụ (mỗi tác vụ mới đều kích hoạt); trên màn hình rộng, thẻ bên cũng được mở rộng; trên màn hình hẹp, ngăn kéo toàn màn hình không bị ép mở; tắt để mở thủ công',
settingsToolsTitle: 'Tiêm công cụ terminal cho mô hình',
settingsToolsDesc: 'Khi bật, mô hình có thể tạo và điều khiển terminal thanh bên qua 8 công cụ terminal_* (mặc định tắt)',
settingsFenceTitle: 'Kiểm tra đường dẫn workspace',
settingsFenceDesc: 'Khi bật, các tính năng tệp của thanh bên chỉ truy cập đường dẫn trong workspace của phiên (mặc định); khi tắt, mọi tệp trên máy chủ đều có thể truy cập — script trong trang cũng có quyền tương ứng trong thời gian đó',
fenceErrorReason: 'Đường dẫn này nằm ngoài workspace của phiên và đã bị chặn bởi kiểm tra',
fenceDisableAction: 'Tắt kiểm tra workspace',
settingsBottomTerminalTitle: 'Tự động mở terminal khi panel dưới mở rộng lần đầu',
settingsBottomTerminalDesc: 'Khi panel dưới được mở rộng lần đầu trong phiên, thử mở một tab terminal mới ở panel dưới (giới hạn terminal vẫn áp dụng; mặc định bật)',
settingsFontFamilyTitle: 'Font terminal',
-4
View File
@@ -277,10 +277,6 @@ export const zhHK: Record<string, string> = {
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsToolsTitle: '為模型注入終端工具',
settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)',
settingsFenceTitle: '工作區路徑檢測',
settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力',
fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截',
fenceDisableAction: '關閉工作區檢測',
settingsBottomTerminalTitle: '底部面板首次展開自動開終端',
settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)',
settingsFontFamilyTitle: '終端字體',
-4
View File
@@ -277,10 +277,6 @@ export const zhMO: Record<string, string> = {
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsToolsTitle: '為模型注入終端工具',
settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)',
settingsFenceTitle: '工作區路徑檢測',
settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力',
fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截',
fenceDisableAction: '關閉工作區檢測',
settingsBottomTerminalTitle: '底部面板首次展開自動開終端',
settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)',
settingsFontFamilyTitle: '終端字體',
-4
View File
@@ -277,10 +277,6 @@ export const zhTW: Record<string, string> = {
settingsJobsDesc: '目前工作階段出現新的背景任務時,自動選取任務管理頁(每個新任務都會觸發);寬螢幕時亦會展開側邊欄,窄螢幕時不會強制開啟全螢幕抽屜;關閉後需手動選取',
settingsToolsTitle: '為模型注入終端工具',
settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)',
settingsFenceTitle: '工作區路徑檢測',
settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力',
fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截',
fenceDisableAction: '關閉工作區檢測',
settingsBottomTerminalTitle: '底部面板首次展開自動開終端',
settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)',
settingsFontFamilyTitle: '終端字體',
-8
View File
@@ -274,10 +274,6 @@ export const zh = {
settingsJobsDesc: '当前会话出现新的后台任务时,自动激活任务管理页(每个新任务都会触发);宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉;关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsFenceTitle: '工作区路径检测',
settingsFenceDesc: '开启后,侧栏的文件功能仅能访问会话工作区内的路径(默认);关闭后可访问主机上任意文件——关闭期间页面内脚本也将获得同等访问能力',
fenceErrorReason: '此路径在会话工作区之外,已被工作区检测拦截',
fenceDisableAction: '关闭工作区检测',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
settingsFontFamilyTitle: '终端字体',
@@ -788,10 +784,6 @@ export const en: Record<keyof typeof zh, string> = {
settingsJobsDesc: 'Activate the Tasks page whenever a new background job appears for the current conversation (every new job triggers); wide viewports also expand the side card, while narrow full-screen drawers are not forced open; turn off to open it manually',
settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsFenceTitle: 'Workspace path fence',
settingsFenceDesc: 'On, the sidebar\'s file features only reach paths inside the session workspace (default); off, any file on the host is reachable — page scripts gain the same reach while it is off',
fenceErrorReason: 'This path is outside the session workspace and was blocked by the workspace fence',
fenceDisableAction: 'Turn off the workspace fence',
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)',
settingsFontFamilyTitle: 'Terminal font family',
+4
View File
@@ -185,6 +185,10 @@ export interface NativeSurfaceDeps {
*/
export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
const { ctx, store, service, records, reportFailure } = deps
// Bind the store BEFORE any body can render: the per-session state is the
// authority for the explorer's expansion set, so the native surface and the
// workbench share one set per session and a closed tab cannot drop it.
records.attachStore(store)
// Wait for the tab-type REGISTRY (a service), not for the slot declaration:
// the native seat declares `sidebar.right.pane.tab` BEFORE it provides
// `sidebarRightTabs`, so a declaration-triggered registration reads the
+77 -8
View File
@@ -14,7 +14,10 @@
* record + `navigation.params` and kept live across navigations, so a
* component that retitles itself (`updateTab`) or rewrites its path (the
* editor's in-place file switch) keeps working;
* - the explorer's per-tab expansion/reveal sets;
* - the explorer's expansion/reveal sets: EXPANSION lives in the per-session
* state the workbench already uses (one set per session, shared by every
* native tab of that session — a closed tab cannot drop it), while the
* transient "show in folder" reveal stays per tab;
* - the per-kind instance counter behind titles like "Terminal 2".
*
* Nothing here is a singleton: the registry is created once per client
@@ -28,6 +31,7 @@ import { RenderBoundary } from '../RenderBoundary.tsx'
import { OrphanedTab } from '../OrphanedTab.tsx'
import { referenceInChat } from '../reference-in-chat.ts'
import type { BetterSidebarService } from '../service.ts'
import { toggleExpanded } from '../state.ts'
import type { SidebarStore, SidebarTab, TabType } from '../state.ts'
import css from '../sidebar.module.css'
@@ -77,7 +81,15 @@ export interface NativeTabInfo {
interface View {
tab: SidebarTab
scope: SessionScope
/**
* A PROJECTION of the session's expansion set (never an authoritative copy):
* refreshed from the store on every `ensure` and on every store change, so
* every native tab of one session shows the same set and reopening a tab
* cannot reset it. Cached only for referential stability — a fresh array on
* each render would re-run the tree's load effect for nothing.
*/
expanded: string[]
/** The transient "show in folder" highlight: per tab by design. */
revealed: string[]
/** Bumped on every mutation; the components subscribe to it. */
version: number
@@ -114,7 +126,14 @@ export interface NativeTabRecords {
update(id: string, patch: { title?: string; path?: string; meta?: unknown }): void
/** Forget a record (the native tab closed). */
drop(id: string): void
/** Toggle one directory in a record's expansion set. */
/**
* Bind the plugin store — the per-session state is the AUTHORITY for every
* view's expansion set. Idempotent; the first call also subscribes to store
* changes so a toggle made anywhere (the workbench, another native tab, a
* persisted restore) refreshes every native view.
*/
attachStore(store: SidebarStore): void
/** Toggle one directory in a record's SESSION expansion set. */
toggleExpanded(id: string, path: string): void
/** Mint the next instance number of a kind (titles like "Terminal 2"). */
nextInstance(kind: string): number
@@ -124,6 +143,11 @@ export interface NativeTabRecords {
subscribe(listener: () => void): () => void
}
/** Whether two expansion sets hold the same paths in the same order. */
function samePaths(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((path, index) => path === right[index])
}
/** Create the record registry for one client activation. */
export function createNativeTabRecords(): NativeTabRecords {
const views = new Map<string, View>()
@@ -134,7 +158,39 @@ export function createNativeTabRecords(): NativeTabRecords {
views.set(id, { ...view, version: view.version + 1 })
notify()
}
/** The session-state authority (bound through {@link NativeTabRecords.attachStore}). */
let store: SidebarStore | undefined
/** The session's expansion set (absent session = nothing expanded yet). */
const expandedOf = (sessionId: string): string[] =>
store?.getSessionStates().get(sessionId)?.expanded ?? []
/**
* Re-read every view's expansion projection from the session state. Views
* that changed get a version bump (which is what re-renders their bodies);
* identical sets keep their array identity so the tree's `expanded` prop —
* and therefore its load effect — stays stable.
*/
const syncExpanded = (): void => {
if (store === undefined) return
// One map copy for the whole pass (getSessionStates copies by contract).
const states = store.getSessionStates()
let changed = false
for (const [id, entry] of views) {
const next = states.get(entry.scope.sessionId)?.expanded ?? []
if (samePaths(entry.expanded, next)) continue
views.set(id, { ...entry, expanded: next, version: entry.version + 1 })
changed = true
}
if (changed) notify()
}
return {
attachStore(next) {
if (store === next) return
store = next
// One listener for the whole registry: the store lives as long as this
// activation, so the returned disposer is not kept.
next.subscribe(syncExpanded)
syncExpanded()
},
ensure({ id, kind, title, params, scope, mint }) {
const existing = views.get(id)
if (existing === undefined) {
@@ -150,7 +206,7 @@ export function createNativeTabRecords(): NativeTabRecords {
...(meta === undefined ? {} : { meta }),
},
scope,
expanded: [],
expanded: expandedOf(scope.sessionId),
revealed: [],
version: 0,
}
@@ -169,6 +225,13 @@ export function createNativeTabRecords(): NativeTabRecords {
: {}
patch.meta = { ...meta, url: params.url }
}
// The expansion set always mirrors the CURRENT session state (a record
// reused for another session must not keep the previous one's set).
const expanded = expandedOf(scope.sessionId)
if (existing.scope.sessionId !== scope.sessionId || !samePaths(existing.expanded, expanded)) {
views.set(id, { ...existing, scope, expanded, tab: { ...existing.tab, ...patch } })
return views.get(id)!
}
if (existing.scope.cwd !== scope.cwd) {
views.set(id, { ...existing, scope, tab: { ...existing.tab, ...patch } })
return views.get(id)!
@@ -190,11 +253,17 @@ export function createNativeTabRecords(): NativeTabRecords {
},
toggleExpanded(id, path) {
const entry = views.get(id)
if (entry === undefined) return
const expanded = entry.expanded.includes(path)
? entry.expanded.filter(candidate => candidate !== path)
: [...entry.expanded, path]
put(id, { ...entry, expanded })
if (entry === undefined || store === undefined) return
const sessionId = entry.scope.sessionId
// The ACTIVE session goes through `reduce` (it notifies, so the native
// views refresh through the subscription); a background session goes
// through `reduceFor`, which deliberately stays silent — refresh here.
if (store.getSnapshot().sessionId === sessionId) {
store.reduce(state => toggleExpanded(state, path))
return
}
store.reduceFor(sessionId, state => toggleExpanded(state, path))
syncExpanded()
},
nextInstance(kind) {
const next = (instances.get(kind) ?? 0) + 1
-3
View File
@@ -60,9 +60,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
workspaceFence: typeof record.workspaceFence === 'boolean'
? record.workspaceFence
: SIDEBAR_PREFS_DEFAULTS.workspaceFence,
// The title-bar scheme (auto | web | preset | custom). The schema
// declares the field WITHOUT a default, so documents written by older
// plugin versions resolve without it — migrate from the legacy fields:
+1 -1
View File
@@ -639,7 +639,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.22.0'
export const SIDEBAR_SERVICE_VERSION = '0.23.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
-12
View File
@@ -1414,18 +1414,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-state-error-primary);
}
/* The workspace-fence refusal row (FenceErrorNotice): reason text plus the
* one-click global-off button share one baseline, wrapping when narrow. */
.fenceError {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
}
.editorBanner {
flex: none;
padding: 4px 8px;