用户实测反馈:
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)
196 lines
8.5 KiB
TypeScript
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 }
|
|
}
|
|
}
|