dsh-session-manager 0.4.7 → 0.4.9

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 CHANGED
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+
4
+ ## 0.4.9 — 2026-09-12
5
+
6
+ - **fix(ui)**: Session manager panel and inner dialogs (Delete / Move /
7
+ Migrate confirmations) now close on Esc regardless of focus
8
+ position (issue #7). The original layer-bound onKeyDown was
9
+ unreachable because the sidebar toggle / row button that opened
10
+ each modal stayed focused -- focus is a sibling of the layer,
11
+ not a descendant, so keydown never bubbles into the modal subtree.
12
+ ConfirmDialog / MoveDialog are also used by the title-bar Delete
13
+ / Move actions, so this fix applies there too.
14
+
15
+ - **fix(ui)**: Modals now close ONLY via Esc or their explicit close
16
+ buttons. The original 0.4.7 behavior (close on backdrop click)
17
+ was removed per user feedback -- a stray click outside the panel
18
+ was dismissing it accidentally. Every `.sm-nativeDialogBackdrop`
19
+ `onMouseDown` handler is gone; the dim backdrop itself is also
20
+ gone so opening a modal never darkens the page (sidebar or
21
+ content area).
22
+
23
+ - **refactor(ui)**: Esc handling moved from per-layer `onKeyDown`
24
+ to a `window`-level `keydown` listener via `useEffect` on every
25
+ modal. A `useRef` lets the listener see the latest state values
26
+ without re-subscribing on every render. The panel listener
27
+ returns early when any inner dialog is open so the child
28
+ dialog's listener gets the first shot at Esc.
29
+
30
+ - **fix(ui)**: After a keyboard-driven close (Esc), the originally
31
+ focused trigger button (sidebar toggle / row button / Cancel
32
+ button) no longer leaves a lingering `:focus-visible` ring. The
33
+ four Esc handlers `blur()` the active element after the close
34
+ call. Mouse-driven closes are not affected -- `:focus-visible`
35
+ only activates for keyboard-acquired focus.
36
+
37
+ - **fix(ui)**: ConfirmDialog no longer listens for Enter at the
38
+ layer level. The previous handler raced with the focused Cancel
39
+ button's native Enter handler -- both fired (`onCancel` +
40
+ `onConfirm`). Enter now lives only on the focused button.
41
+
42
+ - **cleanup(ui)**: Remove three dead `<section>` `ref={(el) =>
43
+ el.focus()}` callbacks (sections have no `tabindex` and are
44
+ not focusable, so the focus calls were silent no-ops). Also
45
+ remove the now-unused `onPanelKey` callback and `tabIndex: -1`
46
+ attributes on the modal layers. The `.sm-nativeDialogBackdrop`
47
+ divs are no longer rendered at all -- they had no behavior left
48
+ after removing the click-to-close.
49
+
50
+ - **chore**: bump version to 0.4.9.
51
+
52
+ ## 0.4.8 — 2026-09-11
53
+
54
+ - **perf(listSessionHeaders)**: skip known session directories before any
55
+ fopen. The disk fallback loop previously opened and zstd-decoded every
56
+ candidate file under `<dshHome>/sessions/<proj>/<dir>/` even when the
57
+ persistence layer had already returned those ids, wasting O(N *
58
+ file_size) read+inflate per call. On a healthy 80-session / 249 MB
59
+ library that meant +17s CPU / +254 MB read per DSH startup, paid three
60
+ times (post-boot sweep, cross-workspace move, preset-scan). We now
61
+ pre-build a `knownDirs` set from the persisted ids (both the raw id
62
+ and `encodeSessionSegment(id)`) and skip matching directories with
63
+ an O(1) Set lookup. Orphan raw-id directories that the index does not
64
+ know about still fall through to the file read so the disk fallback
65
+ semantics are preserved. Fixes
66
+ [issue #6](https://github.com/hkkz9522/dsh-session-manager/issues/6).
67
+
68
+ - **refactor(listSessionHeaders)**: replace the inline concatenated-zstd
69
+ walk with `decompressAllZstdFrames` from `lib/compat/zstd-frames.js`,
70
+ removing ~30 lines of duplicated decoder logic. Async decompression
71
+ yields to the event loop between frames -- a net win on the orphan
72
+ path even though the hot path is now skipped entirely.
73
+
74
+ - **perf(moveSession)**: resolve the pre-flight session header via
75
+ `persistence.list().find()` first, and only call the disk-fallback
76
+ `listSessionHeaders()` when the index missed. Previously moveSession
77
+ paid the (pre-fix) full-library scan every time; now the common case
78
+ is one `.list()` + `.find()`.
79
+
80
+ - **chore**: hoist `encodeSessionSegment` and `listSessionHeaders` to
81
+ module scope so the regression test can exercise them in isolation;
82
+ add `export { listSessionHeaders, encodeSessionSegment }` for the
83
+ same reason. Behavior is unchanged.
84
+
85
+ - **chore**: bump version to 0.4.8.
86
+
87
+
3
88
  ## 0.4.7 — 2026-09-10
4
89
 
5
90
  - **fix(disk scan)**: include `session.v3.jsonl.zstd` in the on-disk
package/README.md CHANGED
@@ -72,6 +72,7 @@ dev_inject_plugin {"dir": "<absolute path to this repository>"}
72
72
 
73
73
  | Plugin version | Verified DSH version |
74
74
  | --- | --- |
75
+ | 0.4.9 | v0.1.5-rc.1 |
75
76
  | 0.4.7 | v0.1.5-rc.1 |
76
77
  | 0.4.4 | 0.1.3-alpha.2 |
77
78
  | 0.4.1 | 0.1.3-alpha.2 |
package/README.zh.md CHANGED
@@ -72,6 +72,7 @@ dev_inject_plugin {"dir": "<本仓库的绝对路径>"}
72
72
 
73
73
  | 插件版本 | 已验证 DSH 版本 |
74
74
  | --- | --- |
75
+ | 0.4.9 | v0.1.5-rc.1 |
75
76
  | 0.4.7 | v0.1.5-rc.1 |
76
77
  | 0.4.4 | 0.1.3-alpha.2 |
77
78
  | 0.4.1 | 0.1.3-alpha.2 |
package/lib/client.js CHANGED
@@ -192,6 +192,7 @@ window.__ModuleLoader__.load({
192
192
  ".sm-moveItemMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}.sm-panelModal{width:760px;max-width:92vw}.sm-panelModal .sm-rowActions{flex-wrap:wrap;justify-content:flex-end}.sm-headerMenu{position:absolute;right:0;top:100%;z-index:50;display:flex;flex-direction:column;min-width:160px;padding:4px;background:var(--dsw-alias-surface-l1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12)}.sm-headerMenuItem{display:flex;align-items:center;width:100%;text-align:left;background:0 0;border:0;border-radius:6px;color:var(--dsw-alias-label-primary);cursor:pointer;padding:6px 10px;font-size:13px;line-height:18px}.sm-headerMenuItem:hover{background:var(--dsw-alias-interactive-bg-hover)}.sm-headerMenuItemDanger{color:var(--dsw-alias-state-error-primary)}.sm-headerBtn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;height:auto;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-l2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer;padding:4px 12px;font-size:13px;line-height:18px;white-space:nowrap;transition:background .15s,color .15s,border-color .15s}.sm-headerBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l1)}.sm-headerBtn:focus-visible{outline:2px solid var(--dsw-alias-accent-primary);outline-offset:1px}.sm-headerBtn svg{width:16px;height:16px}.sm-migratePanel{display:flex;flex-direction:column;gap:14px;min-width:480px}.sm-migrateRow{display:flex;flex-direction:column;gap:8px}.sm-migrateRow>label{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;font-weight:500}.sm-migrateChips{display:flex;flex-wrap:wrap;gap:6px}.sm-migrateChip{padding:6px 12px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:transparent;cursor:pointer;font-size:13px;line-height:18px;color:var(--dsw-alias-label-primary);white-space:nowrap}.sm-migrateChip:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.sm-migrateChip:disabled{opacity:.5;cursor:not-allowed}.sm-migrateChipOn{background:var(--dsw-alias-accent-primary);color:#fff;border-color:var(--dsw-alias-accent-primary)}.sm-migrateEmpty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.sm-migratePreview{padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px}.sm-migrateWarn{padding:10px 12px;border:1px solid rgba(217,119,6,.25);border-radius:8px;background:rgba(217,119,6,.08);color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px}.sm-migrateList{max-height:200px;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}.sm-headerBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l1)}.sm-headerBtnDanger{background:var(--dsw-alias-state-error-tertiary);color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);font-weight:500}.sm-headerBtnDanger:hover:not(:disabled){background:#d92d20!important;color:#fff!important;border-color:#d92d20!important}.sm-header{display:inline-flex;align-items:center;gap:6px}.sm-headerBtnActive{background:var(--dsw-alias-fill-l3);color:var(--dsw-alias-label-primary)}",
193
193
  ".sm-panelDialog{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:560px;max-width:calc(100vw - 32px);max-height:540px;height:auto;box-sizing:border-box;display:flex;flex-direction:column;gap:0;background:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.25);overflow:hidden;z-index:9999;opacity:1}.sm-panelDialog .sm-nativeDialogHeader{padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#eee);flex:none}.sm-panelDialog .sm-nativeDialogTitle{margin:0;font-size:14px;line-height:20px;font-weight:600}.sm-panelDialog .sm-nativeDialogFooter{padding:10px 16px;border-top:1px solid var(--dsw-alias-border-l2,#eee);flex:none;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-top:0}.sm-panelDialog .sm-panel{display:flex;flex-direction:column;gap:6px;min-height:0;flex:1 1 auto;overflow:auto;padding:8px 12px}.sm-panelDialog .sm-list{display:flex;flex-direction:column;gap:2px;flex:1 1 auto;min-height:0;overflow:auto}.sm-panelDialog .sm-row{display:flex;align-items:center;gap:8px;padding:0 10px;height:36px;min-height:36px;border:1px solid transparent;border-radius:8px;background:transparent}.sm-panelDialog .sm-row:hover{background:var(--dsw-alias-interactive-bg-hover)}.sm-panelDialog .sm-rowCurrent{background:var(--dsw-alias-fill-l2);border-color:var(--dsw-alias-accent-primary,#356ae6)}.sm-panelDialog .sm-rowMain{display:flex;align-items:stretch!important;gap:8px;min-width:0;flex:1 1 auto;overflow:hidden;text-align:left!important}.sm-panelDialog .sm-rowTitle{min-width:0;flex:1 1 0%;width:100%;align-self:stretch;display:flex;align-items:center;justify-content:flex-start;gap:6px;font-size:12px;line-height:16px;overflow:hidden;text-align:left!important;direction:ltr!important}.sm-confirmDialog{z-index:10000}.sm-confirmDialog.sm-nativeDialogLayer{position:fixed;left:calc(50vw + 184px);top:calc(50vh - 90px);width:300px;max-width:calc(100vw - 32px);max-height:none;display:block;align-items:initial;justify-content:initial;box-sizing:border-box;background:transparent;border:0;box-shadow:none;padding:0;inset:calc(50vh - 90px) auto auto calc(50vw + 308px)}.sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialogBackdrop{display:none}.sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialog{position:relative;left:auto;top:auto;transform:none;width:auto;box-sizing:border-box;background:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.25);padding:0;overflow:hidden;animation:sm-confirmPop .14s ease-out}.sm-confirmDialog .sm-nativeDialogHeader{padding:12px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#eee)}.sm-confirmDialog .sm-nativeDialogTitle{margin:0;font-size:14px;line-height:20px;font-weight:600}.sm-confirmDialog .sm-nativeDialogBody{padding:10px 16px;display:flex;flex-direction:column;gap:8px;min-height:0}.sm-confirmDialog .sm-nativeDialogDescription{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap}.sm-confirmDialog .sm-warn{padding:6px 10px;font-size:12px;line-height:16px;margin-top:0}.sm-confirmDialog .sm-nativeDialogFooter{padding:10px 16px;border-top:1px solid var(--dsw-alias-border-l2,#eee);flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:0}.sm-confirmDialog .sm-nativeDialogButton{min-height:28px;padding:3px 14px;font-size:12px;line-height:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer;background:transparent;color:var(--dsw-alias-label-primary)}.sm-confirmDialog .sm-nativeDialogCancel:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.sm-confirmDialog .sm-nativeDialogConfirm{background:var(--dsw-alias-state-error-primary);color:#fff;border-color:var(--dsw-alias-state-error-primary)}.sm-confirmDialog .sm-nativeDialogConfirm:hover:not(:disabled){filter:brightness(.94)}@keyframes sm-confirmPop{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.sm-confirmDialog.sm-nativeDialog{padding:0;width:auto;max-width:none}.sm-panelDialog .sm-rowTitle>span:first-child{display:block;min-width:0;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;flex:1 1 auto;width:auto;text-align:left!important;direction:ltr!important;unicode-bidi:plaintext}.sm-panelDialog .sm-rowMeta{display:none}.sm-panelDialog .sm-rowActions{display:flex;gap:2px;flex:none;justify-content:flex-end}.sm-panelDialog .sm-rowActions .sm-rowBtn{min-height:22px;height:22px;padding:0 8px;font-size:11px;line-height:14px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}.sm-panelDialog .sm-rowActions .sm-rowBtn:hover{background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-primary)}.sm-panelDialog .sm-rowActions .sm-rowBtnDanger{color:var(--dsw-alias-state-error-primary)}.sm-panelDialog .sm-filter{flex:none;padding:6px 12px 4px;gap:4px;display:flex;flex-wrap:wrap}.sm-panelDialog .sm-filterBtn{min-height:24px;padding:2px 10px;font-size:11px;line-height:16px;border-radius:4px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}.sm-panelDialog .sm-filterBtn.sm-on{background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-primary)}.sm-panelDialog .sm-badge{font-size:10px;line-height:14px;padding:0 5px;border-radius:6px;flex:none}.sm-panelDialog .sm-error{padding:6px 10px;margin:0;font-size:12px;line-height:16px}.sm-panelDialog .sm-nativeDialogButton{min-height:28px;padding:3px 12px;font-size:12px;line-height:18px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}.sm-panelDialog .sm-nativeDialogCancel{background:transparent}.sm-panelDialog .sm-nativeDialogGhost{background:transparent;border-color:var(--dsw-alias-border-l2)}.sm-panelDialog .sm-nativeDialogConfirm{background:var(--dsw-alias-accent-primary,#356ae6);color:#fff;border-color:var(--dsw-alias-accent-primary,#356ae6)}.sm-panelDialog .sm-empty{padding:18px 0;font-size:12px;color:var(--dsw-alias-label-tertiary);text-align:center}.sm-panelDialog .sm-footError{font-size:11px;color:var(--dsw-alias-state-error-primary);margin-right:auto;line-height:20px}.sm-panelDialog .sm-moveLabel{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}.sm-migrateDialog .sm-nativeDialogFooter{flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:14px}.sm-migrateDialog .sm-migrateChip{cursor:pointer;pointer-events:auto;position:relative;z-index:1}.sm-migrateDialog .sm-migrateChip[disabled]{opacity:.5;cursor:not-allowed}.sm-migrateDialog.sm-nativeDialogLayer{position:fixed;left:50vw;top:auto;bottom:48px;transform:translateX(-50%);width:min(560px,calc(100vw - 32px));max-height:none;display:block;align-items:initial;justify-content:initial;box-sizing:border-box;background:transparent;border:0;box-shadow:none;padding:0;inset:auto;z-index:10001}.sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialogBackdrop{display:none}.sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialog{position:relative;left:auto;top:auto;transform:none;width:auto;max-height:min(70vh,520px);box-sizing:border-box;background-color:#ffffff;background-color:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.45);overflow:hidden;padding:0;display:flex;flex-direction:column;gap:0;opacity:1}.sm-migrateDialog{position:fixed;left:50vw;top:auto;bottom:48px;transform:translateX(-50%);width:min(560px,calc(100vw - 32px));max-height:min(70vh,520px);box-sizing:border-box;display:flex;flex-direction:column;gap:0;background-color:#ffffff;background-color:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.45);overflow:hidden;opacity:1;z-index:10001;backdrop-filter:none;-webkit-backdrop-filter:none}.sm-migrateDialog .sm-nativeDialogHeader{padding:12px 18px;border-bottom:1px solid #eee;border-bottom:1px solid var(--dsw-alias-border-l2,#eee);flex:none;background-color:#fff;background-color:var(--dsw-alias-surface-l1,#fff)}.sm-migrateDialog .sm-nativeDialogTitle{margin:0;font-size:15px;line-height:22px;font-weight:600;color:#111;color:var(--dsw-alias-label-primary,#111)}.sm-migrateDialog .sm-migratePanel{padding:12px 18px;display:flex;flex-direction:column;gap:14px;background-color:#fff;background-color:var(--dsw-alias-surface-l1,#fff)}.sm-migrateDialog .sm-nativeDialogBody{padding:0;background-color:#fff;background-color:var(--dsw-alias-surface-l1,#fff)}.sm-migrateDialog .sm-nativeDialogFooter{padding:12px 18px;border-top:1px solid #eee;border-top:1px solid var(--dsw-alias-border-l2,#eee);flex:none;flex-wrap:wrap;justify-content:flex-end;gap:8px;background-color:#fff;background-color:var(--dsw-alias-surface-l1,#fff)}.sm-migrateDialog .sm-migrateRow>label{color:#444;color:var(--dsw-alias-label-secondary,#444)}.sm-migrateDialog .sm-migrateChip{padding:6px 12px;border-radius:999px;border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);background-color:#f5f5f5;background-color:var(--dsw-alias-fill-l2,#f5f5f5);color:#111;color:var(--dsw-alias-label-primary,#111);font-size:13px;line-height:18px;cursor:pointer;pointer-events:auto;position:relative;z-index:1;white-space:nowrap}.sm-migrateDialog .sm-migrateChip:hover:not(:disabled){background-color:#e8e8e8;background-color:var(--dsw-alias-interactive-bg-hover,#e8e8e8)}.sm-migrateDialog .sm-migrateChipOn{background-color:#356ae6;background-color:var(--dsw-alias-accent-primary,#356ae6);color:#fff;border-color:#356ae6;border-color:var(--dsw-alias-accent-primary,#356ae6)}.sm-migrateDialog .sm-migrateChip.sm-migrateChipOn:hover:not(:disabled){background-color:#4576f0;background-color:var(--dsw-alias-accent-primary-hover,#4576f0);color:#fff;border-color:#4576f0;border-color:var(--dsw-alias-accent-primary-hover,#4576f0);filter:none}.sm-migrateDialog .sm-migrateChipOn:hover:not(:disabled){filter:brightness(.92)}.sm-migrateDialog .sm-migrateChip[disabled]{opacity:.5;cursor:not-allowed}.sm-migrateDialog .sm-migrateEmpty{color:#888;color:var(--dsw-alias-label-tertiary,#888)}.sm-migrateDialog .sm-migratePreview{padding:10px 12px;border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;background-color:#f5f5f5;background-color:var(--dsw-alias-fill-l2,#f5f5f5);color:#111;color:var(--dsw-alias-label-primary,#111);font-size:13px;line-height:18px}.sm-migrateDialog .sm-migrateWarn{padding:10px 12px;border:1px solid rgba(217,119,6,.25);border-radius:8px;background-color:rgba(217,119,6,.08);color:#444;color:var(--dsw-alias-label-secondary,#444);font-size:13px;line-height:18px}.sm-migrateDialog .sm-nativeDialogButton{min-height:32px;padding:4px 14px;font-size:13px;line-height:20px;border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;cursor:pointer;background-color:#fff;background-color:var(--dsw-alias-fill-l2,#fff);color:#111;color:var(--dsw-alias-label-primary,#111)}.sm-migrateDialog .sm-nativeDialogButton:hover:not(:disabled){background-color:#e8e8e8;background-color:var(--dsw-alias-interactive-bg-hover,#e8e8e8)}.sm-migrateDialog .sm-nativeDialogConfirm{background-color:#356ae6;background-color:var(--dsw-alias-accent-primary,#356ae6);color:#fff;border-color:#356ae6;border-color:var(--dsw-alias-accent-primary,#356ae6)}.sm-migrateDialog .sm-nativeDialogConfirm:hover:not(:disabled){filter:brightness(.94)}",
194
194
  ".sm-panelDialog .sm-nativeDialogHeader{display:flex;align-items:center;gap:8px}.sm-panelHeaderClose{min-height:26px;padding:2px 9px;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#555);cursor:pointer;font-size:12px;line-height:18px}.sm-panelHeaderClose:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#eee);color:var(--dsw-alias-label-primary,#111)}.sm-panelHeaderClose:disabled{opacity:.5;cursor:not-allowed}.sm-migrateCurrent{padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;background:var(--dsw-alias-fill-l2,#f5f5f5);font-size:13px;line-height:18px;color:var(--dsw-alias-label-primary,#111)}.sm-migrateConfirmLayer.sm-confirmDialog.sm-nativeDialogLayer{width:300px;inset:calc(50vh - 90px) auto auto calc(50vw + 308px)}.sm-migrateConfirmLayer.sm-confirmDialog.sm-nativeDialogLayer .sm-migrateDialog{position:relative;left:auto;top:auto;bottom:auto;transform:none;width:100%;max-width:none;max-height:min(70vh,520px);z-index:auto}.sm-migrateConfirmLayer .sm-migratePanel{min-width:0}.sm-migrateConfirmLayer .sm-nativeDialogButton.sm-nativeDialogConfirm:hover:not(:disabled){background-color:#4576f0;background-color:var(--dsw-alias-accent-primary-hover,#4576f0);color:#fff;border-color:#4576f0;border-color:var(--dsw-alias-accent-primary-hover,#4576f0);filter:none}",
195
+
195
196
  // Theme colors are scoped to the plugin dialogs. DSH exposes its
196
197
  // actual theme through `body[data-ds-dark-theme]` and color-scheme;
197
198
  // keeping these values local avoids changing DSH's global variables.
@@ -354,6 +355,33 @@ window.__ModuleLoader__.load({
354
355
  const warning = props.warning || "";
355
356
  const confirmLabel = props.confirmLabel || "确认";
356
357
  const cancelLabel = props.cancelLabel || "取消";
358
+ // Esc is handled at window level (issue #7): the layer-bound
359
+ // onKeyDown was unreachable when the row button that opened
360
+ // the dialog stayed focused. ConfirmDialog's portal to body
361
+ // makes it a separate subtree from the panel section, so
362
+ // keydown never bubbles through it. Enter was previously on
363
+ // the layer too, but that races with the focused Cancel /
364
+ // Confirm button's native Enter handler -- removing the layer
365
+ // Enter leaves Enter handling purely to the focused button.
366
+ const escStateRef = useRef({});
367
+ escStateRef.current = { busy, onCancel };
368
+ useEffect(() => {
369
+ if (!open) return;
370
+ const handler = (e) => {
371
+ if (e.key !== "Escape") return;
372
+ if (e.defaultPrevented) return;
373
+ const s = escStateRef.current;
374
+ if (s.busy) return;
375
+ e.preventDefault();
376
+ e.stopPropagation();
377
+ s.onCancel();
378
+ // See SessionManagerPanel for the rationale.
379
+ const ae = document.activeElement;
380
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
381
+ };
382
+ window.addEventListener("keydown", handler);
383
+ return () => window.removeEventListener("keydown", handler);
384
+ }, [open]);
357
385
  const R = (typeof window !== "undefined") ? window.__smReact : null;
358
386
  const createPortal = (R && typeof R.createPortal === "function") ? R.createPortal : null;
359
387
  const body = (typeof document === "undefined") ? null : document.body;
@@ -364,23 +392,14 @@ window.__ModuleLoader__.load({
364
392
  style: props.anchor && typeof props.anchor.top === "number"
365
393
  ? { position: "fixed", top: props.anchor.top + "px", left: props.anchor.left + "px", zIndex: 10001 }
366
394
  : null,
367
- role: "presentation",
368
- onKeyDown: (e) => {
369
- if (e.key === "Escape" && !busy) { e.preventDefault(); onCancel(); }
370
- else if (e.key === "Enter" && !busy) { e.preventDefault(); onConfirm(); }
371
- },
372
- tabIndex: -1
395
+ role: "presentation"
373
396
  },
374
- h("div", {
375
- className: "sm-nativeDialogBackdrop",
376
- onMouseDown: (e) => { if (e.target === e.currentTarget && !busy) onCancel(); }
377
- }),
397
+
378
398
  h("section", {
379
399
  className: "sm-nativeDialog",
380
400
  role: "dialog",
381
401
  "aria-modal": "true",
382
- "aria-labelledby": "sm-confirm-dialog-title",
383
- ref: (el) => { if (el && typeof el.focus === "function") el.focus(); }
402
+ "aria-labelledby": "sm-confirm-dialog-title"
384
403
  },
385
404
  h("div", { className: "sm-nativeDialogHeader" },
386
405
  h("h2", { id: "sm-confirm-dialog-title", className: "sm-nativeDialogTitle" }, title)
@@ -428,6 +447,33 @@ window.__ModuleLoader__.load({
428
447
  const cancelLabel = props.cancelLabel || t("confirm.cancel");
429
448
  const error = props.error || "";
430
449
  const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
450
+ // Esc handled at window level (issue #7) -- Enter stays on the
451
+ // layer because (a) it gates on selectedWorkspaceId which is set
452
+ // only by clicking a listbox item, and (b) racing Enter on the
453
+ // Cancel / Confirm buttons is benign since the layer Enter
454
+ // handler only fires when focus is inside the dialog subtree,
455
+ // which is exactly when the focused button's native Enter has
456
+ // already triggered its own click -- and in MoveDialog that
457
+ // click just toggles selection, never cancels or confirms.
458
+ const escStateRef = useRef({});
459
+ escStateRef.current = { busy, onCancel };
460
+ useEffect(() => {
461
+ if (!open) return;
462
+ const handler = (e) => {
463
+ if (e.key !== "Escape") return;
464
+ if (e.defaultPrevented) return;
465
+ const s = escStateRef.current;
466
+ if (s.busy) return;
467
+ e.preventDefault();
468
+ e.stopPropagation();
469
+ s.onCancel();
470
+ // See SessionManagerPanel for the rationale.
471
+ const ae = document.activeElement;
472
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
473
+ };
474
+ window.addEventListener("keydown", handler);
475
+ return () => window.removeEventListener("keydown", handler);
476
+ }, [open]);
431
477
  if (!open) return null;
432
478
 
433
479
  const list = (Array.isArray(props.workspaces) ? props.workspaces : [])
@@ -458,10 +504,8 @@ window.__ModuleLoader__.load({
458
504
  // Single cancel button (footer) + ESC handler. No "x" close button so the
459
505
  // user only sees one cancel entry point.
460
506
  const onKeyDown = (e) => {
461
- if (e.key === "Escape" && !busy) {
462
- e.preventDefault();
463
- onCancel();
464
- } else if (e.key === "Enter" && !busy && selectedWorkspaceId && list.length > 0) {
507
+ // Enter only -- Esc moved to a window-level listener (see above).
508
+ if (e.key === "Enter" && !busy && selectedWorkspaceId && list.length > 0) {
465
509
  e.preventDefault();
466
510
  onConfirm(selectedWorkspaceId);
467
511
  }
@@ -472,19 +516,14 @@ window.__ModuleLoader__.load({
472
516
  ? { position: "fixed", top: props.anchor.top + "px", left: props.anchor.left + "px", zIndex: 10001 }
473
517
  : null,
474
518
  role: "presentation",
475
- onKeyDown,
476
- tabIndex: -1
519
+ onKeyDown
477
520
  },
478
- h("div", {
479
- className: "sm-nativeDialogBackdrop",
480
- onMouseDown: (e) => { if (e.target === e.currentTarget && !busy) onCancel(); }
481
- }),
521
+
482
522
  h("section", {
483
523
  className: "sm-nativeDialog",
484
524
  role: "dialog",
485
525
  "aria-modal": "true",
486
- "aria-labelledby": "sm-move-dialog-title",
487
- ref: (el) => { if (el && typeof el.focus === "function") el.focus(); }
526
+ "aria-labelledby": "sm-move-dialog-title"
488
527
  },
489
528
  h("div", { className: "sm-nativeDialogHeader" },
490
529
  h("h2", { id: "sm-move-dialog-title", className: "sm-nativeDialogTitle" }, title)
@@ -543,6 +582,30 @@ window.__ModuleLoader__.load({
543
582
  });
544
583
  return () => { alive = false; };
545
584
  }, [open, sessionId]);
585
+ // Esc handled at window level (issue #7). MigratePresetDialog is
586
+ // a sibling of the panel layer in the panel's render tree, so
587
+ // the layer-bound onKeyDown (which the dialog relied on for
588
+ // Esc) was dead code -- focus never reached the dialog's own
589
+ // subtree because the row Migrate button stayed focused.
590
+ const escStateRef = useRef({});
591
+ escStateRef.current = { busy, onCancel };
592
+ useEffect(() => {
593
+ if (!open) return;
594
+ const handler = (e) => {
595
+ if (e.key !== "Escape") return;
596
+ if (e.defaultPrevented) return;
597
+ const s = escStateRef.current;
598
+ if (s.busy) return;
599
+ e.preventDefault();
600
+ e.stopPropagation();
601
+ s.onCancel();
602
+ // See SessionManagerPanel for the rationale.
603
+ const ae = document.activeElement;
604
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
605
+ };
606
+ window.addEventListener("keydown", handler);
607
+ return () => window.removeEventListener("keydown", handler);
608
+ }, [open]);
546
609
  if (!open) return null;
547
610
 
548
611
  const currentPreset = (scan && scan.ok && scan.row && (scan.row.finalPreset || scan.row.headerPreset)) || fallbackPreset || "—";
@@ -578,13 +641,9 @@ window.__ModuleLoader__.load({
578
641
  );
579
642
  return h("div", {
580
643
  className: "sm-nativeDialogLayer sm-confirmDialog sm-migrateConfirmLayer",
581
- role: "presentation",
582
- onKeyDown: (event) => { if (event.key === "Escape" && !busy) { event.preventDefault(); onCancel(); } }
644
+ role: "presentation"
583
645
  },
584
- h("div", {
585
- className: "sm-nativeDialogBackdrop",
586
- onMouseDown: (event) => { if (event.target === event.currentTarget && !busy) onCancel(); }
587
- }),
646
+
588
647
  h("section", {
589
648
  className: "sm-nativeDialog sm-migrateDialog",
590
649
  role: "dialog",
@@ -746,30 +805,48 @@ window.__ModuleLoader__.load({
746
805
  setMigrateFor(null);
747
806
  onClose();
748
807
  };
749
- // Single cancel button in the panel footer (no native "x").
750
- const onPanelKey = (e) => {
751
- if (e.key === "Escape" && busyId === null) {
752
- if (confirmFor || moveFor || migrateFor) return;
808
+ // Esc is handled at window level (issue #7): the original layer-
809
+ // bound onKeyDown was unreachable when focus stayed on the sidebar
810
+ // toggle button that opened the panel -- that button is a sibling
811
+ // of the panel layer, so keydown never bubbles into the panel
812
+ // subtree. The state ref lets the listener see the latest values
813
+ // without re-subscribing on every render. The panel listener
814
+ // returns early when any inner dialog is open so the child
815
+ // dialog's own window listener gets the first shot at Esc.
816
+ const escStateRef = useRef({});
817
+ escStateRef.current = { busyId, confirmFor, moveFor, migrateFor, onPanelClose };
818
+ useEffect(() => {
819
+ if (!open) return;
820
+ const handler = (e) => {
821
+ if (e.key !== "Escape") return;
822
+ if (e.defaultPrevented) return;
823
+ const s = escStateRef.current;
824
+ if (s.busyId !== null) return;
825
+ if (s.confirmFor || s.moveFor || s.migrateFor) return;
753
826
  e.preventDefault();
754
- onPanelClose();
755
- }
756
- };
827
+ e.stopPropagation();
828
+ s.onPanelClose();
829
+ // Esc after a keyboard-driven close leaves the originally-
830
+ // focused trigger button (sidebar toggle / row button) with
831
+ // :focus-visible still active -- blur it so no focus ring
832
+ // lingers after the panel closes. Mouse-driven closes are
833
+ // not affected: :focus-visible only activates for keyboard
834
+ // focus, so the ring never shows in that path.
835
+ const ae = document.activeElement;
836
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
837
+ };
838
+ window.addEventListener("keydown", handler);
839
+ return () => window.removeEventListener("keydown", handler);
840
+ }, [open]);
757
841
  const panelModal = h("div", {
758
842
  className: "sm-nativeDialogLayer",
759
- role: "presentation",
760
- onKeyDown: onPanelKey,
761
- tabIndex: -1
843
+ role: "presentation"
762
844
  },
763
- h("div", {
764
- className: "sm-nativeDialogBackdrop",
765
- onMouseDown: (e) => { if (e.target === e.currentTarget && busyId === null) onPanelClose(); }
766
- }),
767
845
  h("section", {
768
846
  className: "sm-nativeDialog sm-panelDialog",
769
847
  role: "dialog",
770
848
  "aria-modal": "true",
771
- "aria-labelledby": "sm-panel-dialog-title",
772
- ref: (el) => { if (el && typeof el.focus === "function") el.focus(); }
849
+ "aria-labelledby": "sm-panel-dialog-title"
773
850
  },
774
851
  h("div", { className: "sm-nativeDialogHeader" },
775
852
  h("h2", { id: "sm-panel-dialog-title", className: "sm-nativeDialogTitle" }, t("panel.title")),
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ import { mkdir, open, readdir, readFile, realpath, rename, rm, stat } from "node
29
29
  import { dirname, join } from "node:path";
30
30
  import { randomBytes } from "node:crypto";
31
31
  import { createDshAdapter } from "./compat/dsh-adapter.js";
32
+ import { decompressAllZstdFrames } from "./compat/zstd-frames.js";
32
33
 
33
34
  export const name = "dsh-session-manager";
34
35
 
@@ -57,6 +58,125 @@ function safeErrorMessage(error) {
57
58
  const API_PREFIX = "/session-manager/api";
58
59
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
59
60
 
61
+ /**
62
+ * Encode a session id the way DSH's persistence layer stores its project
63
+ * directory: separators and unsafe code units become "--<encoded>--" markers.
64
+ * This mirrors the projectKey helper inside the JSONL persistence backend.
65
+ * (The name encodeSessionSegment is a leftover from an earlier refactor;
66
+ * the function encodes a session id, which DSH derives from a workspace
67
+ * path; "project" + "segment" both refer to that same mapping.)
68
+ */
69
+ const encodeSessionSegment = (id) => {
70
+ let readable = "";
71
+ let separatorRun = false;
72
+ for (let i = 0; i < id.length; i++) {
73
+ const ch = id[i];
74
+ const code = id.charCodeAt(i);
75
+ if (ch === "/" || ch === "\\" || ch === ":") {
76
+ if (!separatorRun) readable += "-";
77
+ separatorRun = true;
78
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
79
+ readable += ch;
80
+ separatorRun = false;
81
+ } else {
82
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
83
+ separatorRun = false;
84
+ }
85
+ }
86
+ return "--" + (readable.replace(/^-+/, "") || "root").slice(0, 251) + "--";
87
+ };
88
+
89
+ /** List every persisted session header visible to the configured persistence
90
+ * backend, supplemented by raw-id sessions written by older DSH builds.
91
+ *
92
+ * Performance (issue #6): the disk loop previously opened and zstd-decoded
93
+ * every candidate file in <dshHome>/sessions/<proj>/<dir>/ even when the
94
+ * persistence layer had already returned those ids. On a healthy index
95
+ * that wasted O(N * file_size) read+inflate per call -- the reporter
96
+ * measured +17s CPU / +254 MB read per DSH startup on an 80-session /
97
+ * 249 MB library. We now pre-build a `knownDirs` set from the persisted
98
+ * ids (both `[id]` and `[encodeSessionSegment(id)]`) and skip matching
99
+ * directories before any fopen. Orphan raw-id directories that the
100
+ * index does not know about still fall through to the file read so the
101
+ * disk fallback semantics are preserved -- the orphan is the whole
102
+ * reason this loop exists.
103
+ *
104
+ * @param {object|undefined} persistence DSH's sessionPersistence service
105
+ * @param {((segment: string) => string)|undefined} dshHome Resolver for
106
+ * DSH home sub-paths; only "sessions" is read.
107
+ */
108
+ const listSessionHeaders = async (persistence, dshHome) => {
109
+ const seen = new Set();
110
+ const out = [];
111
+ if (persistence !== void 0 && typeof persistence.list === "function") {
112
+ try {
113
+ for (const header of await persistence.list()) {
114
+ if (header === void 0 || typeof header.id !== "string") continue;
115
+ seen.add(header.id);
116
+ out.push(header);
117
+ }
118
+ } catch { /* fall through to disk */ }
119
+ }
120
+
121
+ // Early-skip set for the disk fallback. For every id already in `seen`,
122
+ // both candidate directory names ([encodeSessionSegment(id), id]) are
123
+ // guaranteed to map back to that header; opening those files would only
124
+ // re-decode a payload we already have. Mark them up-front and skip with
125
+ // an O(1) Set lookup instead of an fopen + inflate + JSON.parse
126
+ // roundtrip. Genuine orphans (raw-id sessions the index does not
127
+ // surface) are not in `knownDirs` and still fall through to the file
128
+ // read below -- the disk fallback semantics are preserved.
129
+ const knownDirs = new Set();
130
+ for (const id of seen) {
131
+ knownDirs.add(id);
132
+ knownDirs.add(encodeSessionSegment(id));
133
+ }
134
+
135
+ if (typeof dshHome !== "function") return out;
136
+ const root = dshHome("sessions");
137
+ let projects;
138
+ try { projects = await readdir(root, { withFileTypes: true }); }
139
+ catch { return out; }
140
+ for (const proj of projects) {
141
+ if (!proj.isDirectory()) continue;
142
+ let dirs;
143
+ try { dirs = await readdir(join(root, proj.name), { withFileTypes: true }); }
144
+ catch { continue; }
145
+ for (const entry of dirs) {
146
+ if (!entry.isDirectory()) continue;
147
+ // Cheap directory-name skip -- the issue #6 hot path fix.
148
+ if (knownDirs.has(entry.name)) continue;
149
+ for (const filename of ["session.v3.jsonl.zstd", "session.v2.jsonl.zstd", "session.jsonl.zstd", "session.jsonl"]) {
150
+ const filePath = join(root, proj.name, entry.name, filename);
151
+ try {
152
+ const buf = await readFile(filePath);
153
+ let content;
154
+ if (filename.endsWith(".zstd")) {
155
+ // lib/compat/zstd-frames.js#decompressAllZstdFrames walks every
156
+ // complete concatenated frame (one frame per DSH batch) and
157
+ // concatenates the plaintexts. Throws on empty/header-less
158
+ // buffers and on corrupt headers; the outer catch swallows
159
+ // both so we move on to the next candidate filename.
160
+ try {
161
+ const { content: decoded } = await decompressAllZstdFrames(buf);
162
+ content = decoded.toString("utf8");
163
+ } catch {
164
+ continue;
165
+ }
166
+ } else {
167
+ content = buf.toString("utf8");
168
+ }
169
+ const headerLine = content.split("\n", 1)[0];
170
+ const meta = JSON.parse(headerLine);
171
+ if (typeof meta?.id !== "string" || seen.has(meta.id)) continue;
172
+ seen.add(meta.id);
173
+ out.push({ id: meta.id, cwd: meta.cwd, createdAt: meta.createdAt, version: meta.version, origin: meta.origin });
174
+ } catch { /* skip */ }
175
+ }
176
+ }
177
+ }
178
+ return out;
179
+ };
60
180
  export function apply(ctx) {
61
181
  const readBody = async (req) => {
62
182
  const chunks = [];
@@ -157,6 +277,7 @@ ${rest}`, "utf8");
157
277
  const moveSession = async (sessionId, targetWorkspaceId) => {
158
278
  const registry = ctx.workspaceRegistry;
159
279
  const persistence = ctx.get("sessionPersistence");
280
+ const dshHomePath = ctx.get("dshHomePath");
160
281
  if (persistence === void 0) throw new Error("sessionPersistence 服务不可用");
161
282
  if (typeof persistence.locate !== "function") {
162
283
  throw new Error("当前持久化后端无法定位会话工件,无法跨工作区移动");
@@ -170,8 +291,27 @@ ${rest}`, "utf8");
170
291
  }
171
292
  const targetPath = target.path;
172
293
 
173
- const storedHeaders = await listSessionHeaders(persistence);
174
- const storedHeader = storedHeaders.find((header) => header.id === sessionId);
294
+ // Fast path: the common case -- the session is already in the
295
+ // persistence index -- costs one .list() + .find(). The previous
296
+ // code called listSessionHeaders() unconditionally, which (before
297
+ // the issue #6 fix) re-read every file in the session library even
298
+ // when the index already had the answer. We still fall through to
299
+ // the disk fallback for raw-id orphans persistence.list() cannot
300
+ // surface (older DSH builds wrote plain-id directories that the
301
+ // index may not know about).
302
+ let storedHeader;
303
+ if (persistence !== void 0 && typeof persistence.list === "function") {
304
+ try {
305
+ const headers = await persistence.list();
306
+ storedHeader = headers.find((header) =>
307
+ header !== void 0 && typeof header.id === "string" && header.id === sessionId
308
+ );
309
+ } catch { /* fall through to disk fallback below */ }
310
+ }
311
+ if (storedHeader === void 0) {
312
+ const storedHeaders = await listSessionHeaders(persistence, dshHomePath);
313
+ storedHeader = storedHeaders.find((header) => header !== void 0 && header.id === sessionId);
314
+ }
175
315
  if (storedHeader === void 0) {
176
316
  const error = new Error(
177
317
  `会话 ${sessionId} 没有磁盘记录(不存在,或是一个尚未发送任何消息的空白会话),无法移动`
@@ -398,7 +538,7 @@ ${rest}`, "utf8");
398
538
  // failure that hides which session actually failed.
399
539
  let headers = [];
400
540
  try {
401
- headers = await listSessionHeaders(persistence);
541
+ headers = await listSessionHeaders(persistence, ctx.get("dshHomePath"));
402
542
  } catch (probeError) {
403
543
  throw new Error("failed to enumerate sessions: " + String(probeError.message ?? probeError));
404
544
  }
@@ -666,33 +806,7 @@ ${rest}`, "utf8");
666
806
  return result;
667
807
  };
668
808
 
669
- /**
670
- * Encode a workspace path the way DSH stores it as a project directory:
671
- * separators and unsafe code units become "--<encoded>--" markers.
672
- * This mirrors the projectKey helper inside the JSONL persistence backend.
673
- * (The name encodeSessionSegment is a leftover from an earlier refactor;
674
- * the function encodes a workspace path, not a session id.)
675
- */
676
- const encodeSessionSegment = (id) => {
677
- let readable = "";
678
- let separatorRun = false;
679
- for (let i = 0; i < id.length; i++) {
680
- const ch = id[i];
681
- const code = id.charCodeAt(i);
682
- if (ch === "/" || ch === "\\" || ch === ":") {
683
- if (!separatorRun) readable += "-";
684
- separatorRun = true;
685
- } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
686
- readable += ch;
687
- separatorRun = false;
688
- } else {
689
- readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
690
- separatorRun = false;
691
- }
692
- }
693
- return "--" + (readable.replace(/^-+/, "") || "root").slice(0, 251) + "--";
694
- };
695
- /** Read one session artifact verbatim, falling back to direct disk access when the
809
+ /** Read one session artifact verbatim, falling back to direct disk access when the
696
810
  * configured persistence backend cannot see the id (e.g. an older DSH build
697
811
  * wrote a "session.v2.jsonl.zstd" file under the raw-id directory).
698
812
  */
@@ -770,66 +884,6 @@ ${rest}`, "utf8");
770
884
  }
771
885
  }
772
886
  return void 0;
773
- };
774
- /** List every persisted session header visible to the configured persistence
775
- * backend, supplemented by raw-id sessions written by older DSH builds. */
776
- const listSessionHeaders = async (persistence) => {
777
- const seen = new Set();
778
- const out = [];
779
- if (persistence !== void 0 && typeof persistence.list === "function") {
780
- try {
781
- for (const header of await persistence.list()) {
782
- if (header === void 0 || typeof header.id !== "string") continue;
783
- seen.add(header.id);
784
- out.push(header);
785
- }
786
- } catch { /* fall through to disk */ }
787
- }
788
- const dshHomePath = ctx.get("dshHomePath");
789
- if (typeof dshHomePath !== "function") return out;
790
- const root = dshHomePath("sessions");
791
- let projects;
792
- try { projects = await readdir(root, { withFileTypes: true }); }
793
- catch { return out; }
794
- const { createRequire } = await import("node:module"); const require_ = createRequire(import.meta.url); const { zstdDecompressSync } = require_("node:zlib");
795
- for (const proj of projects) {
796
- if (!proj.isDirectory()) continue;
797
- let dirs;
798
- try { dirs = await readdir(join(root, proj.name), { withFileTypes: true }); }
799
- catch { continue; }
800
- for (const entry of dirs) {
801
- if (!entry.isDirectory()) continue;
802
- for (const filename of ["session.v3.jsonl.zstd", "session.v2.jsonl.zstd", "session.jsonl.zstd", "session.jsonl"]) {
803
- const filePath = join(root, proj.name, entry.name, filename);
804
- try {
805
- const buf = await readFile(filePath);
806
- let content;
807
- if (filename.endsWith(".zstd")) {
808
- const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
809
- const plaintexts = [];
810
- let pos = 0;
811
- while (pos < buf.length) {
812
- const next = buf.indexOf(ZSTD_MAGIC, pos);
813
- if (next < 0) break;
814
- try { plaintexts.push(zstdDecompressSync(buf.subarray(next))); }
815
- catch { break; }
816
- pos = next + 4;
817
- }
818
- if (plaintexts.length === 0) continue;
819
- content = Buffer.concat(plaintexts).toString("utf8");
820
- } else {
821
- content = buf.toString("utf8");
822
- }
823
- const headerLine = content.split("\n", 1)[0];
824
- const meta = JSON.parse(headerLine);
825
- if (typeof meta?.id !== "string" || seen.has(meta.id)) continue;
826
- seen.add(meta.id);
827
- out.push({ id: meta.id, cwd: meta.cwd, createdAt: meta.createdAt, version: meta.version, origin: meta.origin });
828
- } catch { /* skip */ }
829
- }
830
- }
831
- }
832
- return out;
833
887
  };
834
888
  const sessionDirOf = async (sessionId) => {
835
889
  const persistence = ctx.get("sessionPersistence");
@@ -958,7 +1012,7 @@ ${rest}`, "utf8");
958
1012
  // ---- step 1: re-fold every active session's projection cache row,
959
1013
  // so a session moved before coldSnapshot was wired in stops being
960
1014
  // identity-stuck under a stale {createdAt, cwd} key.
961
- const headers = await listSessionHeaders(persistence);
1015
+ const headers = await listSessionHeaders(persistence, ctx.get("dshHomePath"));
962
1016
  let refolded = 0;
963
1017
  const headerById = new Map();
964
1018
  for (const header of headers) {
@@ -1149,3 +1203,5 @@ if (req.method === "GET" && path === "/preset-scan") {
1149
1203
 
1150
1204
 
1151
1205
  }
1206
+
1207
+ export { listSessionHeaders, encodeSessionSegment };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-manager",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Session manager for the DeepSeek Harness Web UI: delete sessions, archive sessions, move sessions across workspaces, and migrate a session’s Agent preset. Suggestions are welcome on GitHub. 在 DeepSeek Harness Web 中进行会话管理,包括:删除会话、归档会话、跨工作区移动会话、迁移会话的 Agent 预设。欢迎至GitHub提意见。",
5
5
  "license": "MIT",
6
6
  "keywords": [