Files
apes-sidebar/src/client/prefs.ts
T
Menghuan1918 5c95002641 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)
2026-09-28 20:37:56 +08:00

196 lines
8.5 KiB
TypeScript

/**
* Client-side read of the user-facing "Side card" preferences. The host owns
* the namespace through the settings seam (in-process); the DSH settings RPC
* domain only serves allowlisted namespaces to configuration clients, so the
* client reads and writes THIS namespace through the plugin's own fenced
* /sidebar routes instead (api.settingsGet/settingsUpdate). Any failure
* (route rejected, namespace absent, a field of the wrong type, a value out
* of the contract range) falls back to the schema defaults — the side card
* must keep working exactly as composed when the settings surface is missing.
*/
import type { api } from './api.ts'
import {
clampTitleBarStrip,
SIDEBAR_PREFS_DEFAULTS,
TITLE_BAR_SCHEMES,
TITLE_BAR_STRIP_DEFAULT,
type SidebarPrefs,
type TitleBarScheme,
} from '../prefs-shared.ts'
export {
SIDEBAR_PREFS_DEFAULTS,
TITLE_BAR_SCHEMES,
TITLE_BAR_STRIP_DEFAULT,
clampTitleBarStrip,
}
export type { SidebarPrefs, TitleBarScheme }
/** The settings wire face the preferences need (a subset of the plugin api). */
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
/** Validate one raw resolved value into {@link SidebarPrefs}. Used for the
* settings.get payload AND the settings.update response (both carry the
* layered resolved value); any malformed field falls back to its default.
* @param value - the raw resolved section from the settings wire.
* @returns validated prefs (always well-formed).
*/
export function parsePrefs(value: unknown): SidebarPrefs {
if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS }
const record = value as Record<string, unknown>
return {
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
autoOpenJobs: typeof record.autoOpenJobs === 'boolean'
? record.autoOpenJobs
: SIDEBAR_PREFS_DEFAULTS.autoOpenJobs,
tasksViewMode: record.tasksViewMode === 'tree' || record.tasksViewMode === 'graph'
? record.tasksViewMode
: SIDEBAR_PREFS_DEFAULTS.tasksViewMode,
mobileNoAutoOpen: typeof record.mobileNoAutoOpen === 'boolean'
? record.mobileNoAutoOpen
: SIDEBAR_PREFS_DEFAULTS.mobileNoAutoOpen,
mobileDefaultTree: typeof record.mobileDefaultTree === 'boolean'
? record.mobileDefaultTree
: SIDEBAR_PREFS_DEFAULTS.mobileDefaultTree,
agentOpenTools: typeof record.agentOpenTools === 'boolean'
? record.agentOpenTools
: SIDEBAR_PREFS_DEFAULTS.agentOpenTools,
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
// 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:
// a document that ALREADY HAS VALUES (the manual compat flag on, or a
// non-default strip px — both only reachable through the old gear
// popup) maps to the `custom` scheme so the user's numbers keep
// working; a pristine document keeps the conservative `auto` scheme.
titleBarScheme: isTitleBarScheme(record.titleBarScheme)
? record.titleBarScheme
: (record.titleBarCompat === true || hasLegacyStripValue(record.titleBarStripPx) ? 'custom' : 'auto'),
titleBarPresetId: typeof record.titleBarPresetId === 'string'
? record.titleBarPresetId
: SIDEBAR_PREFS_DEFAULTS.titleBarPresetId,
customCss: typeof record.customCss === 'string'
? record.customCss
: SIDEBAR_PREFS_DEFAULTS.customCss,
titleBarCompat: typeof record.titleBarCompat === 'boolean'
? record.titleBarCompat
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
titleBarStripPx: typeof record.titleBarStripPx === 'number' && Number.isFinite(record.titleBarStripPx)
? clampTitleBarStrip(record.titleBarStripPx)
: SIDEBAR_PREFS_DEFAULTS.titleBarStripPx,
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
? record.htmlViewerNoSandbox
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
htmlViewerDefaultUnsafe: typeof record.htmlViewerDefaultUnsafe === 'boolean'
? record.htmlViewerDefaultUnsafe
: SIDEBAR_PREFS_DEFAULTS.htmlViewerDefaultUnsafe,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
}
}
/**
* Validate the plugin-owned settings map (v0.12.0+): `{ descriptorId: { key:
* value } }`, nested open maps. Any non-object value (or a malformed whole)
* falls back to the empty map — the schema defaults already guard the wire
* shape, this is the client's second line.
*/
function pluginSettingsMapOf(value: unknown): Record<string, Record<string, unknown>> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
const out: Record<string, Record<string, unknown>> = {}
for (const [id, blob] of Object.entries(value as Record<string, unknown>)) {
if (blob !== null && typeof blob === 'object' && !Array.isArray(blob)) {
out[id] = blob as Record<string, unknown>
}
}
return out
}
/**
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values
* survive; a non-object or a non-boolean entry falls back to the empty map /
* drops the entry — an absent key means the feature stays enabled.
*/
function booleanMapOf(value: unknown): Record<string, boolean> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
const out: Record<string, boolean> = {}
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
if (typeof item === 'boolean') out[key] = item
}
return out
}
/** Type guard for the title-bar scheme union (anything else falls back). */
function isTitleBarScheme(value: unknown): value is TitleBarScheme {
return typeof value === 'string' && (TITLE_BAR_SCHEMES as readonly string[]).includes(value)
}
/**
* Whether the legacy document carries an explicit strip value (only
* reachable through the old gear popup): a stored number different from the
* default counts as "the user already configured something" and migrates to
* the `custom` scheme.
*/
function hasLegacyStripValue(value: unknown): boolean {
return typeof value === 'number' && Number.isFinite(value) && value !== TITLE_BAR_STRIP_DEFAULT
}
/**
* Read the resolved side card preferences through the plugin's settings route.
* @param settings - the settings wire face (the plugin api by default).
* @returns validated prefs, or the schema defaults when the route rejects,
* the namespace is absent, or a stored value violates the contract.
*/
export async function loadPrefs(settings: SidebarSettingsClient): Promise<SidebarPrefs> {
try {
const view = await settings.settingsGet()
return parsePrefs(view.value)
} catch {
// Transport/fence rejection or a malformed response: keep the defaults.
return { ...SIDEBAR_PREFS_DEFAULTS }
}
}
/**
* Read the external-disable flag from the same settings route: the
* dsh-web-ui family's aionui-panel provider choice. True only when the host
* resolved `aionui-panel.rightPanel` to 'aionui-panel' — while true the
* sidebar must not mount (the two right panels are mutually exclusive). Any
* failure (route rejected, aionui absent, malformed response) reads false,
* so a missing family never hides the sidebar.
* @param settings - the settings wire face (the plugin api by default).
* @returns the external-disable flag (false on any failure).
*/
export async function loadExternalDisable(settings: SidebarSettingsClient): Promise<boolean> {
try {
const view = await settings.settingsGet()
return view.externalDisable === true
} catch {
return false
}
}
/** The boot decision both the prefs and the external-disable flag need:
* ONE settings fetch answers both (the boot path used to await
* {@link loadPrefs} and {@link loadExternalDisable} serially — two round
* trips of the same document before the first paint, and the second had no
* timeout, so a stalled wire could keep the sidebar unmounted forever). */
export interface BootDecision {
prefs: SidebarPrefs
suspended: boolean
}
export async function loadBootDecision(settings: SidebarSettingsClient): Promise<BootDecision> {
try {
const view = await settings.settingsGet()
return { prefs: parsePrefs(view.value), suspended: view.externalDisable === true }
} catch {
return { prefs: { ...SIDEBAR_PREFS_DEFAULTS }, suspended: false }
}
}