dsh-manage-session 0.4.8 → 0.5.3
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/CHANGELOG.md +36 -0
- package/README.en.md +4 -0
- package/README.md +4 -0
- package/cordis.patch.yml +6 -1
- package/dsh.plugin.json +22 -22
- package/lib/client.js +3848 -202
- package/lib/index.d.ts +759 -29
- package/lib/index.js +2604 -93
- package/locale/de.json +6 -0
- package/locale/en.json +6 -0
- package/locale/es.json +6 -0
- package/locale/fr.json +6 -0
- package/locale/it.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/ko.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +43 -32
- package/src/client/bridge-scope.ts +213 -0
- package/src/client/index.ts +154 -26
- package/src/client/locales.ts +857 -223
- package/src/client/panel.tsx +139 -118
- package/src/client/search/card.tsx +563 -0
- package/src/client/search/host-api.ts +208 -0
- package/src/client/search/locales.ts +780 -0
- package/src/client/search/manage.tsx +400 -0
- package/src/client/search/panel.tsx +815 -0
- package/src/client/search/platform.ts +136 -0
- package/src/config.ts +54 -5
- package/src/host/history/archive-source.ts +48 -0
- package/src/host/history/archive.ts +56 -1
- package/src/host/history/favorites.ts +71 -0
- package/src/host/index/archive-source.ts +90 -0
- package/src/host/index/engine.ts +571 -0
- package/src/host/index/extract.ts +272 -0
- package/src/host/index/peers.ts +62 -0
- package/src/host/index/rebuild.ts +368 -0
- package/src/host/index/schema.ts +136 -0
- package/src/host/index/snapshot.ts +135 -0
- package/src/host/index/sync.ts +286 -0
- package/src/host/registry/archive-registry.ts +317 -0
- package/src/host/settings-bridge.ts +237 -0
- package/src/index.ts +1421 -532
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform detection and the keyboard-label vocabulary, for the client half.
|
|
3
|
+
*
|
|
4
|
+
* Why this module exists
|
|
5
|
+
* ----------------------
|
|
6
|
+
* A shortcut hint is the one place where a single hard-coded string is wrong
|
|
7
|
+
* for most users: `⌘K` is meaningless on Windows, `Ctrl K` is wrong on macOS.
|
|
8
|
+
* The obvious implementation — one `navigator.platform` test — is also the
|
|
9
|
+
* fragile one: that API is deprecated, and it returns an empty string in some
|
|
10
|
+
* privacy configurations, which silently degrades every Mac user to the
|
|
11
|
+
* Windows vocabulary without any error to notice.
|
|
12
|
+
*
|
|
13
|
+
* This module is the single owner of both the platform answer and every label
|
|
14
|
+
* derived from it. The hint rendered in the sidebar, the hint rendered in the
|
|
15
|
+
* panel footer, and the chord the keydown handler actually matches all read
|
|
16
|
+
* from here — so a platform fix cannot land in one of them and miss another,
|
|
17
|
+
* and the advertised key cannot drift away from the bound key.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The navigator fields the detection reads (all optional, all untrusted). */
|
|
21
|
+
export interface NavigatorLike {
|
|
22
|
+
userAgentData?: { platform?: unknown }
|
|
23
|
+
platform?: unknown
|
|
24
|
+
userAgent?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the running platform string, three tiers deep.
|
|
29
|
+
*
|
|
30
|
+
* 1. UA-CH (`navigator.userAgentData.platform`) — modern and precise.
|
|
31
|
+
* 2. `navigator.platform` — broadly available, deprecated, and empty in some
|
|
32
|
+
* privacy modes.
|
|
33
|
+
* 3. The UA string — last resort, still non-empty in practice.
|
|
34
|
+
*
|
|
35
|
+
* @param nav - the navigator to read; `undefined` is allowed (non-DOM host).
|
|
36
|
+
* @returns a lower-cased platform string, or `''` when nothing could be read.
|
|
37
|
+
*/
|
|
38
|
+
export function detectPlatform(nav: NavigatorLike | undefined): string {
|
|
39
|
+
const chPlatform = nav?.userAgentData?.platform
|
|
40
|
+
if (typeof chPlatform === 'string' && chPlatform !== '') return chPlatform.toLowerCase()
|
|
41
|
+
const legacy = nav?.platform
|
|
42
|
+
if (typeof legacy === 'string' && legacy !== '') return legacy.toLowerCase()
|
|
43
|
+
const agent = nav?.userAgent
|
|
44
|
+
if (typeof agent === 'string' && agent !== '') return agent.toLowerCase()
|
|
45
|
+
return ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The keyboard vocabulary for one platform. */
|
|
49
|
+
export interface PlatformLabels {
|
|
50
|
+
/** The lower-cased platform string these labels were derived from. */
|
|
51
|
+
platform: string
|
|
52
|
+
isMac: boolean
|
|
53
|
+
isWindows: boolean
|
|
54
|
+
/** Invoke-key modifier: `⌘` on macOS, `Ctrl` elsewhere. */
|
|
55
|
+
modLabel: string
|
|
56
|
+
/** Secondary modifier: `⌥` on macOS, `Alt` elsewhere. */
|
|
57
|
+
altLabel: string
|
|
58
|
+
/** Shift: `⇧` on macOS, `Shift` elsewhere. */
|
|
59
|
+
shiftLabel: string
|
|
60
|
+
/** Confirm: the arrow glyph is legible on every platform. */
|
|
61
|
+
enterLabel: string
|
|
62
|
+
/** Escape: `esc` is the macOS spelling, `Esc` the one everywhere else. */
|
|
63
|
+
escLabel: string
|
|
64
|
+
/**
|
|
65
|
+
* The whole invoke chord as one face, ready for a key chip: `⌘K` on macOS,
|
|
66
|
+
* `Ctrl K` on Windows and Linux. `Ctrl K` keeps the space because `CtrlK`
|
|
67
|
+
* reads as a word rather than as two keys.
|
|
68
|
+
*/
|
|
69
|
+
invokeLabel: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Derive the label vocabulary from a platform string.
|
|
74
|
+
*
|
|
75
|
+
* An unrecognised platform — an empty string, or a UA that names neither
|
|
76
|
+
* system — takes the non-Mac vocabulary on purpose: `Ctrl` is legible to
|
|
77
|
+
* everyone, while `⌘` is opaque to anyone who has never used a Mac.
|
|
78
|
+
*
|
|
79
|
+
* @param platform - the string produced by {@link detectPlatform}.
|
|
80
|
+
* @returns the label vocabulary for that platform.
|
|
81
|
+
*/
|
|
82
|
+
export function platformLabels(platform: string): PlatformLabels {
|
|
83
|
+
const isMac = /mac|iphone|ipad|ipod/u.test(platform)
|
|
84
|
+
const isWindows = /win/u.test(platform)
|
|
85
|
+
const modLabel = isMac ? '⌘' : 'Ctrl'
|
|
86
|
+
return {
|
|
87
|
+
platform,
|
|
88
|
+
isMac,
|
|
89
|
+
isWindows,
|
|
90
|
+
modLabel,
|
|
91
|
+
altLabel: isMac ? '⌥' : 'Alt',
|
|
92
|
+
shiftLabel: isMac ? '⇧' : 'Shift',
|
|
93
|
+
enterLabel: '↵',
|
|
94
|
+
escLabel: isMac ? 'esc' : 'Esc',
|
|
95
|
+
invokeLabel: isMac ? `${modLabel}K` : `${modLabel} K`,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read `globalThis.navigator` without assuming a DOM (or a DOM lib). */
|
|
100
|
+
function currentNavigator(): NavigatorLike | undefined {
|
|
101
|
+
const candidate = (globalThis as { navigator?: unknown }).navigator
|
|
102
|
+
if (candidate === null || typeof candidate !== 'object') return undefined
|
|
103
|
+
return candidate as NavigatorLike
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The labels for the platform this bundle is running on. */
|
|
107
|
+
export const LABELS: PlatformLabels = platformLabels(detectPlatform(currentNavigator()))
|
|
108
|
+
|
|
109
|
+
/** The minimal keydown face the chord test reads. */
|
|
110
|
+
export interface KeyChordEvent {
|
|
111
|
+
key: string
|
|
112
|
+
ctrlKey: boolean
|
|
113
|
+
metaKey: boolean
|
|
114
|
+
altKey: boolean
|
|
115
|
+
shiftKey: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a keydown event is the invoke chord advertised by
|
|
120
|
+
* {@link PlatformLabels.invokeLabel}.
|
|
121
|
+
*
|
|
122
|
+
* The hint and the binding have to agree: a footer promising `⌘K` while the
|
|
123
|
+
* handler matches `Ctrl+K` is worse than showing no hint at all. Both sides
|
|
124
|
+
* call this, so neither can be changed alone. Requiring the other modifier to
|
|
125
|
+
* be *absent* keeps the two chords distinct instead of letting either one
|
|
126
|
+
* satisfy both platforms.
|
|
127
|
+
*
|
|
128
|
+
* @param event - the keydown payload.
|
|
129
|
+
* @param isMac - the running platform's macOS-ness, from {@link LABELS}.
|
|
130
|
+
* @returns `true` when the event is the invoke chord.
|
|
131
|
+
*/
|
|
132
|
+
export function isInvokeChord(event: KeyChordEvent, isMac: boolean): boolean {
|
|
133
|
+
if (event.key !== 'k' && event.key !== 'K') return false
|
|
134
|
+
if (event.altKey || event.shiftKey) return false
|
|
135
|
+
return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
|
136
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-manage-session
|
|
2
|
+
* dsh-manage-session 的共享配置面(合并包:管家子域 + 搜索索引子域)。
|
|
3
3
|
*
|
|
4
4
|
* 与 host 半身(schemastery schema 在 src/index.ts)以及浏览器半身共用同一形状,
|
|
5
|
-
* 使一处 admitted 的值在另一处也 admitted
|
|
6
|
-
*
|
|
5
|
+
* 使一处 admitted 的值在另一处也 admitted。
|
|
6
|
+
*
|
|
7
|
+
* 两个设置命名空间**并存且互不迁移**:
|
|
8
|
+
* - `session-steward` —— 管家子域(enabled/historyFiles/healthCheck/search 等共享键);
|
|
9
|
+
* - `switch-search` —— 搜索子域的历史命名空间,存储键承诺保持稳定(见
|
|
10
|
+
* dsh-search-index README「存储键保持稳定,不做迁移」);0.1.7 起两者实际
|
|
11
|
+
* 都落在同一 composition entry(configForms),旧宿主才按命名空间分流。
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
/** 会话管家运行时配置(设置命名空间 + 组合入口)。 */
|
|
10
15
|
export interface StewardConfig {
|
|
11
|
-
/**
|
|
16
|
+
/** 插件总开关(合并包唯一 master:关闭后不注册任何子域路由)。 */
|
|
12
17
|
enabled: boolean
|
|
13
18
|
/** 会话历史文件(归档浏览与清理)。关闭后不注册 history 路由、不渲染「养老院」页签。 */
|
|
14
19
|
historyFiles?: boolean
|
|
15
20
|
/** 会话健康检查(体检 → 处方 → 出院)。关闭后不注册 health 路由、不渲染「体检」页签。 */
|
|
16
21
|
healthCheck?: boolean
|
|
22
|
+
/** 搜索索引子域(`index-*` 路由与侧栏搜索入口)。关闭后索引子域整体停摆。 */
|
|
23
|
+
search?: boolean
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/** 缺省值。 */
|
|
@@ -21,6 +28,7 @@ export const DEFAULT_CONFIG: Required<StewardConfig> = {
|
|
|
21
28
|
enabled: true,
|
|
22
29
|
historyFiles: true,
|
|
23
30
|
healthCheck: true,
|
|
31
|
+
search: true,
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
|
|
@@ -29,5 +37,46 @@ export const STEWARD_SETTINGS_NAMESPACE = 'session-steward'
|
|
|
29
37
|
/** 侧边栏入口 id(客户端注册 id)。 */
|
|
30
38
|
export const STEWARD_ENTRY_ID = 'dsh-manage-session'
|
|
31
39
|
|
|
32
|
-
/** host
|
|
40
|
+
/** host 路由前缀(管家子域;与搜索子域的 /switch-search/api 互不干扰)。 */
|
|
33
41
|
export const STEWARD_API_PREFIX = '/session-steward/api'
|
|
42
|
+
|
|
43
|
+
/** ------------------------------------------------------------------ 搜索子域 */
|
|
44
|
+
|
|
45
|
+
/** 搜索子域的历史设置命名空间(存储键稳定承诺,永不迁移)。 */
|
|
46
|
+
export const SWITCH_SEARCH_SETTINGS_NAMESPACE = 'switch-search'
|
|
47
|
+
|
|
48
|
+
/** 搜索子域路由前缀(历史值,浏览器旧 bundle 与快照文件名依赖它,保持不变)。 */
|
|
49
|
+
export const SWITCH_API_PREFIX = '/switch-search/api'
|
|
50
|
+
|
|
51
|
+
/** 搜索面板打开时的默认模式。 */
|
|
52
|
+
export type SwitchSearchDefaultMode = 'title' | 'content'
|
|
53
|
+
|
|
54
|
+
/** 搜索子域可调配置(独立于 StewardConfig 的历史形状,字段名保持稳定)。 */
|
|
55
|
+
export interface SwitchSearchConfig {
|
|
56
|
+
/**
|
|
57
|
+
* 搜索卡上的「启用」开关。合并包里它与管家 `enabled` 是**同一个**组合入口
|
|
58
|
+
* 字段(0.1.7 configForms 单 scope);保留在此处是为了 legacy settingsScope
|
|
59
|
+
* 回退路径(旧 switch-search 命名空间的存储形状)与设置卡类型不变。
|
|
60
|
+
*/
|
|
61
|
+
enabled?: boolean
|
|
62
|
+
/** 面板默认模式。 */
|
|
63
|
+
defaultMode: SwitchSearchDefaultMode
|
|
64
|
+
/** 独立索引是否后台自动同步(水位轮询)。 */
|
|
65
|
+
autoSync?: boolean
|
|
66
|
+
/** 增量同步间隔(ms)。 */
|
|
67
|
+
syncIntervalMs?: number
|
|
68
|
+
/** 整理索引后保留的归档份数。 */
|
|
69
|
+
archiveKeep?: number
|
|
70
|
+
/** 独立索引文件的可选绝对目录。 */
|
|
71
|
+
indexDir?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 搜索子域缺省值。 */
|
|
75
|
+
export const SWITCH_DEFAULT_CONFIG: Required<SwitchSearchConfig> = {
|
|
76
|
+
enabled: true,
|
|
77
|
+
defaultMode: 'title',
|
|
78
|
+
autoSync: true,
|
|
79
|
+
syncIntervalMs: 30_000,
|
|
80
|
+
archiveKeep: 2,
|
|
81
|
+
indexDir: '',
|
|
82
|
+
}
|
|
@@ -185,6 +185,54 @@ export function pruneArchiveFile(
|
|
|
185
185
|
throw new Error(lastReason)
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* 把会话 id 加入存储中枢的 `global.archivedSessionIds`(= 归档,prune 的逆操作)。
|
|
190
|
+
*
|
|
191
|
+
* 与 prune 同一写入口与协议(备份 + 临时文件 + 原子改名);已在集合中的 id
|
|
192
|
+
* 不重复追加。运行中的宿主把集合留在内存里、只在启动时重载 —— 调用方必须
|
|
193
|
+
* 提示需要重启 DSH。
|
|
194
|
+
* @param ids - 要加入归档数组的会话 id。
|
|
195
|
+
* @param log - 可选日志出口。
|
|
196
|
+
* @param searchPaths - 可选候选路径覆盖(测试注入用)。
|
|
197
|
+
* @returns 实际新增数量与操作后的集合总数。
|
|
198
|
+
*/
|
|
199
|
+
export function archiveArchiveFile(
|
|
200
|
+
ids: readonly string[],
|
|
201
|
+
log?: (msg: string) => void,
|
|
202
|
+
searchPaths?: readonly string[],
|
|
203
|
+
): {
|
|
204
|
+
added: number
|
|
205
|
+
total: number
|
|
206
|
+
file?: string
|
|
207
|
+
} {
|
|
208
|
+
const wanted = new Set(ids)
|
|
209
|
+
let lastReason = 'workspace storage file not found (searched ~/.dsh/storages/workspace.json)'
|
|
210
|
+
for (const path of searchPaths ?? storageFileCandidates()) {
|
|
211
|
+
let added = 0
|
|
212
|
+
let total = 0
|
|
213
|
+
const outcome = editWorkspaceDocument(path, (document) => {
|
|
214
|
+
const global = document.global as { archivedSessionIds?: unknown } | undefined
|
|
215
|
+
const current = global?.archivedSessionIds
|
|
216
|
+
if (!Array.isArray(current)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
|
|
217
|
+
const existing = new Set(current.filter((id): id is string => typeof id === 'string'))
|
|
218
|
+
const fresh = ids.filter((id) => !existing.has(id))
|
|
219
|
+
if (fresh.length === 0) {
|
|
220
|
+
added = 0
|
|
221
|
+
total = existing.size
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
document.global = { ...global, archivedSessionIds: [...current, ...fresh] }
|
|
225
|
+
added = fresh.length
|
|
226
|
+
total = existing.size + fresh.length
|
|
227
|
+
return true
|
|
228
|
+
}, log)
|
|
229
|
+
if (!outcome.ok) { lastReason = outcome.reason; continue }
|
|
230
|
+
if (outcome.changed) log?.(`archive: added ${added} ids; total ${total}`)
|
|
231
|
+
return { added, total, file: outcome.file }
|
|
232
|
+
}
|
|
233
|
+
throw new Error(lastReason)
|
|
234
|
+
}
|
|
235
|
+
|
|
188
236
|
/** 构造同步器期望的惰性来源面,并记录诊断。 */
|
|
189
237
|
export function createArchiveSource(getRegistry: () => StewardRegistryFace | undefined): {
|
|
190
238
|
read: () => StewardArchiveRead
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { existsSync, readFileSync } from 'node:fs'
|
|
15
15
|
import { homedir } from 'node:os'
|
|
16
16
|
import { join } from 'node:path'
|
|
17
|
-
import { pruneArchiveFile, readArchiveSet, type StewardRegistryFace } from './archive-source.ts'
|
|
17
|
+
import { archiveArchiveFile, pruneArchiveFile, readArchiveSet, type StewardRegistryFace } from './archive-source.ts'
|
|
18
18
|
import { locateSessionUsage } from './purge.ts'
|
|
19
19
|
|
|
20
20
|
/** 一行历史文件条目(尽力而为的元数据 + 磁盘占用)。 */
|
|
@@ -192,3 +192,58 @@ export function pruneHistory(
|
|
|
192
192
|
log?.(`archive prune requested ${ids.length}, removed ${result.removed}, remaining ${result.remaining}`)
|
|
193
193
|
return { ok: true, removed: result.removed, remaining: result.remaining, requiresRestart: true }
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
/** 归档结果。 */
|
|
197
|
+
export interface StewardHistoryArchiveResult {
|
|
198
|
+
ok: boolean
|
|
199
|
+
/** 本次新加入归档集合的数量(已在集合中的 id 不重复计)。 */
|
|
200
|
+
added?: number
|
|
201
|
+
/** 操作后归档集合总数。 */
|
|
202
|
+
total?: number
|
|
203
|
+
requiresRestart?: boolean
|
|
204
|
+
error?: string
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 单次归档的最大 id 数(与 prune/purge 一致)。 */
|
|
208
|
+
export const MAX_ARCHIVE_IDS = 5000
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `session-history-archive`:把会话批量加入官方归档集合(prune 的逆操作)。
|
|
212
|
+
* 与 prune 同一条纪律:校验入参 → 备份并原子替换存储文件 → 读回校验 →
|
|
213
|
+
* 要求重启(宿主内存集合只在启动时重载)。会话文件不动、工作区成员表不动
|
|
214
|
+
* (归档只改可见性,与宿主 archiveSession 的文件语义一致)。
|
|
215
|
+
* @param payload - `{ sessionIds: string[] }`。
|
|
216
|
+
* @param log - 可选日志出口。
|
|
217
|
+
* @param searchPaths - 可选候选路径覆盖(测试注入用)。
|
|
218
|
+
*/
|
|
219
|
+
export function archiveHistory(
|
|
220
|
+
payload: unknown,
|
|
221
|
+
log?: (msg: string) => void,
|
|
222
|
+
searchPaths?: readonly string[],
|
|
223
|
+
): StewardHistoryArchiveResult {
|
|
224
|
+
const record = payload as { sessionIds?: unknown } | null
|
|
225
|
+
if (!Array.isArray(record?.sessionIds) || record.sessionIds.length === 0) {
|
|
226
|
+
return { ok: false, error: '缺少 sessionIds 数组' }
|
|
227
|
+
}
|
|
228
|
+
const ids = [...new Set(record.sessionIds.filter((id): id is string => typeof id === 'string' && id !== ''))]
|
|
229
|
+
if (ids.length === 0) return { ok: false, error: 'sessionIds 无有效值' }
|
|
230
|
+
if (ids.length > MAX_ARCHIVE_IDS) return { ok: false, error: `单次最多归档 ${MAX_ARCHIVE_IDS} 个` }
|
|
231
|
+
let result: { added: number; total: number; file?: string }
|
|
232
|
+
try {
|
|
233
|
+
result = archiveArchiveFile(ids, log, searchPaths)
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return { ok: false, error: String(err instanceof Error ? err.message : err) }
|
|
236
|
+
}
|
|
237
|
+
// 写后校验:读回文件确认目标 id 确实进了归档集合。
|
|
238
|
+
const after = readArchiveSet(undefined, searchPaths)
|
|
239
|
+
if (after.source !== 'storage-file') {
|
|
240
|
+
return { ok: false, error: '写入后无法读回存储文件,无法确认归档结果' }
|
|
241
|
+
}
|
|
242
|
+
const now = new Set(after.ids)
|
|
243
|
+
const missing = ids.filter((id) => !now.has(id))
|
|
244
|
+
if (missing.length > 0) {
|
|
245
|
+
return { ok: false, error: `存储文件在写入后仍缺少 ${missing.length} 个目标 id,归档未生效` }
|
|
246
|
+
}
|
|
247
|
+
log?.(`archive requested ${ids.length}, added ${result.added}, total ${result.total}`)
|
|
248
|
+
return { ok: true, added: result.added, total: result.total, requiresRestart: true }
|
|
249
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 收藏域(R6)—— 插件自有的 JSON 文件,与索引文件/官方存储彻底分离:
|
|
3
|
+
* 索引会因 schema 重置/影子切换整体更换,收藏不能放里面;官方存储中枢
|
|
4
|
+
* 只读不写(唯一写方纪律不破)。文件形如
|
|
5
|
+
* `<dshHome>/storages/session-steward-favorites.json`:
|
|
6
|
+
* `{ unit: { name, version }, favoriteSessionIds: string[] }`
|
|
7
|
+
* 原子写(tmp+rename),丢失可重建(收藏只是标记,不是数据)。
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** 收藏文件候选路径(dshHome 注入;测试可覆盖)。 */
|
|
13
|
+
export function favoritesFileCandidates(dshHome: string): string[] {
|
|
14
|
+
return [join(dshHome, 'storages', 'session-steward-favorites.json')]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 读取收藏集合;文件缺失/畸形一律空数组(收藏只是标记,丢失不致命)。 */
|
|
18
|
+
export function listFavorites(searchPaths?: readonly string[]): string[] {
|
|
19
|
+
for (const path of searchPaths ?? []) {
|
|
20
|
+
if (!existsSync(path)) continue
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { favoriteSessionIds?: unknown }
|
|
23
|
+
if (!Array.isArray(parsed.favoriteSessionIds)) return []
|
|
24
|
+
return parsed.favoriteSessionIds.filter((id): id is string => typeof id === 'string')
|
|
25
|
+
} catch {
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 收藏写入结果。 */
|
|
33
|
+
export interface FavoritesSetResult {
|
|
34
|
+
ok: boolean
|
|
35
|
+
/** 操作后收藏总数。 */
|
|
36
|
+
total?: number
|
|
37
|
+
error?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 设置单条收藏状态(原子写)。 */
|
|
41
|
+
export function setFavoriteState(
|
|
42
|
+
sessionId: string,
|
|
43
|
+
favorite: boolean,
|
|
44
|
+
searchPaths?: readonly string[],
|
|
45
|
+
): FavoritesSetResult {
|
|
46
|
+
const path = searchPaths?.[0]
|
|
47
|
+
if (path === undefined || path === '') return { ok: false, error: '缺少收藏文件路径' }
|
|
48
|
+
let ids: string[] = []
|
|
49
|
+
if (existsSync(path)) {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { favoriteSessionIds?: unknown }
|
|
52
|
+
if (Array.isArray(parsed.favoriteSessionIds)) {
|
|
53
|
+
ids = parsed.favoriteSessionIds.filter((id): id is string => typeof id === 'string')
|
|
54
|
+
}
|
|
55
|
+
} catch { /* 畸形文件按空集合重建 */ }
|
|
56
|
+
}
|
|
57
|
+
const without = ids.filter(id => id !== sessionId)
|
|
58
|
+
const next = favorite ? [...without, sessionId] : without
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
61
|
+
const tmp = `${path}.fav-tmp`
|
|
62
|
+
writeFileSync(tmp, `${JSON.stringify({
|
|
63
|
+
unit: { name: 'session-steward-favorites', version: 1 },
|
|
64
|
+
favoriteSessionIds: next,
|
|
65
|
+
}, null, 2)}\n`, 'utf8')
|
|
66
|
+
renameSync(tmp, path)
|
|
67
|
+
return { ok: true, total: next.length }
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { ok: false, error: String(err instanceof Error ? err.message : err) }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official archive-set source resolution.
|
|
3
|
+
*
|
|
4
|
+
* Primary: the in-process `workspaceRegistry` service (the same fact the UI
|
|
5
|
+
* filters by). Fallback: read the canonical storage hub file directly — the
|
|
6
|
+
* workspace domain persists `archivedSessionIds` under the `global` segment
|
|
7
|
+
* (storage-json: `~/.dsh/storages/workspace.json`; storage-sqlite variant
|
|
8
|
+
* exists but the JSON fallback file is what stock web profiles ship).
|
|
9
|
+
* Resolution order is decided per read; failures degrade to "no archive set"
|
|
10
|
+
* and are reported through the diagnostics face.
|
|
11
|
+
*
|
|
12
|
+
* READ-ONLY by contract. The archive set is WRITTEN by dsh-manage-session
|
|
13
|
+
* (会话管家 → 病案室); this package only consumes it to keep archived sessions
|
|
14
|
+
* out of the index. Format contract: `dsh-归档文件格式契约-20260914.md`.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/** One resolved archive read: ids plus which source served them. */
|
|
21
|
+
export interface SwitchArchiveRead {
|
|
22
|
+
ids: readonly string[]
|
|
23
|
+
source: 'registry' | 'storage-file' | 'none'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The workspaceRegistry mirror face (getter only). */
|
|
27
|
+
export interface SwitchRegistryFace {
|
|
28
|
+
readonly archivedSessionIds: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Diagnostics describing how the archive set is being resolved. */
|
|
32
|
+
export interface SwitchArchiveDiagnostics {
|
|
33
|
+
source: 'registry' | 'storage-file' | 'none'
|
|
34
|
+
ids: number
|
|
35
|
+
error?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** DSH storage hub candidates for the workspace domain (json backend). */
|
|
39
|
+
function storageFileCandidates(): string[] {
|
|
40
|
+
return [
|
|
41
|
+
join(homedir(), '.dsh', 'storages', 'workspace.json'),
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parse the storage hub file's global.archivedSessionIds; throw on malformed content. */
|
|
46
|
+
function readStorageFile(path: string): readonly string[] {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
|
|
48
|
+
global?: { archivedSessionIds?: unknown }
|
|
49
|
+
}
|
|
50
|
+
const ids = parsed.global?.archivedSessionIds
|
|
51
|
+
if (!Array.isArray(ids)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
|
|
52
|
+
return ids.filter((id): id is string => typeof id === 'string')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the official archive set once.
|
|
57
|
+
* @param registry - lazy workspaceRegistry face (may be absent or throw).
|
|
58
|
+
* @returns the archive ids plus which source served them.
|
|
59
|
+
*/
|
|
60
|
+
export function readArchiveSet(registry?: SwitchRegistryFace): SwitchArchiveRead {
|
|
61
|
+
if (registry !== undefined) {
|
|
62
|
+
try {
|
|
63
|
+
const ids = registry.archivedSessionIds
|
|
64
|
+
if (Array.isArray(ids)) return { ids, source: 'registry' }
|
|
65
|
+
} catch { /* registry not started yet — fall through to the file */ }
|
|
66
|
+
}
|
|
67
|
+
for (const path of storageFileCandidates()) {
|
|
68
|
+
if (!existsSync(path)) continue
|
|
69
|
+
try {
|
|
70
|
+
return { ids: readStorageFile(path), source: 'storage-file' }
|
|
71
|
+
} catch { /* malformed file — try the next candidate */ }
|
|
72
|
+
}
|
|
73
|
+
return { ids: [], source: 'none' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Build the lazy source face the syncer expects, with diagnostics capture. */
|
|
77
|
+
export function createArchiveSource(getRegistry: () => SwitchRegistryFace | undefined): {
|
|
78
|
+
read: () => SwitchArchiveRead
|
|
79
|
+
diagnostics: () => SwitchArchiveDiagnostics
|
|
80
|
+
} {
|
|
81
|
+
let last: SwitchArchiveDiagnostics = { source: 'none', ids: 0 }
|
|
82
|
+
return {
|
|
83
|
+
read: () => {
|
|
84
|
+
const read = readArchiveSet(getRegistry())
|
|
85
|
+
last = { source: read.source, ids: read.ids.length }
|
|
86
|
+
return read
|
|
87
|
+
},
|
|
88
|
+
diagnostics: () => ({ ...last }),
|
|
89
|
+
}
|
|
90
|
+
}
|