@kabmo-labs/kabmo 0.8.0 → 0.9.0
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.md +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +314 -314
- package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +9 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +47 -67
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +19 -24
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
- package/src/client/hooks/ui/updates/shell-download.ts +55 -14
- package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +20 -5
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +37 -8
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/fs/worktree/create.ts +2 -3
- package/src/server/lib/fs/worktree/list.ts +13 -0
- package/src/server/lib/fs/worktree/remove.ts +3 -3
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.ts +94 -3
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +33 -2
- package/src/server/lib/updates/shell-pages.ts +17 -5
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +10 -27
- package/src/server/lib/updates/shell-release.ts +107 -111
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +29 -7
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/list.ts +11 -1
- package/src/server/routes/worktrees/routes.test.ts +17 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +4 -4
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-gtbpewr7.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which copy wins when a session's stored blob arrives.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `store.ts` for the file-size ceiling, and it is a coherent unit
|
|
10
|
+
* rather than an arbitrary cut: both halves answer the same question, and the
|
|
11
|
+
* store is the only caller. The store owns the CACHE (an entry's lifetime, the
|
|
12
|
+
* persist debounce, eviction); this owns the DECISION (a response describes the
|
|
13
|
+
* row as it was when the request arrived, so anything written since is newer).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type SessionState = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A monotonic counter bumped by every local write, and the counter value each
|
|
20
|
+
* `(session, key)` was last written at.
|
|
21
|
+
*
|
|
22
|
+
* This exists for ONE race, and `dirtySessions` cannot cover it: a load's
|
|
23
|
+
* response is served from a snapshot taken when the request arrived, while a
|
|
24
|
+
* write made in the meantime persists on its own schedule. Once that persist
|
|
25
|
+
* succeeds the dirty flag is cleared — so a response that arrives after it but
|
|
26
|
+
* was computed before it would be adopted as-is and silently drop the write.
|
|
27
|
+
* Measured in the browser (a rare e2e failure, `outline.e2e.ts`): a file opened
|
|
28
|
+
* while the page's first `/state` read was in flight came back with
|
|
29
|
+
* `layout.openedFiles` gone, so the editor drew "Select a file to open" over a
|
|
30
|
+
* file the user had just clicked.
|
|
31
|
+
*
|
|
32
|
+
* `loadSession` records the counter when it STARTS and merges exactly the keys
|
|
33
|
+
* written after that point, which is what makes "wrote it while the read was in
|
|
34
|
+
* flight" survive regardless of when the persist landed.
|
|
35
|
+
*/
|
|
36
|
+
let writeSeq = 0;
|
|
37
|
+
const writeLog = new Map<string, Map<string, number>>();
|
|
38
|
+
|
|
39
|
+
/** The counter as of now — a read takes this before it issues its request. */
|
|
40
|
+
export function currentWriteSeq(): number {
|
|
41
|
+
return writeSeq;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Record that `key` was written locally just now. */
|
|
45
|
+
export function noteWrite(sessionId: string, key: string): void {
|
|
46
|
+
writeSeq += 1;
|
|
47
|
+
let perSession = writeLog.get(sessionId);
|
|
48
|
+
if (!perSession) {
|
|
49
|
+
perSession = new Map();
|
|
50
|
+
writeLog.set(sessionId, perSession);
|
|
51
|
+
}
|
|
52
|
+
perSession.set(key, writeSeq);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The keys of `sessionId` written after `since` (strictly later). */
|
|
56
|
+
export function keysWrittenAfter(sessionId: string, since: number): Set<string> {
|
|
57
|
+
const written = new Set<string>();
|
|
58
|
+
const perSession = writeLog.get(sessionId);
|
|
59
|
+
if (!perSession) return written;
|
|
60
|
+
for (const [key, seq] of perSession) {
|
|
61
|
+
if (seq > since) written.add(key);
|
|
62
|
+
}
|
|
63
|
+
return written;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Drop a session's write history — evicted, forgotten or adopted away. */
|
|
67
|
+
export function forgetWrites(sessionId: string): void {
|
|
68
|
+
writeLog.delete(sessionId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The blob to cache, given the response, the local entry, the keys written
|
|
73
|
+
* while the read was in flight, and whether the entry is still dirty.
|
|
74
|
+
*
|
|
75
|
+
* Only the keys written during the window win over the stored blob. Merging the
|
|
76
|
+
* WHOLE local cache over the blob (the old behavior) resurrected stale values on
|
|
77
|
+
* every tab: a second browser opening the session had cached the pre-write blob,
|
|
78
|
+
* and its stale copy clobbered another tab's fresh writes — queue items written
|
|
79
|
+
* in one tab never appeared in the other.
|
|
80
|
+
*/
|
|
81
|
+
export function mergeStoredBlob(
|
|
82
|
+
incoming: SessionState,
|
|
83
|
+
existing: SessionState,
|
|
84
|
+
written: Set<string>,
|
|
85
|
+
dirty: boolean,
|
|
86
|
+
): SessionState {
|
|
87
|
+
// A dirty entry has a persist that has not landed: the whole local copy is
|
|
88
|
+
// newer than the response.
|
|
89
|
+
if (dirty) return { ...incoming, ...existing };
|
|
90
|
+
if (written.size === 0) return incoming;
|
|
91
|
+
const merged: SessionState = { ...incoming };
|
|
92
|
+
for (const key of written) {
|
|
93
|
+
// A key the local side still holds wins outright; one it no longer holds
|
|
94
|
+
// was CLEARED during the read (`clearSessionKey`), so the response's copy is
|
|
95
|
+
// the stale one and must not resurrect it.
|
|
96
|
+
if (key in existing) merged[key] = existing[key];
|
|
97
|
+
else delete merged[key];
|
|
98
|
+
}
|
|
99
|
+
return merged;
|
|
100
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The blob-arrival half of the session-state store: what wins when a stored
|
|
8
|
+
* blob lands, and what a read must not overwrite.
|
|
9
|
+
*
|
|
10
|
+
* Split from `store.test.ts` for the per-file ceiling. The contract pinned here
|
|
11
|
+
* is the one that produced two measured bugs:
|
|
12
|
+
*
|
|
13
|
+
* - A response is served from a snapshot taken when the request arrived, so a
|
|
14
|
+
* key written WHILE the read was in flight is newer — even once its own
|
|
15
|
+
* persist landed and cleared the dirty flag. Without the write counter, a
|
|
16
|
+
* file opened during the page's first `/state` read came back with
|
|
17
|
+
* `layout.openedFiles` gone.
|
|
18
|
+
* - A key CLEARED in that window must not be resurrected by a response that
|
|
19
|
+
* still carries it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test } from 'bun:test';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
clearSessionKey,
|
|
26
|
+
flushSession,
|
|
27
|
+
getSessionValue,
|
|
28
|
+
hydrateSession,
|
|
29
|
+
loadSession,
|
|
30
|
+
setSessionKey,
|
|
31
|
+
} from '@/shared/lib/workspace/session-state/store';
|
|
32
|
+
import { counter, follow, installStoreTeardown, use } from '@/shared/lib/workspace/session-state/harness.test-util';
|
|
33
|
+
|
|
34
|
+
installStoreTeardown();
|
|
35
|
+
|
|
36
|
+
describe('session-state store: blob arrival (loadSession)', () => {
|
|
37
|
+
function stubWindow(): void {
|
|
38
|
+
Reflect.set(globalThis, 'window', {});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stubFetch(response: unknown): void {
|
|
42
|
+
Reflect.set(globalThis, 'fetch', async () => response);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A fetch stub that HOLDS the blob read and lets writes through.
|
|
47
|
+
*
|
|
48
|
+
* Gating every call deadlocks the case: the write's own persist is a `POST`
|
|
49
|
+
* to the same path, and a stub that awaits the gate for both means the flush
|
|
50
|
+
* can never complete while the read is held.
|
|
51
|
+
*/
|
|
52
|
+
function gateReads(state: unknown): () => void {
|
|
53
|
+
const { promise: gate, resolve } = Promise.withResolvers<void>();
|
|
54
|
+
Reflect.set(globalThis, 'fetch', async (_url: string, init?: { method?: string }) => {
|
|
55
|
+
if (init?.method === 'POST') return { ok: true, json: async () => ({}) };
|
|
56
|
+
await gate;
|
|
57
|
+
return { ok: true, json: async () => ({ state }) };
|
|
58
|
+
});
|
|
59
|
+
return resolve;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('a clean session adopts the stored blob and notifies', async () => {
|
|
63
|
+
const id = use('load-clean');
|
|
64
|
+
stubWindow();
|
|
65
|
+
stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
|
|
66
|
+
const probe = counter();
|
|
67
|
+
follow(id, null, probe.listener);
|
|
68
|
+
|
|
69
|
+
await loadSession(id);
|
|
70
|
+
expect<number | undefined>(getSessionValue(id, 'a')).toBe(1);
|
|
71
|
+
expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
|
|
72
|
+
expect(probe.calls).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a dirty session keeps its local writes over the blob', async () => {
|
|
76
|
+
const id = use('load-dirty');
|
|
77
|
+
stubWindow();
|
|
78
|
+
setSessionKey(id, 'a', 99);
|
|
79
|
+
stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
|
|
80
|
+
|
|
81
|
+
await loadSession(id);
|
|
82
|
+
expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'a')).toBe(99);
|
|
83
|
+
expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a non-ok response leaves the cache untouched but still notifies', async () => {
|
|
87
|
+
const id = use('load-error');
|
|
88
|
+
stubWindow();
|
|
89
|
+
stubFetch({ ok: false, json: async () => ({}) });
|
|
90
|
+
const probe = counter();
|
|
91
|
+
follow(id, null, probe.listener);
|
|
92
|
+
|
|
93
|
+
await loadSession(id);
|
|
94
|
+
expect(getSessionValue(id, 'a')).toBeUndefined();
|
|
95
|
+
expect(probe.calls).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a rejected fetch is swallowed and notifies', async () => {
|
|
99
|
+
const id = use('load-throw');
|
|
100
|
+
stubWindow();
|
|
101
|
+
Reflect.set(globalThis, 'fetch', async () => {
|
|
102
|
+
throw new Error('offline');
|
|
103
|
+
});
|
|
104
|
+
const probe = counter();
|
|
105
|
+
follow(id, null, probe.listener);
|
|
106
|
+
|
|
107
|
+
// The store logs the failure; keep the expected warning out of the run log.
|
|
108
|
+
const warn = console.warn;
|
|
109
|
+
console.warn = () => {};
|
|
110
|
+
try {
|
|
111
|
+
await loadSession(id);
|
|
112
|
+
} finally {
|
|
113
|
+
console.warn = warn;
|
|
114
|
+
}
|
|
115
|
+
expect(getSessionValue(id, 'a')).toBeUndefined();
|
|
116
|
+
expect(probe.calls).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('loadSession without a window returns early: no fetch, no notify', async () => {
|
|
120
|
+
const id = use('load-nowindow');
|
|
121
|
+
// Removed for the case and put back after it: another file's hook can have
|
|
122
|
+
// installed one while this file runs, and leaving it missing would break
|
|
123
|
+
// whichever case follows.
|
|
124
|
+
const hadWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
125
|
+
Reflect.deleteProperty(globalThis, 'window');
|
|
126
|
+
let fetched = false;
|
|
127
|
+
Reflect.set(globalThis, 'fetch', async () => {
|
|
128
|
+
fetched = true;
|
|
129
|
+
throw new Error('must not be called');
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
const probe = counter();
|
|
133
|
+
follow(id, null, probe.listener);
|
|
134
|
+
await loadSession(id);
|
|
135
|
+
expect(fetched).toBe(false);
|
|
136
|
+
expect(probe.calls).toBe(0);
|
|
137
|
+
} finally {
|
|
138
|
+
if (hadWindow) Object.defineProperty(globalThis, 'window', hadWindow);
|
|
139
|
+
Reflect.set(globalThis, 'fetch', Bun.fetch);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A write that lands WHILE the read is in flight must survive the response,
|
|
145
|
+
* even when its own persist already landed (so the dirty flag is clear).
|
|
146
|
+
*
|
|
147
|
+
* This is the race behind a rare e2e failure: the page's first `/state` read
|
|
148
|
+
* is served from a snapshot taken before the click, the click writes and
|
|
149
|
+
* persists, the persist clears `dirty`, and the late response was then adopted
|
|
150
|
+
* wholesale — `layout.openedFiles` came back empty and the editor drew
|
|
151
|
+
* "Select a file to open" over the file the user had just opened.
|
|
152
|
+
*/
|
|
153
|
+
test('a key written during the read wins over the response, even once it persisted', async () => {
|
|
154
|
+
const id = use('load-inflight');
|
|
155
|
+
stubWindow();
|
|
156
|
+
const resolve = gateReads({ a: 1, b: 2 });
|
|
157
|
+
|
|
158
|
+
const pending = loadSession(id);
|
|
159
|
+
// The write and its persist both land while the response is held.
|
|
160
|
+
setSessionKey(id, 'a', 99);
|
|
161
|
+
await flushSession(id);
|
|
162
|
+
resolve();
|
|
163
|
+
await pending;
|
|
164
|
+
|
|
165
|
+
expect<number | undefined>(getSessionValue(id, 'a')).toBe(99);
|
|
166
|
+
// The rest of the blob is still adopted: only the written key is protected.
|
|
167
|
+
expect<number | undefined>(getSessionValue(id, 'b')).toBe(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a key untouched during the read is adopted from the response', async () => {
|
|
171
|
+
const id = use('load-untouched');
|
|
172
|
+
stubWindow();
|
|
173
|
+
const resolve = gateReads({ a: 1 });
|
|
174
|
+
|
|
175
|
+
const pending = loadSession(id);
|
|
176
|
+
// A DIFFERENT key, written and persisted during the same window.
|
|
177
|
+
setSessionKey(id, 'other', true);
|
|
178
|
+
await flushSession(id);
|
|
179
|
+
resolve();
|
|
180
|
+
await pending;
|
|
181
|
+
|
|
182
|
+
expect<number | undefined>(getSessionValue(id, 'a')).toBe(1);
|
|
183
|
+
expect<boolean | undefined>(getSessionValue(id, 'other')).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('a cleared key is not resurrected by a response that still carries it', async () => {
|
|
187
|
+
const id = use('load-cleared');
|
|
188
|
+
stubWindow();
|
|
189
|
+
hydrateSession(id, { retired: 'old', keep: 1 });
|
|
190
|
+
const resolve = gateReads({ retired: 'old', keep: 1 });
|
|
191
|
+
|
|
192
|
+
const pending = loadSession(id);
|
|
193
|
+
clearSessionKey(id, 'retired');
|
|
194
|
+
await flushSession(id);
|
|
195
|
+
resolve();
|
|
196
|
+
await pending;
|
|
197
|
+
|
|
198
|
+
expect(getSessionValue(id, 'retired')).toBeUndefined();
|
|
199
|
+
expect<number | undefined>(getSessionValue(id, 'keep')).toBe(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('flushSession is a resolved no-op without a window or id', async () => {
|
|
203
|
+
await expect(flushSession(null)).resolves.toBeUndefined();
|
|
204
|
+
await expect(flushSession(use('flush'))).resolves.toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -6,63 +6,37 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Pins the session-state store's observer contract and cache lifecycle.
|
|
8
8
|
*
|
|
9
|
-
* The store is
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* The store is the single source of a session's UI state: every panel reads and
|
|
10
|
+
* writes through it, and it persists the whole blob through
|
|
11
|
+
* `GET/POST /api/sessions/:sessionId/state`. Two contracts matter enough to pin
|
|
12
|
+
* here rather than in a component test:
|
|
13
|
+
*
|
|
14
|
+
* - **Notification routing.** A write notifies the slot's followers and the
|
|
15
|
+
* session-wide ones, never another session's, and an unsubscribed listener
|
|
16
|
+
* stops receiving while an in-flight pass still reaches it.
|
|
17
|
+
* - **Cache lifecycle.** The cache is bounded (10 sessions) and TTL'd (10
|
|
18
|
+
* minutes), and eviction never takes a session with unsaved writes.
|
|
19
|
+
*
|
|
20
|
+
* The blob-arrival half — the write-during-read race — is `store-load.test.ts`,
|
|
21
|
+
* and the wholesale-replacement bus is `replacement.test.ts`.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
import {
|
|
24
|
+
import { describe, expect, setSystemTime, test } from 'bun:test';
|
|
19
25
|
|
|
20
|
-
import { notifySessionKey
|
|
26
|
+
import { notifySessionKey } from '@/shared/lib/workspace/session-state/listeners';
|
|
21
27
|
import {
|
|
22
28
|
clearSessionKey,
|
|
23
|
-
flushSession,
|
|
24
29
|
forgetSession,
|
|
25
30
|
getLastOpenedAt,
|
|
26
31
|
getSessionValue,
|
|
27
32
|
hydrateSession,
|
|
28
|
-
loadSession,
|
|
29
33
|
migrateSessionState,
|
|
30
34
|
recordSessionOpen,
|
|
31
35
|
setSessionKey,
|
|
32
36
|
} from '@/shared/lib/workspace/session-state/store';
|
|
37
|
+
import { counter, follow, installStoreTeardown, use } from '@/shared/lib/workspace/session-state/harness.test-util';
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
/** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
|
|
36
|
-
const originalFetch = Bun.fetch;
|
|
37
|
-
const touched: string[] = [];
|
|
38
|
-
const unsubs: Array<() => void> = [];
|
|
39
|
-
|
|
40
|
-
/** Register a session id so `afterEach` forgets it. */
|
|
41
|
-
function use(id: string): string {
|
|
42
|
-
touched.push(id);
|
|
43
|
-
return id;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Subscribe and auto-unsubscribe at teardown. */
|
|
47
|
-
function follow(sessionId: string | null, key: string | null, listener: () => void): () => void {
|
|
48
|
-
const unsubscribe = subscribeSessionKey(sessionId, key, listener);
|
|
49
|
-
unsubs.push(unsubscribe);
|
|
50
|
-
return unsubscribe;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Count how many times `notifySessionKey` reaches a listener. */
|
|
54
|
-
function counter(): { calls: number; listener: () => void } {
|
|
55
|
-
const state = { calls: 0, listener: () => { state.calls += 1; } };
|
|
56
|
-
return state;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
afterEach(() => {
|
|
60
|
-
for (const unsubscribe of unsubs.splice(0)) unsubscribe();
|
|
61
|
-
for (const id of touched.splice(0)) forgetSession(id);
|
|
62
|
-
Reflect.set(globalThis, 'window', originalWindow);
|
|
63
|
-
Reflect.set(globalThis, 'fetch', originalFetch);
|
|
64
|
-
setSystemTime();
|
|
65
|
-
});
|
|
39
|
+
installStoreTeardown();
|
|
66
40
|
|
|
67
41
|
describe('session-state store: read/write', () => {
|
|
68
42
|
test('a null session id is a no-op for reads and writes', () => {
|
|
@@ -211,102 +185,6 @@ describe('session-state store: notification routing', () => {
|
|
|
211
185
|
});
|
|
212
186
|
});
|
|
213
187
|
|
|
214
|
-
describe('session-state store: blob arrival (loadSession)', () => {
|
|
215
|
-
function stubWindow(): void {
|
|
216
|
-
Reflect.set(globalThis, 'window', {});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function stubFetch(response: unknown): void {
|
|
220
|
-
Reflect.set(globalThis, 'fetch', async () => response);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
test('a clean session adopts the stored blob and notifies', async () => {
|
|
224
|
-
const id = use('load-clean');
|
|
225
|
-
stubWindow();
|
|
226
|
-
stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
|
|
227
|
-
const probe = counter();
|
|
228
|
-
follow(id, null, probe.listener);
|
|
229
|
-
|
|
230
|
-
await loadSession(id);
|
|
231
|
-
expect<number | undefined>(getSessionValue(id, 'a')).toBe(1);
|
|
232
|
-
expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
|
|
233
|
-
expect(probe.calls).toBe(1);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test('a dirty session keeps its local writes over the blob', async () => {
|
|
237
|
-
const id = use('load-dirty');
|
|
238
|
-
stubWindow();
|
|
239
|
-
setSessionKey(id, 'a', 99);
|
|
240
|
-
stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
|
|
241
|
-
|
|
242
|
-
await loadSession(id);
|
|
243
|
-
expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'a')).toBe(99);
|
|
244
|
-
expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test('a non-ok response leaves the cache untouched but still notifies', async () => {
|
|
248
|
-
const id = use('load-error');
|
|
249
|
-
stubWindow();
|
|
250
|
-
stubFetch({ ok: false, json: async () => ({}) });
|
|
251
|
-
const probe = counter();
|
|
252
|
-
follow(id, null, probe.listener);
|
|
253
|
-
|
|
254
|
-
await loadSession(id);
|
|
255
|
-
expect(getSessionValue(id, 'a')).toBeUndefined();
|
|
256
|
-
expect(probe.calls).toBe(1);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
test('a rejected fetch is swallowed and notifies', async () => {
|
|
260
|
-
const id = use('load-throw');
|
|
261
|
-
stubWindow();
|
|
262
|
-
Reflect.set(globalThis, 'fetch', async () => {
|
|
263
|
-
throw new Error('offline');
|
|
264
|
-
});
|
|
265
|
-
const probe = counter();
|
|
266
|
-
follow(id, null, probe.listener);
|
|
267
|
-
|
|
268
|
-
// The store logs the failure; keep the expected warning out of the run log.
|
|
269
|
-
const warn = console.warn;
|
|
270
|
-
console.warn = () => {};
|
|
271
|
-
try {
|
|
272
|
-
await loadSession(id);
|
|
273
|
-
} finally {
|
|
274
|
-
console.warn = warn;
|
|
275
|
-
}
|
|
276
|
-
expect(getSessionValue(id, 'a')).toBeUndefined();
|
|
277
|
-
expect(probe.calls).toBe(1);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
test('loadSession without a window returns early: no fetch, no notify', async () => {
|
|
281
|
-
const id = use('load-nowindow');
|
|
282
|
-
// Removed for the case and put back after it: another file's hook can have
|
|
283
|
-
// installed one while this file runs, and leaving it missing would break
|
|
284
|
-
// whichever case follows.
|
|
285
|
-
const hadWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
286
|
-
Reflect.deleteProperty(globalThis, 'window');
|
|
287
|
-
let fetched = false;
|
|
288
|
-
Reflect.set(globalThis, 'fetch', async () => {
|
|
289
|
-
fetched = true;
|
|
290
|
-
throw new Error('must not be called');
|
|
291
|
-
});
|
|
292
|
-
try {
|
|
293
|
-
const probe = counter();
|
|
294
|
-
follow(id, null, probe.listener);
|
|
295
|
-
await loadSession(id);
|
|
296
|
-
expect(fetched).toBe(false);
|
|
297
|
-
expect(probe.calls).toBe(0);
|
|
298
|
-
} finally {
|
|
299
|
-
if (hadWindow) Object.defineProperty(globalThis, 'window', hadWindow);
|
|
300
|
-
Reflect.set(globalThis, 'fetch', Bun.fetch);
|
|
301
|
-
}
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test('flushSession is a resolved no-op without a window or id', async () => {
|
|
305
|
-
await expect(flushSession(null)).resolves.toBeUndefined();
|
|
306
|
-
await expect(flushSession(use('flush'))).resolves.toBeUndefined();
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
|
|
310
188
|
describe('session-state store: bounded cache', () => {
|
|
311
189
|
test('the oldest clean session is evicted past the size cap', () => {
|
|
312
190
|
const ids = Array.from({ length: 11 }, (_unused, index) => use(`lru-${index}`));
|