dsh-sessions-manager 3.7.3 → 3.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +7 -0
- package/README.md +7 -0
- package/lib/client.js +115 -11
- package/lib/client.js.map +2 -2
- package/lib/index.js +116 -9
- package/lib/index.js.map +3 -3
- package/package.json +1 -1
- package/src/active-session.js +87 -0
- package/src/auto-archive.js +9 -2
- package/src/client/index.jsx +132 -10
- package/src/index.js +88 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sessions-manager",
|
|
3
3
|
"description": "DSH 设置面板会话管理器:归档 / 恢复 / 彻底删除 / 移动到其他工作区,带工作区标签与会话日期;统一「会话管理」面板。Session manager for the DeepSeek Harness settings panel — archive / restore / permanently delete / move sessions across workspaces, with workspace tags & session dates in one unified panel.",
|
|
4
|
-
"version": "3.7.
|
|
4
|
+
"version": "3.7.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// 活跃会话(host 侧)追踪。
|
|
2
|
+
//
|
|
3
|
+
// 背景:0.1.7 / 0.2.0 把「当前选中哪个会话」这件事完全放到了客户端——
|
|
4
|
+
// uiWorkspace 持有 mainView 的会话引用,host 进程里根本不存在
|
|
5
|
+
// activeSession / currentSession / sessions.active 任何一个访问器(实测三个
|
|
6
|
+
// 探测全部落空)。而 host 又必须知道「这个会话是不是用户正在看的那个」:
|
|
7
|
+
// - 移动:活跃会话被写锁持有,动盘要排到 DSH 释放之后(409 / 排队)
|
|
8
|
+
// - 自动归档:正在看的会话不该被默默归档掉
|
|
9
|
+
// host 自己证明不了,只能由能看到 UI 状态的客户端心跳上报。
|
|
10
|
+
//
|
|
11
|
+
// 设计取舍:
|
|
12
|
+
// * 按 clientId 分桶——浏览器端与 Desktop 端可能同时连着同一个 host,各自看
|
|
13
|
+
// 的会话不同,只留一份会互相顶掉。
|
|
14
|
+
// * 只采信「新鲜」的心跳(TTL)。客户端面板关闭 / 标签页隐藏 / 进程退出后
|
|
15
|
+
// 心跳停止,过期数据必须失效:宁可漏判(放行移动,移动路径本身有备份+
|
|
16
|
+
// 回滚和写锁兜底),也不拿陈旧数据把用户早已切走的会话一直锁死。
|
|
17
|
+
// * 上报 null/空 = 该客户端当前没有活跃会话,删掉它的桶(而不是留旧值)。
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_ACTIVE_TTL_MS = 45 * 1000
|
|
20
|
+
// 桶数量上限:clientId 由客户端生成,理论无限。超过就先清过期的,再清最旧的,
|
|
21
|
+
// 防止异常客户端(每次刷新换 id)把 Map 撑大。
|
|
22
|
+
export const MAX_ACTIVE_REPORTS = 32
|
|
23
|
+
|
|
24
|
+
// 与 host 侧会话 id 校验同源(src/index.js isSafeSessionId):会话 id 会参与
|
|
25
|
+
// 日志路径拼接,含路径分隔符的脏数据必须挡在外面。
|
|
26
|
+
function isUsableSessionId(value) {
|
|
27
|
+
return typeof value === 'string'
|
|
28
|
+
&& value.length > 0
|
|
29
|
+
&& value.length <= 200
|
|
30
|
+
&& !/[\\/\0]/.test(value)
|
|
31
|
+
&& value !== '.'
|
|
32
|
+
&& value !== '..'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createActiveSessionTracker(options = {}) {
|
|
36
|
+
const ttl = Number.isFinite(options.ttlMs) ? Number(options.ttlMs) : DEFAULT_ACTIVE_TTL_MS
|
|
37
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
38
|
+
/** @type {Map<string, {id: string, at: number}>} */
|
|
39
|
+
const reports = new Map()
|
|
40
|
+
|
|
41
|
+
function trim(at) {
|
|
42
|
+
for (const [key, value] of reports) if (at - value.at > ttl) reports.delete(key)
|
|
43
|
+
while (reports.size > MAX_ACTIVE_REPORTS) {
|
|
44
|
+
const oldest = reports.keys().next().value
|
|
45
|
+
if (oldest === undefined) break
|
|
46
|
+
reports.delete(oldest)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record one client's currently-open session.
|
|
52
|
+
* @param {string} clientId
|
|
53
|
+
* @param {string|null} sessionId - null clears this client's slot.
|
|
54
|
+
* @returns {boolean} whether it was accepted.
|
|
55
|
+
*/
|
|
56
|
+
function report(clientId, sessionId, at = now()) {
|
|
57
|
+
const key = typeof clientId === 'string' && clientId ? clientId.slice(0, 64) : 'default'
|
|
58
|
+
if (sessionId === null || sessionId === undefined || sessionId === '') {
|
|
59
|
+
reports.delete(key)
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
if (!isUsableSessionId(sessionId)) return false
|
|
63
|
+
reports.set(key, { id: String(sessionId), at })
|
|
64
|
+
trim(at)
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Freshly reported active session ids (any connected client).
|
|
70
|
+
* @returns {Set<string>}
|
|
71
|
+
*/
|
|
72
|
+
function activeIds(at = now()) {
|
|
73
|
+
const out = new Set()
|
|
74
|
+
for (const [key, value] of reports) {
|
|
75
|
+
if (at - value.at > ttl) { reports.delete(key); continue }
|
|
76
|
+
out.add(value.id)
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
report,
|
|
83
|
+
activeIds,
|
|
84
|
+
get size() { return reports.size },
|
|
85
|
+
clear() { reports.clear() },
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/auto-archive.js
CHANGED
|
@@ -62,6 +62,9 @@ export function normalizeAutoArchiveStore(raw) {
|
|
|
62
62
|
* @param {number} [options.now] - Reference timestamp (tests inject it).
|
|
63
63
|
* @param {boolean} [options.skipStarred=true] - Keep starred sessions.
|
|
64
64
|
* @param {string|null} [options.activeSessionId] - Never archive the open one.
|
|
65
|
+
* @param {Array<string>} [options.activeSessionIds] - Same, for several clients
|
|
66
|
+
* (browser + desktop can each hold their own open session). Takes precedence
|
|
67
|
+
* over `activeSessionId` when given.
|
|
65
68
|
* @returns {string[]} Session ids to archive.
|
|
66
69
|
*/
|
|
67
70
|
export function pickInactiveCandidates(items, options = {}) {
|
|
@@ -70,7 +73,11 @@ export function pickInactiveCandidates(items, options = {}) {
|
|
|
70
73
|
const now = Number.isFinite(options.now) ? options.now : Date.now()
|
|
71
74
|
const cutoff = now - days * DAY_MS
|
|
72
75
|
const skipStarred = options.skipStarred !== false
|
|
73
|
-
const
|
|
76
|
+
const activeIds = new Set(
|
|
77
|
+
Array.isArray(options.activeSessionIds)
|
|
78
|
+
? options.activeSessionIds.map((id) => String(id))
|
|
79
|
+
: options.activeSessionId != null ? [String(options.activeSessionId)] : [],
|
|
80
|
+
)
|
|
74
81
|
const list = Array.isArray(items) ? items : []
|
|
75
82
|
|
|
76
83
|
const out = []
|
|
@@ -81,7 +88,7 @@ export function pickInactiveCandidates(items, options = {}) {
|
|
|
81
88
|
if (seen.has(id)) continue
|
|
82
89
|
if (item.archived) continue
|
|
83
90
|
if (skipStarred && item.starred) continue
|
|
84
|
-
if (
|
|
91
|
+
if (activeIds.has(id)) continue
|
|
85
92
|
const updatedAt = Number(item.updatedAt)
|
|
86
93
|
// No usable timestamp → cannot prove it is idle → leave it alone.
|
|
87
94
|
if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue
|
package/src/client/index.jsx
CHANGED
|
@@ -24,7 +24,8 @@ const CSS = `
|
|
|
24
24
|
.archv{--dsm-radius-tag:9px;--dsm-radius-ctl:9px;--dsm-radius-sheet:10px;--dsm-radius-card:12px;display:flex;flex-direction:column;gap:4px;max-width:800px;padding:8px 2px 28px}
|
|
25
25
|
.archv-head{display:flex;align-items:center;gap:10px;margin:0 0 2px}
|
|
26
26
|
.archv-title{font-size:16px;font-weight:650;color:var(--dsw-alias-label-primary);letter-spacing:-0.01em;margin:0}
|
|
27
|
-
.archv-stamp{font-size:11px;font-weight:500;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:2px 8px;white-space:nowrap;cursor:
|
|
27
|
+
.archv-stamp{font-size:11px;font-weight:500;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:2px 8px;white-space:nowrap;cursor:pointer;transition:color .15s ease,border-color .15s ease}
|
|
28
|
+
.archv-stamp:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
|
|
28
29
|
.archv-count{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 8px;flex:none}
|
|
29
30
|
.archv-sub{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-tertiary);margin:0 0 12px;max-width:64ch}
|
|
30
31
|
.archv-err{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);border-radius:var(--dsm-radius-sheet);color:var(--dsw-alias-state-error-primary);font-size:12px;margin-bottom:10px}
|
|
@@ -49,8 +50,11 @@ const CSS = `
|
|
|
49
50
|
.sess-fbtn-on{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
|
|
50
51
|
.sess-tools{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin:0 0 8px}
|
|
51
52
|
.sess-tools-4{grid-template-columns:repeat(auto-fit,minmax(132px,1fr))}
|
|
52
|
-
/* T4 起搜索行有 5
|
|
53
|
-
|
|
53
|
+
/* T4 起搜索行有 5 个字段(搜索/工作区/标签/排序/分组):固定 5 列 + minmax(0,1fr)
|
|
54
|
+
一行排满——auto-fit(minmax(124px,…)) 在面板宽度不足 5×124+gap 时会把最后
|
|
55
|
+
一列挤到第二行(分组孤行)。minmax(0,1fr) 允许列均分收缩,select/input
|
|
56
|
+
各自截断;<640px 仍由下方断点收成单列。 */
|
|
57
|
+
.sess-tools-5{grid-template-columns:repeat(5,minmax(0,1fr))}
|
|
54
58
|
.dsm-kids{display:flex;flex-direction:column;gap:6px;margin:10px 0 2px;margin-left:43px;padding-left:10px;border-left:2px solid var(--dsw-alias-border-l3)}
|
|
55
59
|
.dsm-kid .dsm-kids{margin-left:8px;margin-top:6px}
|
|
56
60
|
.dsm-kids-toggle{appearance:none;min-height:22px;padding:0 9px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary);border-radius:var(--dsm-radius-tag);font:inherit;font-size:11px;font-weight:500;cursor:pointer;flex:none;white-space:nowrap}
|
|
@@ -336,6 +340,73 @@ async function dsmOpenSessionById(childId, directParentId) {
|
|
|
336
340
|
return 'failed'
|
|
337
341
|
}
|
|
338
342
|
|
|
343
|
+
// ---- 活跃会话上报:host 判定「用户正在看哪个会话」的唯一来源 ----------------
|
|
344
|
+
//
|
|
345
|
+
// 0.1.7+ / 0.2.x 的 host 进程里没有 activeSession / currentSession /
|
|
346
|
+
// sessions.active 任何一个访问器(实测三个探测全部落空),它根本无从判断用户在
|
|
347
|
+
// 看哪个会话——但必须判断,否则两条保护形同虚设:
|
|
348
|
+
// * 移动:活跃会话被写锁持有,硬动盘要排队(409 / queued)
|
|
349
|
+
// * 自动归档:正在看的会话不该被默默归档掉
|
|
350
|
+
// 能看到 UI 状态的是客户端,所以由它周期性上报;host 只在心跳新鲜期内采信
|
|
351
|
+
// (见 src/active-session.js 的 TTL 说明:过期即失效,宁可漏判也不误判)。
|
|
352
|
+
//
|
|
353
|
+
// 探测顺序:uiWorkspace 的 selection 快照(持久化在 dsh.sessions.current,即
|
|
354
|
+
// 启动时恢复的那个选中项,最权威)→ uiWorkspace 的 mainView 引用(mainReference)
|
|
355
|
+
// → 旧核心 sessions.list.getSnapshot().current。
|
|
356
|
+
// 前两者是客户端服务对象上的字段,全部 try/catch 包裹:上游改名/重构只会退化成
|
|
357
|
+
// 「探测不到」(放行移动,移动路径本身有写锁 + 备份回滚兜底),不会抛错。
|
|
358
|
+
const DSM_CLIENT_ID = (() => {
|
|
359
|
+
try {
|
|
360
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
|
361
|
+
} catch (e) { /* 非安全上下文没有 randomUUID */ }
|
|
362
|
+
return `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
|
363
|
+
})()
|
|
364
|
+
|
|
365
|
+
export function dsmActiveSessionId() {
|
|
366
|
+
try {
|
|
367
|
+
if (!dsmClientCtx || typeof dsmClientCtx.get !== 'function') return null
|
|
368
|
+
const ui = dsmClientCtx.get('uiWorkspace')
|
|
369
|
+
if (ui) {
|
|
370
|
+
const snap = ui.selection && typeof ui.selection.getSnapshot === 'function' ? ui.selection.getSnapshot() : null
|
|
371
|
+
if (snap && snap.sessionId != null) return String(snap.sessionId)
|
|
372
|
+
const ref = ui.mainReference
|
|
373
|
+
if (ref && ref.sessionId != null) return String(ref.sessionId)
|
|
374
|
+
}
|
|
375
|
+
const native = dsmClientCtx.get('sessions')
|
|
376
|
+
if (native && native.list && typeof native.list.getSnapshot === 'function') {
|
|
377
|
+
const snap = native.list.getSnapshot()
|
|
378
|
+
if (snap && snap.current != null) return String(snap.current)
|
|
379
|
+
}
|
|
380
|
+
} catch (e) { /* 服务不可用/字段不存在 */ }
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const DSM_ACTIVE_HEARTBEAT_MS = 15000
|
|
385
|
+
const DSM_ACTIVE_MIN_REPORT_MS = 30000
|
|
386
|
+
let dsmLastActiveReport = { id: null, at: 0 }
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 上报当前会话。心跳(force=false)在「值没变 + 距上次不足 30s」时跳过网络请求;
|
|
390
|
+
* force=true 用于移动前强制同步——用户在心跳间隔内刚切过会话时,host 手里还是旧值。
|
|
391
|
+
*/
|
|
392
|
+
async function reportActiveSession(force) {
|
|
393
|
+
const id = dsmActiveSessionId()
|
|
394
|
+
const now = Date.now()
|
|
395
|
+
if (!force && id === dsmLastActiveReport.id && now - dsmLastActiveReport.at < DSM_ACTIVE_MIN_REPORT_MS) return
|
|
396
|
+
dsmLastActiveReport = { id, at: now }
|
|
397
|
+
try {
|
|
398
|
+
await postJSON('/archived-sessions/active-session', { clientId: DSM_CLIENT_ID, sessionId: id })
|
|
399
|
+
} catch (e) { /* host 未就绪 / 路由缺失:静默,下个心跳再试 */ }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 移动类请求:先同步一次活跃会话,再发真正的移动请求。否则 host 拿旧值放行,
|
|
403
|
+
// 移动中的会话恰好是用户刚切过去的那个(写锁持有 → 只能排队,UI 上表现为「排队」
|
|
404
|
+
// 而不是「已移动」——不算错,但用户点之前本可以选别的会话)。
|
|
405
|
+
async function postActiveAware(path, body) {
|
|
406
|
+
await reportActiveSession(true).catch(() => null)
|
|
407
|
+
return postJSON(path, body)
|
|
408
|
+
}
|
|
409
|
+
|
|
339
410
|
// Left-nav icon swap: DSH's settings shell renders a shared fallback gear for
|
|
340
411
|
// every custom section and settings.section has no per-section icon field.
|
|
341
412
|
// Like dsh-better-sidebar, we mark our own row by matching its visible label
|
|
@@ -819,6 +890,37 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
819
890
|
timer.current = setTimeout(() => setToast(null), toastDurationFor(msg, kind))
|
|
820
891
|
}
|
|
821
892
|
|
|
893
|
+
// 版本徽章点击复制完整 buildStamp。webview 里 navigator.clipboard 可能缺失
|
|
894
|
+
// 或被权限拒绝(此前失败被静默吞掉,用户以为按钮坏了)——失败落
|
|
895
|
+
// execCommand 兜底;两条路都不通时至少把完整版本号亮在 toast 里。
|
|
896
|
+
const copyBuildStamp = async () => {
|
|
897
|
+
const full = String(capabilities && capabilities.buildStamp || '')
|
|
898
|
+
if (!full) return
|
|
899
|
+
try {
|
|
900
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
901
|
+
await navigator.clipboard.writeText(full)
|
|
902
|
+
showToast('已复制完整版本号 ' + full, 'ok')
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
throw new Error('clipboard unavailable')
|
|
906
|
+
} catch (e) {
|
|
907
|
+
try {
|
|
908
|
+
const ta = document.createElement('textarea')
|
|
909
|
+
ta.value = full
|
|
910
|
+
ta.style.position = 'fixed'
|
|
911
|
+
ta.style.opacity = '0'
|
|
912
|
+
document.body.appendChild(ta)
|
|
913
|
+
ta.select()
|
|
914
|
+
const ok = document.execCommand('copy')
|
|
915
|
+
document.body.removeChild(ta)
|
|
916
|
+
if (!ok) throw new Error('execCommand failed')
|
|
917
|
+
showToast('已复制完整版本号 ' + full, 'ok')
|
|
918
|
+
} catch (e2) {
|
|
919
|
+
showToast('复制失败,完整版本号:' + full, 'err')
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
822
924
|
const refresh = () => {
|
|
823
925
|
setError(null)
|
|
824
926
|
Promise.all([
|
|
@@ -1320,7 +1422,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1320
1422
|
if (!targetPath) { setError('请选择已有工作区或输入新的目标目录路径'); return }
|
|
1321
1423
|
setBusy(it.sessionId)
|
|
1322
1424
|
setError(null)
|
|
1323
|
-
|
|
1425
|
+
postActiveAware('/archived-sessions/move', { sessionId: it.sessionId, targetPath })
|
|
1324
1426
|
.then((r) => {
|
|
1325
1427
|
setBusy(null)
|
|
1326
1428
|
setOpenMove(null)
|
|
@@ -1380,7 +1482,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1380
1482
|
setBusy('__batch__')
|
|
1381
1483
|
setError(null)
|
|
1382
1484
|
setRetry(null)
|
|
1383
|
-
|
|
1485
|
+
postActiveAware('/archived-sessions/move-many', { sessionIds: ids, targetPath })
|
|
1384
1486
|
.then((r) => {
|
|
1385
1487
|
setBusy(null)
|
|
1386
1488
|
setBatchMoveOpen(false)
|
|
@@ -1541,7 +1643,13 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1541
1643
|
<div className="archv-head">
|
|
1542
1644
|
<h2 className="archv-title">会话管理</h2>
|
|
1543
1645
|
{capabilities && capabilities.buildStamp
|
|
1544
|
-
? <span
|
|
1646
|
+
? <span
|
|
1647
|
+
className="archv-stamp"
|
|
1648
|
+
role="button"
|
|
1649
|
+
aria-label="复制完整版本号"
|
|
1650
|
+
title={capabilities.buildStamp}
|
|
1651
|
+
onClick={copyBuildStamp}
|
|
1652
|
+
>v{String(capabilities.buildStamp).split('+')[0]}</span>
|
|
1545
1653
|
: null}
|
|
1546
1654
|
{sessions !== null && <span className="archv-count" aria-label={`${sessions.length} 个会话`}>{sessions.length}</span>}
|
|
1547
1655
|
</div>
|
|
@@ -1828,8 +1936,8 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
1828
1936
|
<div className="sess-field">
|
|
1829
1937
|
<label htmlFor="dsm-group">分组</label>
|
|
1830
1938
|
<select id="dsm-group" value={groupByLineage ? 'lineage' : 'flat'} onChange={(e) => setGroupByLineage(e.target.value === 'lineage')} title="血缘分组:子代理折叠、分支聚拢成组;平铺:与 DSH 原生一致,全部并列">
|
|
1831
|
-
<option value="lineage"
|
|
1832
|
-
<option value="flat"
|
|
1939
|
+
<option value="lineage">血缘(折叠)</option>
|
|
1940
|
+
<option value="flat">平铺(并列)</option>
|
|
1833
1941
|
</select>
|
|
1834
1942
|
</div>
|
|
1835
1943
|
</div>
|
|
@@ -2756,7 +2864,7 @@ function installSidebarSessionMenuAug() {
|
|
|
2756
2864
|
e.preventDefault()
|
|
2757
2865
|
closeSub()
|
|
2758
2866
|
closeMenu()
|
|
2759
|
-
|
|
2867
|
+
postActiveAware('/archived-sessions/move', { sessionId: info.id, targetPath: w.path })
|
|
2760
2868
|
.then((r) => {
|
|
2761
2869
|
// 会话被 DSH 打开(活跃/未关闭)时服务端只登记排队:磁盘与工作区分组都没动。
|
|
2762
2870
|
// 这条路径原先无条件报「移动成功」——2026-09-10 用户报的「说移动成功但还在
|
|
@@ -3501,7 +3609,7 @@ function installSidebarWorkspaceDrag() {
|
|
|
3501
3609
|
clearVisuals()
|
|
3502
3610
|
row.classList.add('dsm-drag-busy')
|
|
3503
3611
|
try {
|
|
3504
|
-
const result = await
|
|
3612
|
+
const result = await postActiveAware('/archived-sessions/move', { sessionId: item.sessionId, targetPath: target.path })
|
|
3505
3613
|
// 会话被 DSH 打开时服务端只登记排队:磁盘与工作区分组都没动。这条路径绝不能
|
|
3506
3614
|
// 本地改行 + 报成功——那会让用户以为移动已完成,而侧栏刷新后该行又跳回原工作区
|
|
3507
3615
|
//(用户 2026-09-10 报的「说移动成功但还在原工作区」就是这个)。排队时只如实说明。
|
|
@@ -3567,6 +3675,20 @@ function installSidebarWorkspaceDrag() {
|
|
|
3567
3675
|
export function apply(ctx) {
|
|
3568
3676
|
dsmClientCtx = ctx
|
|
3569
3677
|
dsmLoadCapabilities()
|
|
3678
|
+
// 活跃会话心跳:页面隐藏时不发(用户没在看;host 侧 TTL 到期后自然失效,
|
|
3679
|
+
// 于是「无法证明活跃」→ 放行移动,不会把会话永久锁死)。
|
|
3680
|
+
const beat = () => {
|
|
3681
|
+
if (typeof document !== 'undefined' && document.hidden) return
|
|
3682
|
+
reportActiveSession(false).catch(() => {})
|
|
3683
|
+
}
|
|
3684
|
+
beat()
|
|
3685
|
+
const beatTimer = setInterval(beat, DSM_ACTIVE_HEARTBEAT_MS)
|
|
3686
|
+
const beatAc = typeof AbortController !== 'undefined' ? new AbortController() : null
|
|
3687
|
+
if (beatAc && typeof document !== 'undefined' && document.addEventListener) {
|
|
3688
|
+
// 标签页重新可见:立刻补一次(切回来后第一件事就可能是移动)。
|
|
3689
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) beat() }, { signal: beatAc.signal })
|
|
3690
|
+
}
|
|
3691
|
+
ctx.effect(() => () => { clearInterval(beatTimer); if (beatAc) beatAc.abort() })
|
|
3570
3692
|
installSettingsNavIcons(ctx)
|
|
3571
3693
|
installSidebarSessionMenuAug()
|
|
3572
3694
|
// 侧栏增强的卸载清退挂到 cordis effect:插件停用/HMR 时 interval、
|
package/src/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.j
|
|
|
24
24
|
import { createSessionMetaCache, fingerprintOf, persistFingerprintOf } from './session-meta-cache.js'
|
|
25
25
|
import { createTitleIndexStore } from './title-persist-index.js'
|
|
26
26
|
import { createEmptyScanStore } from './empty-scan-index.js'
|
|
27
|
+
import { createActiveSessionTracker } from './active-session.js'
|
|
27
28
|
import { createPersistenceAdapter } from './compat/persistence.js'
|
|
28
29
|
import { detectCapabilities, requireCapability } from './compat/capabilities.js'
|
|
29
30
|
import { pathOwnsSession } from './path-guard.js'
|
|
@@ -124,7 +125,12 @@ function requireSessionId(value) {
|
|
|
124
125
|
// few known accessors for the active id; if none is available we return null
|
|
125
126
|
// and callers should treat the session as movable (the move path is
|
|
126
127
|
// crash-safe via backup+rollback and re-syncs the live object afterwards).
|
|
127
|
-
|
|
128
|
+
//
|
|
129
|
+
// 这些访问器只在旧核心(≤0.1.6)里存在。0.1.7+ / 0.2.x 把「当前选中哪个会话」
|
|
130
|
+
// 完全移到了客户端(uiWorkspace 持 mainView 引用),host 侧三个探测全部落空 →
|
|
131
|
+
// 恒 null。所以真正的判定入口是下面的 getActiveSessionIds(客户端心跳优先),
|
|
132
|
+
// 这个函数退化为旧核心的兜底探测。
|
|
133
|
+
function legacyActiveSessionId(context) {
|
|
128
134
|
try {
|
|
129
135
|
const a = context.get('activeSession')
|
|
130
136
|
if (a != null) return (a && a.id != null) ? a.id : (typeof a === 'string' ? a : null)
|
|
@@ -140,6 +146,15 @@ function getActiveSessionId(context) {
|
|
|
140
146
|
return null
|
|
141
147
|
}
|
|
142
148
|
|
|
149
|
+
// 活跃会话判定:优先采信客户端心跳(0.1.7+ 唯一可信来源),没有心跳才退回旧核心
|
|
150
|
+
// 访问器探测。心跳过期/没有客户端连接 → 空集合,调用方按「无法证明活跃」处理。
|
|
151
|
+
function getActiveSessionIds(context, tracker) {
|
|
152
|
+
const reported = tracker ? tracker.activeIds() : null
|
|
153
|
+
if (reported && reported.size) return reported
|
|
154
|
+
const legacy = legacyActiveSessionId(context)
|
|
155
|
+
return legacy != null ? new Set([String(legacy)]) : new Set()
|
|
156
|
+
}
|
|
157
|
+
|
|
143
158
|
// foldTitle(v3.6.1 删除路径的兜底折叠)随 v3.6.2 #7 的「删除路径零解码」一并
|
|
144
159
|
// 移除;标题折叠逻辑的权威实现现在是 runtime 投影(src/index.js projectTitlesStatus)。
|
|
145
160
|
|
|
@@ -162,6 +177,9 @@ export function apply(ctx) {
|
|
|
162
177
|
// T1(3.7.0):空白精判结论的持久层。只有**真解码成功**的判定落盘(unknown
|
|
163
178
|
// 与失败兜底都不是结论),sz 指纹 + 14 天 TTL 双闸门防「同尺寸原地改写」。
|
|
164
179
|
const emptyIndex = createEmptyScanStore({ dir: TRASH_DIR })
|
|
180
|
+
// 活跃会话(客户端心跳上报,见 src/active-session.js):0.1.7+ host 侧无法
|
|
181
|
+
// 自行判断「用户正在看哪个会话」,靠客户端周期性上报。过期即失效。
|
|
182
|
+
const activeTracker = createActiveSessionTracker()
|
|
165
183
|
const EMPTY_VERDICT_TTL_MS = 14 * 24 * 3600 * 1000
|
|
166
184
|
|
|
167
185
|
// P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
|
|
@@ -233,6 +251,31 @@ export function apply(ctx) {
|
|
|
233
251
|
return result
|
|
234
252
|
}
|
|
235
253
|
|
|
254
|
+
// —— 0.2.0+ 官方 title 投影快路径(2026-09-29 审计 P2)————————————————
|
|
255
|
+
// dsh-session-title(0.2.0 起 dsh-base 默认组合挂载)把标题折叠官方化为
|
|
256
|
+
// O(1) 投影(key='title',state 为 string|null),注册表对已 materialize 的
|
|
257
|
+
// cell 提供 cachedSnapshot——只读 watermark 缓存,**零折叠、零磁盘 I/O**
|
|
258
|
+
// (对比 readTitleSnapshots 每条整本解码,issue #8)。命中即免预热。
|
|
259
|
+
// 红线:
|
|
260
|
+
// 1. 只信 cachedSnapshot(绝不调 stateOf——那会对内存日志做同步折叠,
|
|
261
|
+
// 违反「请求路径零重活」纪律);
|
|
262
|
+
// 2. 服务不存在(0.1.7)/会话未实例化/任何异常 → 静默返回 null,行为与
|
|
263
|
+
// 3.7.4 完全一致;
|
|
264
|
+
// 3. cachedSnapshot 的 title=null 只说明「该 cell 还没有非空标题」(可能
|
|
265
|
+
// 会话从未打开、cell 未折叠)——一律当**未知**交回预热队列,绝不冒充
|
|
266
|
+
// 「会话真的无标题」(empty 三态纪律,v3.7.0 T1)。
|
|
267
|
+
function projectionTitleOf(liveStore, id) {
|
|
268
|
+
try {
|
|
269
|
+
const reg = ctx.get('sessionProjections')
|
|
270
|
+
if (!reg || typeof reg.cachedSnapshot !== 'function') return null
|
|
271
|
+
const session = liveStore && liveStore.get && liveStore.get(id)
|
|
272
|
+
if (!session) return null
|
|
273
|
+
const snap = reg.cachedSnapshot(session, ['title'])
|
|
274
|
+
const title = snap && snap.values ? snap.values.title : null
|
|
275
|
+
return typeof title === 'string' && title.length > 0 ? title : null
|
|
276
|
+
} catch (e) { return null }
|
|
277
|
+
}
|
|
278
|
+
|
|
236
279
|
async function archivedState() {
|
|
237
280
|
const d = dom()
|
|
238
281
|
if (!d) throw new Error('workspace domain is not open')
|
|
@@ -960,8 +1003,9 @@ export function apply(ctx) {
|
|
|
960
1003
|
// merely looked at. When the host exposes no active-session accessor we
|
|
961
1004
|
// can't prove activeness, so we allow the move; the relocation below is
|
|
962
1005
|
// crash-safe (backup + rollback) and re-syncs the live object.
|
|
963
|
-
const
|
|
964
|
-
|
|
1006
|
+
const activeIds = getActiveSessionIds(ctx, activeTracker)
|
|
1007
|
+
const activeId = activeIds.values().next().value != null ? activeIds.values().next().value : null
|
|
1008
|
+
if (activeIds.has(String(sid)) || (activeId != null && String(activeId) === String(sid))) {
|
|
965
1009
|
const error = new Error('会话正被 DSH 打开,暂时无法移动;请重启 DSH 后再试。')
|
|
966
1010
|
error.status = 409
|
|
967
1011
|
error.code = 'DSM_SESSION_BUSY'
|
|
@@ -1459,7 +1503,26 @@ export function apply(ctx) {
|
|
|
1459
1503
|
createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
|
|
1460
1504
|
})
|
|
1461
1505
|
}
|
|
1462
|
-
const
|
|
1506
|
+
const stillMissing0 = missing.filter((id) => !persisted.has(id))
|
|
1507
|
+
// 0.2.0+ 官方投影快路径:先把「已实例化且 cell 已 materialize」的会话从
|
|
1508
|
+
// 预热队列里捞回来(cachedSnapshot 零 I/O),剩下的才进后台预热。
|
|
1509
|
+
// 注意:metaCache.set 在指纹不可算(无 stat / revision 缺失)时**拒绝写入**,
|
|
1510
|
+
// 所以命中结果另存 projectionMeta,本请求的渲染链直接可用——缓存只是增益。
|
|
1511
|
+
const projectionMeta = new Map()
|
|
1512
|
+
const stillMissing = []
|
|
1513
|
+
for (const id of stillMissing0) {
|
|
1514
|
+
const title = projectionTitleOf(live, id)
|
|
1515
|
+
if (title == null) { stillMissing.push(id); continue }
|
|
1516
|
+
const entry = entryById.get(id)
|
|
1517
|
+
const header = entry && entry.header ? entry.header : null
|
|
1518
|
+
const meta = {
|
|
1519
|
+
title,
|
|
1520
|
+
cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : null,
|
|
1521
|
+
createdAt: (header && header.createdAt != null) ? header.createdAt : null,
|
|
1522
|
+
}
|
|
1523
|
+
projectionMeta.set(id, meta)
|
|
1524
|
+
metaCache.set(id, statsById.get(id), meta)
|
|
1525
|
+
}
|
|
1463
1526
|
// issue #8:缺失部分交后台预热(分批 + 让步),请求路径零投影、零日志解码。
|
|
1464
1527
|
if (stillMissing.length) enqueueWarm(stillMissing, statsById, entryById)
|
|
1465
1528
|
const items = []
|
|
@@ -1468,7 +1531,7 @@ export function apply(ctx) {
|
|
|
1468
1531
|
// metaCache(persisted.get 里的 cwd 可能因移动会话而陈旧)。
|
|
1469
1532
|
// 完全未命中的会话也不能丢 cwd/createdAt——它们来自 list header,零成本
|
|
1470
1533
|
// 且是工作区归属/创建时间的权威来源;只有标题允许占位等后台预热(issue #8)。
|
|
1471
|
-
let meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
|
|
1534
|
+
let meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || projectionMeta.get(id) || null
|
|
1472
1535
|
if (!meta) {
|
|
1473
1536
|
meta = { title: null, cwd: null, createdAt: null }
|
|
1474
1537
|
const entry = entryById.get(id)
|
|
@@ -1559,7 +1622,7 @@ export function apply(ctx) {
|
|
|
1559
1622
|
const candidates = pickInactiveCandidates(items, {
|
|
1560
1623
|
inactiveDays: days,
|
|
1561
1624
|
skipStarred: store.settings.skipStarred,
|
|
1562
|
-
|
|
1625
|
+
activeSessionIds: [...getActiveSessionIds(ctx, activeTracker)],
|
|
1563
1626
|
now,
|
|
1564
1627
|
})
|
|
1565
1628
|
let archived = 0
|
|
@@ -1906,6 +1969,25 @@ export function apply(ctx) {
|
|
|
1906
1969
|
handler: async (req, res) => json(res, { ...capabilities, buildStamp: BUILD_STAMP }),
|
|
1907
1970
|
}))
|
|
1908
1971
|
|
|
1972
|
+
// 活跃会话心跳(客户端 → host)。0.1.7+ 起 host 无从得知 UI 当前选中了哪个
|
|
1973
|
+
// 会话(见 src/active-session.js),只能由客户端上报;host 只在心跳新鲜期内
|
|
1974
|
+
// 采信,用它拦住「移动正在看的会话」和「自动归档正在看的会话」。
|
|
1975
|
+
// body: { clientId, sessionId|null }。sessionId 为空 = 该客户端当前无活跃会话。
|
|
1976
|
+
disposers.push(ctx.webServer.register({
|
|
1977
|
+
kind: 'exact',
|
|
1978
|
+
path: '/archived-sessions/active-session',
|
|
1979
|
+
handler: async (req, res) => {
|
|
1980
|
+
const body = await readJsonBody(req)
|
|
1981
|
+
const clientId = body && typeof body.clientId === 'string' ? body.clientId : 'default'
|
|
1982
|
+
const sid = body && typeof body.sessionId === 'string' && body.sessionId ? body.sessionId : null
|
|
1983
|
+
const accepted = activeTracker.report(clientId, sid)
|
|
1984
|
+
json(res, {
|
|
1985
|
+
ok: accepted,
|
|
1986
|
+
activeSessionIds: [...activeTracker.activeIds()],
|
|
1987
|
+
})
|
|
1988
|
+
},
|
|
1989
|
+
}))
|
|
1990
|
+
|
|
1909
1991
|
disposers.push(ctx.webServer.register({
|
|
1910
1992
|
kind: 'exact',
|
|
1911
1993
|
path: '/archived-sessions/list',
|