fix(v0.3.3): 分栏引擎双版本锚点(0.1.1-rc.2/0.1.2-rc.1) + 数据目录解析循环修复 + 发布流水线加防(锚点8场景/数据目录3场景) + 公告/文档同步
This commit is contained in:
@@ -1,9 +1,21 @@
|
||||
/** 更新公告正文(v0.3.2:文件夹选择体验 + 发布流程加固;呈现时按纯文本保留换行,不做 md 渲染) */
|
||||
export const CHANGELOG_VERSION = 'v0.3.2'
|
||||
export const CHANGELOG_V030 = `更新公告 · v0.3.2
|
||||
/** 更新公告正文(v0.3.3:DSH 0.1.2 兼容 + 数据路径修复;呈现时按纯文本保留换行,不做 md 渲染) */
|
||||
export const CHANGELOG_VERSION = 'v0.3.3'
|
||||
export const CHANGELOG_V030 = `更新公告 · v0.3.3
|
||||
|
||||
🛠️ 本版本更新
|
||||
|
||||
【兼容新版 DeepSeek Harness】适配已测版本 DSH 0.1.2-rc.1 与 0.1.1-rc.2:点击项目卡片不再无反应——分栏工作区在无会话 / 空会话 / 有消息会话下都能正常打开(0.1.1-rc.2 实测未发现回归);选择项目文件夹的窗口在 0.1.2-rc.1 上恢复正常可用;工作台数据读取不再依赖默认安装路径,自定义安装位置(DSH_HOME)也能正确读取项目与分组数据。
|
||||
|
||||
【关于选择文件夹窗口的已知问题(DeepSeek Harness 宿主自身问题,不是本插件的问题)】在部分系统上,点击「选择位置…」之后文件夹选择窗口可能不会自动跳到最前面,而是藏在任务栏或其他窗口后面。这是 DeepSeek Harness 宿主选择器的已知缺陷,与工作台无关,等待官方修复。遇到时点一下任务栏里的 DSH 图标即可切出窗口;若不想等,直接用「手动输入」粘贴文件夹绝对路径,功能完全不受影响。
|
||||
|
||||
【谁需要升级】仍在用老版本 DSH 且工作台一切正常的用户,可以暂不升级;升级到 0.1.2 系列、或遇到「点击项目卡片没有反应」的用户,建议升级本版本。
|
||||
|
||||
【升级说明】升级安装后请完整退出并重启 DSH 再刷新页面;若重启后仍显示加载失败,请保留完整错误信息反馈。
|
||||
|
||||
───────
|
||||
|
||||
此前版本说明(v0.3.2)
|
||||
|
||||
【文件夹选择体验】选择项目文件夹时:等待期间按钮显示「等待选择…」并提示「若未看到选择窗口,可检查任务栏,或使用手动输入」;新增「手动输入」入口,可直接粘贴文件夹绝对路径(相对路径会被拒绝并提示);选择失败时显示具体错误,不再静默无反应;修复了旧选择窗口返回后覆盖手工输入、弹窗关闭/切换后状态残留等问题。
|
||||
|
||||
【发布流程加固】发布包新增结构与身份校验、客户端工厂求值门禁、双资产同源核对与远端 SHA 比对(开发者侧改进,不影响插件功能)。
|
||||
|
||||
@@ -412,6 +412,8 @@ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [],
|
||||
|
||||
/** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */
|
||||
let sessionBridge: { sessions: any; conversation: any; list: any; workspaces: any } | null = null
|
||||
/** 客户端根 ctx(apply 时保存;用于运行时探测 0.1.2 的 remote.directoryPicker 等新服务,不硬写 inject) */
|
||||
let clientCtx: any = null
|
||||
|
||||
/** 宿主 API 客户端(apply 时从 connection 服务取;agentPresets/sessions 用于修复新会话继承失效模型的 bug) */
|
||||
let hostApi: { agentPresets?: any; sessions?: any } | null = null
|
||||
@@ -1845,11 +1847,29 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
* - useRef 在途锁 + 请求序号:真实防重入;手工确认/新请求会使旧请求失效,旧弹窗返回后丢弃
|
||||
* - 返回 null = 用户取消:不报错
|
||||
* - 抛异常 = 显示真实错误信息,不再静默吞掉
|
||||
* - 宿主能力不可用 = 提示改用「手动输入」 */
|
||||
* - 宿主能力三级探测(运行时,不硬写 inject):
|
||||
* ① 0.1.1-rc.2:ctx.workspaces.pickDirectory() → string | null
|
||||
* ② 0.1.2-rc.1:ctx remote.directoryPicker.pick() → { ok, value, error }(value 即路径或 null)
|
||||
* ③ 0.1.2 新能力不可用 → 提示改用「手动输入」 */
|
||||
const pickFolder = async (target: 'add' | 'bind', apply: (p: string) => void) => {
|
||||
if (pickBusyRef.current) return
|
||||
// 解析本次选择器调用路径
|
||||
const ws = sessionBridge?.workspaces as any
|
||||
if (!ws || typeof ws.pickDirectory !== 'function') {
|
||||
const legacy = ws && typeof ws.pickDirectory === 'function'
|
||||
? () => ws.pickDirectory()
|
||||
: null
|
||||
let remotePicker: (() => Promise<any>) | null = null
|
||||
try {
|
||||
const rp = clientCtx?.get?.('remote.directoryPicker')
|
||||
if (rp && typeof rp.pick === 'function') remotePicker = () => rp.pick()
|
||||
} catch {}
|
||||
if (!remotePicker) {
|
||||
try {
|
||||
const dp = (clientCtx as any)?.directoryPicker
|
||||
if (dp && typeof dp.pick === 'function') remotePicker = () => dp.pick()
|
||||
} catch {}
|
||||
}
|
||||
if (!legacy && !remotePicker) {
|
||||
setPickErr((prev) => ({ ...prev, [target]: t('add.folderPickerUnavailable') }))
|
||||
return
|
||||
}
|
||||
@@ -1858,7 +1878,19 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
setPickErr((prev) => ({ ...prev, [target]: '' }))
|
||||
const seq = ++pickSeqRef.current
|
||||
try {
|
||||
const p = await ws.pickDirectory()
|
||||
let p: string | null = null
|
||||
if (legacy) {
|
||||
p = await legacy()
|
||||
} else {
|
||||
const r = await remotePicker!()
|
||||
if (r && typeof r === 'object' && 'ok' in r) {
|
||||
if (!r.ok) throw new Error(r?.error?.message ?? 'directory picker failed')
|
||||
const v = r.value
|
||||
p = typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.path === 'string' ? v.path : null)
|
||||
} else {
|
||||
p = typeof r === 'string' ? r : null
|
||||
}
|
||||
}
|
||||
if (seq !== pickSeqRef.current) return // 已被手工确认/新请求/弹窗关闭失效:丢弃旧弹窗结果
|
||||
if (!mountedRef.current) return // 组件已卸载:不再应用
|
||||
if (p && typeof p === 'string') apply(p)
|
||||
@@ -3273,6 +3305,7 @@ export const inject = ['slots', 'locale', 'sessions', 'conversation', 'workspace
|
||||
export function apply(ctx: any) {
|
||||
// 自定义窗口 → 宿主会话桥:保存 sessions/conversation/list 服务引用(模块级)
|
||||
sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null, workspaces: ctx.workspaces ?? null }
|
||||
clientCtx = ctx
|
||||
try { hostApi = ctx.get?.('connection')?.api ?? null } catch { hostApi = null }
|
||||
try { (window as any).__dshHostApi = hostApi } catch {}
|
||||
try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces; (window as any).__dshBuildWindowTaskText = buildWindowTaskText; (window as any).__dshSyncSessionScope = () => syncSessionScope(sessionBridge?.list) } catch {}
|
||||
|
||||
@@ -90,6 +90,8 @@ type SplitState = {
|
||||
savedMarginLeft: string
|
||||
savedMarginRight: string
|
||||
savedMarginTop: string
|
||||
/** 打开前会话根上 --dsh-chat-user-width 的内联值(关闭时原样恢复;0.1.1 上该变量闲置无害) */
|
||||
savedWidthVar: string
|
||||
observer: ResizeObserver | null
|
||||
fallback: MutationObserver | null
|
||||
yieldObserver: MutationObserver | null
|
||||
@@ -671,13 +673,48 @@ function AnnotationOverlay() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者;优先 phase=active;无活动会话返回 null */
|
||||
/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者。
|
||||
* DSH 0.1.1-rc.2 与 0.1.2-rc.1 的会话阶段一致:active(有消息)/ hero(无会话或空会话)/ settling(过渡)。
|
||||
* 优先 active,其次 hero(空会话/无会话时也能打开分栏),settling 兜底(锚定后由观察器修正)。 */
|
||||
function findConversationRoot(): HTMLElement | null {
|
||||
const candidates = Array.from(document.querySelectorAll<HTMLElement>('[data-phase]'))
|
||||
const ok = (el: HTMLElement) => el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT' && el.children.length >= 2
|
||||
return candidates.find((el) => ok(el) && el.dataset.phase === 'active')
|
||||
?? candidates.find(ok)
|
||||
?? null
|
||||
const ok = (el: HTMLElement) => el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT'
|
||||
&& el.children.length >= 1 && !/input/i.test(String(el.className))
|
||||
const rank = (el: HTMLElement) => (el.dataset.phase === 'active' ? 0 : el.dataset.phase === 'hero' ? 1 : 2)
|
||||
let best: HTMLElement | null = null
|
||||
let bestRank = Infinity
|
||||
for (const el of candidates) {
|
||||
if (!ok(el)) continue
|
||||
const r = rank(el)
|
||||
if (r < bestRank) { bestRank = r; best = el }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** 从首个槽位元素里解析「可见头部」:
|
||||
* - 0.1.1:children[0] 就是 <header> 本身(可见时自身有高度)。
|
||||
* - 0.1.2:children[0] 是零高的槽位包装(display:contents 式),真实头部渲染在其内部——
|
||||
* 此时向内找第一个可见后代(文档序 = 头部自身,先于其子元素)。
|
||||
* - 空会话(headerHidden,整棵子树 display:none):找不到任何可见元素 → null(顶部 = 根顶部)。 */
|
||||
function visibleHeaderIn(first: HTMLElement): HTMLElement | null {
|
||||
if (first.getBoundingClientRect().height > 0) return first
|
||||
for (const el of Array.from(first.querySelectorAll<HTMLElement>('*'))) {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.height > 0 && r.width > 0) return el
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 从会话根解析分栏锚点(兼容 0.1.1 与 0.1.2 两种 DOM 结构):
|
||||
* - 0.1.1 与 0.1.2 会话打开:root.children = [头部(或头部包装), 内容滚动区](0.1.2 多一层 body 包装,不影响取 children[1])。
|
||||
* - 0.1.2 无会话:root.children = [body](无头部元素)。
|
||||
* - 空会话(blank):头部带 headerHidden 样式(高度为 0),按「无头部」处理(顶部 = 根顶部)。
|
||||
* header 仅当存在第二个子元素且能解析出可见头部时生效;viewArea = 第二个子元素,只有一个时 = 第一个。 */
|
||||
function resolveAnchor(root: HTMLElement): { header: HTMLElement | null; viewArea: HTMLElement } {
|
||||
const first = root.children[0] as HTMLElement | undefined
|
||||
const second = root.children[1] as HTMLElement | undefined
|
||||
const header = !!second && !!first ? visibleHeaderIn(first) : null
|
||||
return { header, viewArea: second ?? first! }
|
||||
}
|
||||
|
||||
function loadSaved(layoutId: string): { chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[]; leftWs: number[] } | null {
|
||||
@@ -730,6 +767,7 @@ export const splitStore: SplitState = {
|
||||
savedMarginLeft: '',
|
||||
savedMarginRight: '',
|
||||
savedMarginTop: '',
|
||||
savedWidthVar: '',
|
||||
observer: null,
|
||||
fallback: null,
|
||||
yieldObserver: null,
|
||||
@@ -759,9 +797,7 @@ export const splitStore: SplitState = {
|
||||
} catch {}
|
||||
const root = findConversationRoot()
|
||||
if (!root) return false
|
||||
const header = root.children[0] as HTMLElement | undefined
|
||||
const viewArea = root.children[1] as HTMLElement | undefined
|
||||
if (!header || !viewArea) return false
|
||||
const { header, viewArea } = resolveAnchor(root)
|
||||
this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' }
|
||||
// 向后兼容归一化:单内容声明 → 一个标签页
|
||||
const normalize = (p: SplitPane): SplitPane => {
|
||||
@@ -793,6 +829,7 @@ export const splitStore: SplitState = {
|
||||
this.root = root
|
||||
this.header = header
|
||||
this.viewArea = viewArea
|
||||
this.savedWidthVar = root.style.getPropertyValue('--dsh-chat-user-width')
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginRight = viewArea.style.marginRight
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
@@ -841,20 +878,21 @@ export const splitStore: SplitState = {
|
||||
this.applyMargin()
|
||||
this.observer = new ResizeObserver(() => {
|
||||
const r = this.root
|
||||
if (!(r && r.isConnected && r.dataset.phase === 'active')) {
|
||||
this.syncAnchor()
|
||||
return
|
||||
}
|
||||
if (!(r && r.isConnected)) { this.syncAnchor(); return }
|
||||
const anchor = resolveAnchor(r)
|
||||
if (anchor.header !== this.header || anchor.viewArea !== this.viewArea) { this.syncAnchor(); return }
|
||||
this.refreshGeom()
|
||||
this.applyMargin()
|
||||
this.notify()
|
||||
})
|
||||
this.observer.observe(root)
|
||||
// 兜底:会话根被替换/phase 变化时 RO 可能不再回调,用 body 级 MutationObserver 驱动重锚定
|
||||
// 兜底:会话根被替换/子结构变化(如 hero→active 头部出现)时 RO 可能不再回调,
|
||||
// 用 body 级 MutationObserver 驱动重锚定;锚点未变时静默(避免流式消息期间高频刷新)
|
||||
this.fallback = new MutationObserver(() => {
|
||||
const r = this.root
|
||||
if (r && r.isConnected && r.dataset.phase === 'active') return
|
||||
this.syncAnchor()
|
||||
if (!(r && r.isConnected)) { this.syncAnchor(); return }
|
||||
const anchor = resolveAnchor(r)
|
||||
if (anchor.header !== this.header || anchor.viewArea !== this.viewArea) this.syncAnchor()
|
||||
})
|
||||
this.fallback.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-phase'] })
|
||||
// 让位观察器:会话视图区 margin 被外部改写(其他未接入协议的分栏引擎接管)时关闭自身
|
||||
@@ -872,7 +910,7 @@ export const splitStore: SplitState = {
|
||||
return true
|
||||
},
|
||||
|
||||
/** 会话根失效(切换会话)时重新锚定:左侧内容保持不关闭;无会话才关闭 */
|
||||
/** 会话根失效(切换会话 / hero↔active 结构变化)时重新锚定:左侧内容保持不关闭;无会话根才关闭 */
|
||||
syncAnchor() {
|
||||
if (!this.active) return
|
||||
const next = findConversationRoot()
|
||||
@@ -880,31 +918,42 @@ export const splitStore: SplitState = {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
if (next.dataset.phase !== 'active') return // 过渡态:保持等待(phase 变化会再次触发)
|
||||
if (next === this.root) {
|
||||
const ph = next.dataset.phase
|
||||
if (ph !== 'active' && ph !== 'hero') return // settling 等过渡态:保持等待(phase 变化会再次触发)
|
||||
const anchor = resolveAnchor(next)
|
||||
if (next === this.root && anchor.header === this.header && anchor.viewArea === this.viewArea) {
|
||||
// 同锚点:头部可能由隐藏变可见(hero→active),刷新几何与挤位
|
||||
this.refreshGeom()
|
||||
this.applyMargin()
|
||||
this.notify()
|
||||
return
|
||||
}
|
||||
const header = next.children[0] as HTMLElement | undefined
|
||||
const viewArea = next.children[1] as HTMLElement | undefined
|
||||
if (!header || !viewArea) {
|
||||
this.close()
|
||||
return
|
||||
const viewArea = anchor.viewArea
|
||||
const oldRoot = this.root
|
||||
const oldViewArea = this.viewArea
|
||||
// 恢复旧锚点(若仍连接),锚定到新会话根
|
||||
if (oldViewArea && oldViewArea.isConnected && oldViewArea !== viewArea) {
|
||||
oldViewArea.style.marginLeft = this.savedMarginLeft
|
||||
oldViewArea.style.marginRight = this.savedMarginRight
|
||||
oldViewArea.style.marginTop = this.savedMarginTop
|
||||
}
|
||||
// 恢复旧视图区 margin(若仍连接),锚定到新会话根
|
||||
if (this.viewArea && this.viewArea.isConnected && this.viewArea !== viewArea) {
|
||||
this.viewArea.style.marginLeft = this.savedMarginLeft
|
||||
this.viewArea.style.marginRight = this.savedMarginRight
|
||||
this.viewArea.style.marginTop = this.savedMarginTop
|
||||
if (oldRoot && oldRoot.isConnected && oldRoot !== next) {
|
||||
if (this.savedWidthVar) oldRoot.style.setProperty('--dsh-chat-user-width', this.savedWidthVar)
|
||||
else oldRoot.style.removeProperty('--dsh-chat-user-width')
|
||||
}
|
||||
this.root = next
|
||||
this.header = header
|
||||
this.header = anchor.header
|
||||
this.viewArea = viewArea
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginRight = viewArea.style.marginRight
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
// 仅锚点元素真正更换时才记录「打开前」原值——同元素重锚(hero→active 头部出现)
|
||||
// 必须保留最初存档,否则关闭时会把已应用的 margin/变量误还原成「新原值」
|
||||
if (oldRoot !== next) {
|
||||
this.savedWidthVar = next.style.getPropertyValue('--dsh-chat-user-width')
|
||||
}
|
||||
if (oldViewArea !== viewArea) {
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginRight = viewArea.style.marginRight
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
}
|
||||
this.observer?.disconnect()
|
||||
this.observer.observe(next)
|
||||
this.refreshGeom()
|
||||
@@ -914,11 +963,12 @@ export const splitStore: SplitState = {
|
||||
|
||||
refreshGeom() {
|
||||
const root = this.root
|
||||
const header = this.header
|
||||
if (!root || !header) return
|
||||
if (!root) return
|
||||
const rr = root.getBoundingClientRect()
|
||||
const hr = header.getBoundingClientRect()
|
||||
this.geom = { left: rr.left, top: hr.bottom, right: rr.right, bottom: rr.bottom }
|
||||
const hr = this.header ? this.header.getBoundingClientRect() : null
|
||||
// 头部隐藏(blank 会话)或无头部(0.1.2 无会话)时以根顶部为分栏上沿
|
||||
const top = hr && hr.height > 0 ? hr.bottom : rr.top
|
||||
this.geom = { left: rr.left, top, right: rr.right, bottom: rr.bottom }
|
||||
},
|
||||
|
||||
applyMargin() {
|
||||
@@ -931,6 +981,11 @@ export const splitStore: SplitState = {
|
||||
const hasLeft = !!spec.left
|
||||
const hasTop = !!(spec.top && spec.top.length > 0)
|
||||
const chatW = clamp(this.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
|
||||
// 会话内容宽 clamp(680px, …, 920px) 不随挤出的聊天列收缩 → 显式钉到挤出的列宽
|
||||
// (close/重锚定时原样恢复;0.1.1 上该 CSS 变量闲置,设置无害)
|
||||
if (this.root && this.root.isConnected) {
|
||||
this.root.style.setProperty('--dsh-chat-user-width', Math.round(chatW) + 'px')
|
||||
}
|
||||
const topH = hasTop
|
||||
? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
|
||||
: 0
|
||||
@@ -1261,6 +1316,10 @@ export const splitStore: SplitState = {
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.root && this.root.isConnected) {
|
||||
if (this.savedWidthVar) this.root.style.setProperty('--dsh-chat-user-width', this.savedWidthVar)
|
||||
else this.root.style.removeProperty('--dsh-chat-user-width')
|
||||
}
|
||||
if (this.viewArea) {
|
||||
this.viewArea.style.marginLeft = this.savedMarginLeft
|
||||
this.viewArea.style.marginRight = this.savedMarginRight
|
||||
@@ -1275,6 +1334,7 @@ export const splitStore: SplitState = {
|
||||
this.root = null
|
||||
this.header = null
|
||||
this.viewArea = null
|
||||
this.savedWidthVar = ''
|
||||
this.geom = null
|
||||
this.spec = null
|
||||
this.active = false
|
||||
|
||||
+83
-4
@@ -7,6 +7,45 @@ import { createRequire } from 'node:module'
|
||||
import { homedir } from 'node:os'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
/**
|
||||
* 基础数据目录解析:不加载任何官方包(loadPkg 的兜底只能用它,禁止反向调用包加载函数——否则成环)。
|
||||
* 规则与官方 @deepseek-ai/dsh-home-paths 的 resolveDshHome 一致:
|
||||
* DSH_HOME 环境变量优先(空/纯空白视为未设置),否则 ~/.dsh;
|
||||
* 支持 ~、~/、~\ 前缀展开;相对路径按进程 cwd 解析;结果归一为绝对路径。
|
||||
* 禁止任何业务代码直接拼 homedir()/.dsh —— 自定义 DSH_HOME(Desktop/隔离测试)会读错数据。
|
||||
*/
|
||||
function baseDshHome(): string {
|
||||
const env = process.env.DSH_HOME
|
||||
// 与官方一致:trim 只用于判断是否全空白,实际路径保留原字符串(两端空格有含义)
|
||||
const value = env !== undefined && env.trim().length > 0 ? env : pathResolve(homedir(), '.dsh')
|
||||
if (value === '~') return homedir()
|
||||
if (value.startsWith('~/') || value.startsWith('~\\')) return pathResolve(homedir(), value.slice(2))
|
||||
return pathResolve(value)
|
||||
}
|
||||
|
||||
/** 解析 DSH 数据根目录:优先官方 @deepseek-ai/dsh-home-paths(显式配置/DSH_HOME/默认),
|
||||
* 不可用或返回非法值时回退 baseDshHome()(同官方规则)。带缓存。 */
|
||||
let cachedDshHome: string | null = null
|
||||
/** 本次解析实际走了哪条路径(供测试断言官方包是否真的被使用) */
|
||||
let dshHomeSource: 'official' | 'fallback' = 'fallback'
|
||||
function resolveDshHomeSafe(): string {
|
||||
if (cachedDshHome) return cachedDshHome
|
||||
try {
|
||||
const pkg = loadPkg('@deepseek-ai/dsh-home-paths') as any
|
||||
if (pkg && typeof pkg.resolveDshHome === 'function') {
|
||||
const home = pkg.resolveDshHome(undefined, process.env)
|
||||
if (typeof home === 'string' && home.trim() !== '') {
|
||||
dshHomeSource = 'official'
|
||||
cachedDshHome = home
|
||||
return cachedDshHome
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
dshHomeSource = 'fallback'
|
||||
cachedDshHome = baseDshHome()
|
||||
return cachedDshHome
|
||||
}
|
||||
|
||||
/**
|
||||
* dsh-worktable 服务端:健康路由 + 工作区内容窗的数据路由。
|
||||
* 参考 dsh-better-sidebar 的架构——内容窗能力由本插件自己的服务端路由提供:
|
||||
@@ -53,6 +92,8 @@ const TEMPLATE_PREFIX = '/api/worktable/template'
|
||||
* 本包经 junction 链接进 profile,普通 import 可能解析不到 profile 级依赖;
|
||||
* 同时尝试 junction 路径与 realpath 两条祖先链。
|
||||
*/
|
||||
/** 依赖探测尝试计数(供测试断言「有界探测、无循环重入」) */
|
||||
let loadProbeAttempts = 0
|
||||
function loadPkg(pkg: string): any | null {
|
||||
const starts = new Set<string>()
|
||||
try { starts.add(dirname(fileURLToPath(import.meta.url))) } catch {}
|
||||
@@ -60,6 +101,7 @@ function loadPkg(pkg: string): any | null {
|
||||
for (const start of starts) {
|
||||
let dir: string | null = start
|
||||
while (dir && dir !== pathResolve(dir, '..')) {
|
||||
loadProbeAttempts++
|
||||
try {
|
||||
const req = createRequire(pathToFileURL(pathResolve(dir, '__wt_probe__.js')).href)
|
||||
return req(pkg)
|
||||
@@ -67,12 +109,13 @@ function loadPkg(pkg: string): any | null {
|
||||
dir = pathResolve(dir, '..')
|
||||
}
|
||||
}
|
||||
// 兜底:DSH 标准目录 ~/.dsh/profiles/*/node_modules(宿主按 realpath 加载时前两条链都找不到)
|
||||
// 兜底:DSH profiles/*/node_modules(按 baseDshHome 解析根目录——不能用 resolveDshHomeSafe,否则与本函数成环)
|
||||
try {
|
||||
const profilesDir = pathResolve(homedir(), '.dsh', 'profiles')
|
||||
const profilesDir = pathResolve(baseDshHome(), 'profiles')
|
||||
for (const profile of readdirSync(profilesDir, { withFileTypes: true })) {
|
||||
if (!profile.isDirectory() && !profile.isSymbolicLink()) continue
|
||||
const nm = pathResolve(profilesDir, profile.name, 'node_modules')
|
||||
loadProbeAttempts++
|
||||
try {
|
||||
const req = createRequire(pathToFileURL(pathResolve(nm, '__wt_probe__.js')).href)
|
||||
return req(pkg)
|
||||
@@ -82,6 +125,11 @@ function loadPkg(pkg: string): any | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 测试钩子:依赖探测尝试次数 + 数据目录解析路径(循环回归与官方路径断言用) */
|
||||
export function __wtLoadProbeStats(): { attempts: number; homeSource: 'official' | 'fallback' } {
|
||||
return { attempts: loadProbeAttempts, homeSource: dshHomeSource }
|
||||
}
|
||||
|
||||
/** 解析会话工作目录:服务端 header.cwd 优先,其次客户端传入 cwd,最后进程 cwd */
|
||||
function serverCwd(ctx: any, sessionId?: string, clientCwd?: string): string {
|
||||
if (sessionId) {
|
||||
@@ -316,13 +364,44 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
// 工作区列表(自定义窗口会话分组用):读宿主 ~/.dsh/storages/workspace.json(只读)
|
||||
// 工作区列表(自定义窗口会话分组用):
|
||||
// 优先走宿主正式服务 ctx.workspaceRegistry(0.1.1/0.1.2 均有,正确感知 DSH_HOME 与存储后端);
|
||||
// 不可用时回退按 resolveDshHomeSafe() 读 storages/workspace.json(只读)。
|
||||
// 返回结构是客户端契约,两种来源都映射成同一 shape。
|
||||
webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/workspaces',
|
||||
handler: async (_req: any, res: any) => {
|
||||
try {
|
||||
const file = pathResolve(homedir(), '.dsh', 'storages', 'workspace.json')
|
||||
// cordis 对未 inject 服务的属性访问会直接 throw(不返回 undefined),必须 try-catch 探测
|
||||
let registry: any = null
|
||||
try { registry = (ctx as any).workspaceRegistry ?? null } catch {}
|
||||
if (!registry) {
|
||||
try { registry = ctx.get?.('workspaceRegistry') ?? null } catch {}
|
||||
}
|
||||
if (registry && typeof registry.list === 'function') {
|
||||
const list = registry.list() ?? []
|
||||
const workspaceIds: string[] = []
|
||||
const tables: Record<string, { title?: string; sessionIds?: string[] }> = {}
|
||||
for (const ws of list) {
|
||||
const id = String(ws?.id ?? '')
|
||||
if (!id) continue
|
||||
workspaceIds.push(id)
|
||||
tables[id] = {
|
||||
title: typeof ws?.title === 'string' ? ws.title : undefined,
|
||||
sessionIds: Array.isArray(ws?.sessionIds) ? ws.sessionIds.map(String) : [],
|
||||
}
|
||||
}
|
||||
let archived: string[] = []
|
||||
try { archived = (registry.archivedSessionIds ?? []).map(String) } catch {}
|
||||
json(res, 200, {
|
||||
unit: { name: 'workspace', version: 2 },
|
||||
global: { initialized: true, workspaceIds, archivedSessionIds: archived },
|
||||
tables: { workspaces: tables },
|
||||
})
|
||||
return
|
||||
}
|
||||
const file = pathResolve(resolveDshHomeSafe(), 'storages', 'workspace.json')
|
||||
const raw = await readFile(file, 'utf8')
|
||||
// 容忍 BOM(外部工具改写可能带 EF BB BF,JSON.parse 会抛错)
|
||||
json(res, 200, JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw))
|
||||
|
||||
Reference in New Issue
Block a user