@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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which toolbar clusters fit on ONE row, and which go into the `⋯` menu.
|
|
8
|
+
*
|
|
9
|
+
* The editor and diff toolbars carry ten-plus controls and the editor panel is
|
|
10
|
+
* 320px wide on a 1440px screen. WRAPPING was the previous answer and it is the
|
|
11
|
+
* wrong one: a second row moves the document down by the toolbar's own height
|
|
12
|
+
* and reads as a second toolbar. A horizontal scroll is worse still — a clipped
|
|
13
|
+
* control is invisible and reads as "there is nothing more here".
|
|
14
|
+
*
|
|
15
|
+
* So: the clusters that do not fit move into a `⋯` menu, and a cluster can be
|
|
16
|
+
* PINNED to stay on the row at every width. Pinning is what keeps this from
|
|
17
|
+
* being a priority guess — the toolbar's visual order is not its priority order
|
|
18
|
+
* (Save is drawn first because that is where a reader looks for it, while the
|
|
19
|
+
* width modes sit last and are the reason the panel exists).
|
|
20
|
+
*
|
|
21
|
+
* Pure, and measured widths in: the caller lays the clusters out once and hands
|
|
22
|
+
* their rendered widths here. A table of guessed pixel widths drifts the moment
|
|
23
|
+
* a label, an icon or a font changes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Width the `⋯` trigger needs, including the gap before it. */
|
|
27
|
+
export const TOOLBAR_OVERFLOW_TRIGGER_PX = 30;
|
|
28
|
+
|
|
29
|
+
/** Gap between two clusters (`gap-x-1`). */
|
|
30
|
+
export const TOOLBAR_ITEM_GAP_PX = 4;
|
|
31
|
+
|
|
32
|
+
export interface ToolbarOverflowArgs {
|
|
33
|
+
/** Rendered width of each cluster, in row order. */
|
|
34
|
+
widths: number[];
|
|
35
|
+
/** Which clusters never leave the row. Same length as `widths`. */
|
|
36
|
+
pinned: boolean[];
|
|
37
|
+
/** Width the row actually has, or 0 before it is measured. */
|
|
38
|
+
available: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Which clusters to render on the row, in row order.
|
|
43
|
+
*
|
|
44
|
+
* An unmeasured row (available 0) keeps everything: hiding controls before the
|
|
45
|
+
* width is known flashes a menu on every mount, and the row is measured a
|
|
46
|
+
* layout effect later.
|
|
47
|
+
*/
|
|
48
|
+
export function toolbarVisibleClusters({ widths, pinned, available }: ToolbarOverflowArgs): boolean[] {
|
|
49
|
+
if (!(available > 0) || widths.length === 0) return widths.map(() => true);
|
|
50
|
+
|
|
51
|
+
const total = widths.reduce((sum, width) => sum + width, 0) + TOOLBAR_ITEM_GAP_PX * (widths.length - 1);
|
|
52
|
+
if (total <= available) return widths.map(() => true);
|
|
53
|
+
|
|
54
|
+
// Pinned clusters are placed FIRST and in full, because whether the row is
|
|
55
|
+
// wide enough for them is not a question — they stay and the row clips if it
|
|
56
|
+
// must. The unpinned ones then take whatever is left, in row order, once the
|
|
57
|
+
// `⋯` trigger's own width is set aside. Filling in one pass over row order
|
|
58
|
+
// instead would let an early unpinned control consume the space a pinned one
|
|
59
|
+
// later needs.
|
|
60
|
+
const shown = widths.map(() => false);
|
|
61
|
+
let used = 0;
|
|
62
|
+
let rendered = 0;
|
|
63
|
+
widths.forEach((width, index) => {
|
|
64
|
+
if (!pinned[index]) return;
|
|
65
|
+
used += width + (rendered > 0 ? TOOLBAR_ITEM_GAP_PX : 0);
|
|
66
|
+
rendered += 1;
|
|
67
|
+
shown[index] = true;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let budget = available - used - TOOLBAR_OVERFLOW_TRIGGER_PX - TOOLBAR_ITEM_GAP_PX;
|
|
71
|
+
widths.forEach((width, index) => {
|
|
72
|
+
if (pinned[index]) return;
|
|
73
|
+
const cost = width + (rendered > 0 ? TOOLBAR_ITEM_GAP_PX : 0);
|
|
74
|
+
if (cost > budget) return;
|
|
75
|
+
budget -= cost;
|
|
76
|
+
rendered += 1;
|
|
77
|
+
shown[index] = true;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return shown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when at least one cluster is going into the menu. */
|
|
84
|
+
export function toolbarHasOverflow(visible: boolean[]): boolean {
|
|
85
|
+
return visible.some((shown) => !shown);
|
|
86
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The architecture a DESKTOP shell runs as, and the artifact it installs itself
|
|
8
|
+
* from.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this exists at all
|
|
11
|
+
*
|
|
12
|
+
* Every desktop artifact is per-architecture (`Kabmo-0.2.0-mac-arm64.zip`,
|
|
13
|
+
* `Kabmo-0.2.0-win-x64-setup.exe`), and the console used to answer a desktop
|
|
14
|
+
* probe with the RELEASE PAGE precisely because it could not know which
|
|
15
|
+
* architecture the window asking was running on. That is correct for a link and
|
|
16
|
+
* useless for a self-update: handing an arm64 reader an x64 installer produces
|
|
17
|
+
* an app that does not launch, and on Windows an installer that refuses with
|
|
18
|
+
* "this installation package could not be opened".
|
|
19
|
+
*
|
|
20
|
+
* So a desktop shell reports its own architecture over the bridge, and the
|
|
21
|
+
* server names the ONE artifact. This module is the vocabulary both ends share,
|
|
22
|
+
* which is what stops the shell, the route and the asset table from spelling
|
|
23
|
+
* `x86_64`, `x64` and `amd64` for the same machine.
|
|
24
|
+
*
|
|
25
|
+
* ## Two spellings, one canonical token
|
|
26
|
+
*
|
|
27
|
+
* A shell reports the CANONICAL token (`arm64` / `x64`) because that is what the
|
|
28
|
+
* client and the route pass around. Each platform's release assets spell its
|
|
29
|
+
* architecture differently — Linux says `x86_64`, macOS and Windows say `x64` —
|
|
30
|
+
* and that mapping lives in ONE table (`SHELL_ASSETS`), on the server, next to
|
|
31
|
+
* the names it has to match. A shell that mapped its own spelling to the asset's
|
|
32
|
+
* would be a second copy of the same fact.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** A desktop architecture, in the vocabulary the console and the shells share. */
|
|
36
|
+
export type ShellArch = 'arm64' | 'x64';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read a canonical arch out of a string, or null.
|
|
40
|
+
*
|
|
41
|
+
* Lenient about the spellings a shell might report (a shell that read
|
|
42
|
+
* `std::env::consts::ARCH` says `x86_64`; one that read
|
|
43
|
+
* `RuntimeInformation.ProcessArchitecture` says `X64`), because a refused arch
|
|
44
|
+
* costs the reader the self-update and buys nothing: the value only chooses
|
|
45
|
+
* between two artifacts of the same product, and the artifact is then verified
|
|
46
|
+
* by the shell before it is installed.
|
|
47
|
+
*/
|
|
48
|
+
export function parseShellArch(value: string | null | undefined): ShellArch | null {
|
|
49
|
+
if (!value) return null;
|
|
50
|
+
const folded = value.trim().toLowerCase();
|
|
51
|
+
if (folded === 'arm64' || folded === 'aarch64') return 'arm64';
|
|
52
|
+
if (folded === 'x64' || folded === 'x86_64' || folded === 'amd64') return 'x64';
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
@@ -23,10 +23,10 @@ export type MobileShellKind = 'android' | 'ios';
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* The desktop shells. They carry NO User-Agent token — a desktop app is not
|
|
26
|
-
* downloaded from
|
|
27
|
-
*
|
|
28
|
-
* which is why they are a separate family:
|
|
29
|
-
* does not.
|
|
26
|
+
* downloaded from a page, so the server has nothing to parse per request. They
|
|
27
|
+
* report their version and their ARCHITECTURE over the shell bridge instead
|
|
28
|
+
* (`__kabmoDesktop`), which is why they are a separate family: both facts exist,
|
|
29
|
+
* the UA channel does not.
|
|
30
30
|
*/
|
|
31
31
|
export type DesktopShellKind = 'macos' | 'windows' | 'linux';
|
|
32
32
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The three width modes' table, asserted as data.
|
|
8
|
+
*
|
|
9
|
+
* Each case is a rule the layout depends on and that no component test would
|
|
10
|
+
* catch: `compact` must NOT hide the sidebar (that is `full`'s job, and the
|
|
11
|
+
* two buttons would be the same button if it did), the right panel is hidden
|
|
12
|
+
* by neither, and clicking an active mode returns to `normal` rather than
|
|
13
|
+
* doing nothing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_EDITOR_VIEW_MODE,
|
|
20
|
+
isEditorViewMode,
|
|
21
|
+
editorViewHides,
|
|
22
|
+
toggledEditorViewMode,
|
|
23
|
+
} from '@/shared/lib/workspace/editor-view-mode';
|
|
24
|
+
|
|
25
|
+
describe('editorViewHides', () => {
|
|
26
|
+
test('compact hides the chat only', () => {
|
|
27
|
+
expect(editorViewHides('compact', 'chat')).toBe(true);
|
|
28
|
+
expect(editorViewHides('compact', 'sidebar')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('full hides the chat AND the sidebar', () => {
|
|
32
|
+
expect(editorViewHides('full', 'chat')).toBe(true);
|
|
33
|
+
expect(editorViewHides('full', 'sidebar')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('normal hides nothing', () => {
|
|
37
|
+
expect(editorViewHides('normal', 'chat')).toBe(false);
|
|
38
|
+
expect(editorViewHides('normal', 'sidebar')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('toggledEditorViewMode', () => {
|
|
43
|
+
test('a mode button enters its mode', () => {
|
|
44
|
+
expect(toggledEditorViewMode('normal', 'compact')).toBe('compact');
|
|
45
|
+
expect(toggledEditorViewMode('normal', 'full')).toBe('full');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the OTHER mode switches rather than toggling off', () => {
|
|
49
|
+
expect(toggledEditorViewMode('compact', 'full')).toBe('full');
|
|
50
|
+
expect(toggledEditorViewMode('full', 'compact')).toBe('compact');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('clicking the active mode returns to normal', () => {
|
|
54
|
+
expect(toggledEditorViewMode('compact', 'compact')).toBe('normal');
|
|
55
|
+
expect(toggledEditorViewMode('full', 'full')).toBe('normal');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('isEditorViewMode', () => {
|
|
60
|
+
test('accepts the three modes and nothing else', () => {
|
|
61
|
+
expect(['normal', 'compact', 'full'].every(isEditorViewMode)).toBe(true);
|
|
62
|
+
expect(isEditorViewMode('fullscreen')).toBe(false);
|
|
63
|
+
expect(isEditorViewMode(undefined)).toBe(false);
|
|
64
|
+
expect(isEditorViewMode(null)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the fallback is the ordinary layout', () => {
|
|
68
|
+
expect(DEFAULT_EDITOR_VIEW_MODE).toBe('normal');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How wide the editor column is allowed to be, as ONE of three modes.
|
|
8
|
+
*
|
|
9
|
+
* `compact` and `full` both hide the chat timeline and let the editor take the
|
|
10
|
+
* width the chat had; `full` also hides the session sidebar. Neither touches
|
|
11
|
+
* the right panel: the point of the two modes is a wider DOCUMENT, and the
|
|
12
|
+
* right column is where the outline, search hits and the diff list live while
|
|
13
|
+
* it is being read. The third width — the existing `fixed inset-0` full
|
|
14
|
+
* SCREEN — is a different thing entirely (it covers the right panel too) and
|
|
15
|
+
* stays where it is, on its own button.
|
|
16
|
+
*
|
|
17
|
+
* The rules are pure so the layout, both toolbars and the tests read the same
|
|
18
|
+
* table: which columns a mode hides is data, never a condition at a call site.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type EditorViewMode = 'normal' | 'compact' | 'full';
|
|
22
|
+
|
|
23
|
+
/** The two modes a toolbar button can name. */
|
|
24
|
+
export type EditorViewModeTarget = 'compact' | 'full';
|
|
25
|
+
|
|
26
|
+
/** Session-state slot. Per session, like every other layout choice. */
|
|
27
|
+
export const EDITOR_VIEW_MODE_KEY = 'layout.editorViewMode';
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_EDITOR_VIEW_MODE: EditorViewMode = 'normal';
|
|
30
|
+
|
|
31
|
+
/** A stored value written by another build is not a mode this one can draw. */
|
|
32
|
+
export function isEditorViewMode(value: unknown): value is EditorViewMode {
|
|
33
|
+
return value === 'normal' || value === 'compact' || value === 'full';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a mode HIDES. `full` hides everything `compact` does plus the sidebar,
|
|
38
|
+
* so it is strictly wider — the two are ordered, not alternatives.
|
|
39
|
+
*/
|
|
40
|
+
export function editorViewHides(mode: EditorViewMode, column: 'chat' | 'sidebar'): boolean {
|
|
41
|
+
if (column === 'chat') return mode === 'compact' || mode === 'full';
|
|
42
|
+
return mode === 'full';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Clicking the mode a button already names returns to `normal`. */
|
|
46
|
+
export function toggledEditorViewMode(current: EditorViewMode, next: EditorViewModeTarget): EditorViewMode {
|
|
47
|
+
return current === next ? 'normal' : next;
|
|
48
|
+
}
|
|
@@ -124,6 +124,27 @@ describe('resolvePanelWidth', () => {
|
|
|
124
124
|
// panel keeps its minimum and the row clips, rather than collapsing.
|
|
125
125
|
expect(resolvePanelWidth({ ...base, available: 400, siblingWidth: 320, handleCount: 2 })).toBe(320);
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
test('a collapsed chat removes the floor AND the pair caps', () => {
|
|
129
|
+
const available = 2000;
|
|
130
|
+
// The ordinary layout would cap this at 0.6 × 2000 = 1200 (own cap) and at
|
|
131
|
+
// 0.7 × 2000 − 320 = 1080 (pair cap, a sibling reserving its floor), and the
|
|
132
|
+
// chat's 400 floor would bind at 1280. With no chat on screen there is no
|
|
133
|
+
// conversation to leave room for, so only the group's own width limits it.
|
|
134
|
+
const wanted = { fraction: 0.9 };
|
|
135
|
+
expect(resolvePanelWidth({ ...base, available, siblingWidth: 320, handleCount: 1, stored: wanted })).toBe(
|
|
136
|
+
Math.round(PAIR_MAX_FRACTION * available),
|
|
137
|
+
);
|
|
138
|
+
expect(resolvePanelWidth({ ...base, available, siblingWidth: 320, handleCount: 1, stored: wanted, chatHidden: true })).toBe(
|
|
139
|
+
available - 320 - 1 * 3,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('the floor still wins with the chat collapsed', () => {
|
|
144
|
+
// A group narrower than the sibling and this panel's floor: the panel keeps
|
|
145
|
+
// its minimum rather than collapsing into nothing.
|
|
146
|
+
expect(resolvePanelWidth({ ...base, available: 400, siblingWidth: 320, handleCount: 1, chatHidden: true })).toBe(320);
|
|
147
|
+
});
|
|
127
148
|
});
|
|
128
149
|
|
|
129
150
|
describe('the editor/right-panel pair', () => {
|
|
@@ -273,4 +294,13 @@ describe('pairMaxSize (the drag cap)', () => {
|
|
|
273
294
|
test('no measured area means no cap', () => {
|
|
274
295
|
expect(pairMaxSize({ available: null, siblingMin: editorMin, siblingOpen: true })).toBeUndefined();
|
|
275
296
|
});
|
|
297
|
+
|
|
298
|
+
test('a collapsed chat lets a drag reach the whole row', () => {
|
|
299
|
+
// Two panels and nothing else: the editor may be dragged up to the group's
|
|
300
|
+
// width minus the right panel's floor. The 0.6 own-cap would have frozen the
|
|
301
|
+
// separator at 2400 of 4000 with 1600px of empty row behind it.
|
|
302
|
+
expect(pairMaxSize({ available, siblingMin: rightMin, siblingOpen: true, chatHidden: true })).toBe(available - rightMin);
|
|
303
|
+
// The sibling closed means nothing to reserve at all.
|
|
304
|
+
expect(pairMaxSize({ available, siblingMin: 0, siblingOpen: false, chatHidden: true })).toBe(available);
|
|
305
|
+
});
|
|
276
306
|
});
|
|
@@ -149,6 +149,13 @@ export interface ResolveWidthArgs {
|
|
|
149
149
|
siblingWidth: number;
|
|
150
150
|
/** Separators in the group, which also consume width. */
|
|
151
151
|
handleCount: number;
|
|
152
|
+
/**
|
|
153
|
+
* The CHAT column is collapsed (`compact`/`full` editor views), so it holds no
|
|
154
|
+
* floor and the pair caps do not apply — both exist to leave the conversation
|
|
155
|
+
* a share of the group, and neither has anything to reserve once it is off
|
|
156
|
+
* screen. Keeping them would cap a two-panel layout at 70% of its width.
|
|
157
|
+
*/
|
|
158
|
+
chatHidden?: boolean;
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
/**
|
|
@@ -172,7 +179,7 @@ export interface ResolveWidthArgs {
|
|
|
172
179
|
* default pixel width is used unchanged.
|
|
173
180
|
*/
|
|
174
181
|
export function resolvePanelWidth(args: ResolveWidthArgs): number {
|
|
175
|
-
const { stored, defaultFraction, defaultPx, available, min, pairReservePx = 0, siblingWidth, handleCount } = args;
|
|
182
|
+
const { stored, defaultFraction, defaultPx, available, min, pairReservePx = 0, siblingWidth, handleCount, chatHidden = false } = args;
|
|
176
183
|
|
|
177
184
|
if (available === null || available <= 0) return Math.max(min, Math.round(stored?.px ?? defaultPx));
|
|
178
185
|
|
|
@@ -182,14 +189,20 @@ export function resolvePanelWidth(args: ResolveWidthArgs): number {
|
|
|
182
189
|
// `total × available − the sibling's reserved px` — and ROUNDED, because
|
|
183
190
|
// `0.7 × 4000` is `2799.9999…` in binary floating point and flooring it lands
|
|
184
191
|
// a pixel short of the intended share.
|
|
185
|
-
|
|
186
|
-
|
|
192
|
+
//
|
|
193
|
+
// With the chat COLLAPSED neither cap means anything: the pair's 70% share
|
|
194
|
+
// and the chat floor are both about leaving the conversation room, and there
|
|
195
|
+
// is no conversation on screen. Only the group's own width remains a limit,
|
|
196
|
+
// which is what lets the editor take the width the chat had.
|
|
197
|
+
const ownCap = chatHidden ? Number.MAX_SAFE_INTEGER : Math.round(PAIR_MAX_FRACTION * available);
|
|
198
|
+
const pairCap = chatHidden ? Number.MAX_SAFE_INTEGER : Math.round(PAIR_MAX_TOTAL_FRACTION * available) - pairReservePx;
|
|
199
|
+
const chatFloor = chatHidden ? 0 : MIN_CHAT_PANEL_WIDTH;
|
|
187
200
|
const ceiling = Math.max(
|
|
188
201
|
min,
|
|
189
202
|
Math.min(
|
|
190
203
|
ownCap,
|
|
191
204
|
pairCap,
|
|
192
|
-
Math.floor(available -
|
|
205
|
+
Math.floor(available - chatFloor - siblingWidth - handleCount * HANDLE_WIDTH),
|
|
193
206
|
),
|
|
194
207
|
);
|
|
195
208
|
|
|
@@ -263,9 +276,16 @@ export function pairMaxSize(args: {
|
|
|
263
276
|
siblingMin: number;
|
|
264
277
|
/** The sibling is open and therefore reserves its floor from the pair total. */
|
|
265
278
|
siblingOpen: boolean;
|
|
279
|
+
/** The chat column is collapsed, so the pair's shared share is the whole group. */
|
|
280
|
+
chatHidden?: boolean;
|
|
266
281
|
}): number | undefined {
|
|
267
|
-
const { available, siblingMin, siblingOpen } = args;
|
|
282
|
+
const { available, siblingMin, siblingOpen, chatHidden = false } = args;
|
|
268
283
|
if (available === null || available <= 0) return undefined;
|
|
284
|
+
// No chat on screen means nothing to reserve a share FOR: the pair is the
|
|
285
|
+
// whole row, so each side may be dragged up to the group's width minus the
|
|
286
|
+
// sibling's floor. The 60% own-cap would freeze a separator halfway across a
|
|
287
|
+
// layout whose only two panels are the ones being dragged.
|
|
288
|
+
if (chatHidden) return Math.max(0, Math.floor(available - (siblingOpen ? siblingMin : 0)));
|
|
269
289
|
const ownCap = Math.round(PAIR_MAX_FRACTION * available);
|
|
270
290
|
if (!siblingOpen) return ownCap;
|
|
271
291
|
const pairCap = Math.round(PAIR_MAX_TOTAL_FRACTION * available) - siblingMin;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The session-state cache: which entries are held, and for how long.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `store.ts` for the file-size ceiling, and it is the natural seam
|
|
10
|
+
* rather than an arbitrary cut. `store.ts` owns the PUBLIC API (read, write,
|
|
11
|
+
* load, migrate) and the decisions about what a write means; this owns the
|
|
12
|
+
* STORAGE — the maps, the LRU order, the idle TTL, and the dirty flag that says
|
|
13
|
+
* an entry holds writes no persist has carried yet.
|
|
14
|
+
*
|
|
15
|
+
* The maps live here and nowhere else, so "is this session dirty" and "drop this
|
|
16
|
+
* session" cannot be answered from two places that disagree.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type SessionState = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
const entries = new Map<string, SessionState>();
|
|
22
|
+
/** Epoch ms of the last user-driven access per session (get/set/hydrate). */
|
|
23
|
+
const lastTouched = new Map<string, number>();
|
|
24
|
+
/** A session with writes no persist has carried yet. */
|
|
25
|
+
const dirty = new Map<string, boolean>();
|
|
26
|
+
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
27
|
+
|
|
28
|
+
// Bounded LRU cache: cap the number of sessions kept in memory so the tab
|
|
29
|
+
// doesn't grow unboundedly as the user visits more sessions.
|
|
30
|
+
const MAX_CACHED_SESSIONS = 10;
|
|
31
|
+
/** A session whose last user access is older than this is evicted regardless
|
|
32
|
+
* of the size cap, so stale per-session UI state (drafts, panel layouts)
|
|
33
|
+
* stops occupying memory 10 minutes after the user last opened it. */
|
|
34
|
+
const SESSION_IDLE_TTL_MS = 10 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
/** The cached entry, as read-only — write through the store's own setters. */
|
|
37
|
+
export function readEntry(sessionId: string): Readonly<SessionState> | undefined {
|
|
38
|
+
return entries.get(sessionId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reorder a cache entry to the end of the Map's iteration order (the
|
|
43
|
+
* most-recently-used position) via delete-then-set. Map.set on an existing
|
|
44
|
+
* key updates the value but keeps its original position, so the explicit
|
|
45
|
+
* delete is required for true LRU semantics.
|
|
46
|
+
*/
|
|
47
|
+
export function touchEntry(sessionId: string): void {
|
|
48
|
+
const value = entries.get(sessionId);
|
|
49
|
+
if (value !== undefined) {
|
|
50
|
+
entries.delete(sessionId);
|
|
51
|
+
entries.set(sessionId, value);
|
|
52
|
+
}
|
|
53
|
+
lastTouched.set(sessionId, Date.now());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stamp the entry as most-recently-used without reordering it. */
|
|
57
|
+
export function stampAccess(sessionId: string): void {
|
|
58
|
+
lastTouched.set(sessionId, Date.now());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function lastAccess(sessionId: string): number | undefined {
|
|
62
|
+
return lastTouched.get(sessionId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Replace an entry outright (a blob arriving, a migrate, a hydrate). */
|
|
66
|
+
export function writeEntry(sessionId: string, state: SessionState): void {
|
|
67
|
+
entries.set(sessionId, state);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The MUTABLE entry for a write, created on demand.
|
|
72
|
+
*
|
|
73
|
+
* `setSessionKey` and `clearSessionKey` mutate the cached object in place, as
|
|
74
|
+
* they always have: a copy per write would give every reader a different object
|
|
75
|
+
* for the same slot, and `useSessionState` compares the store's entry against
|
|
76
|
+
* its own copy by IDENTITY to decide whether a fresher write exists.
|
|
77
|
+
*/
|
|
78
|
+
export function entryForWrite(sessionId: string): SessionState {
|
|
79
|
+
const existing = entries.get(sessionId);
|
|
80
|
+
if (existing) return existing;
|
|
81
|
+
const created: SessionState = {};
|
|
82
|
+
entries.set(sessionId, created);
|
|
83
|
+
return created;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Mark a session as holding writes no persist has carried yet. */
|
|
87
|
+
export function markDirty(sessionId: string): void {
|
|
88
|
+
dirty.set(sessionId, true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function clearDirty(sessionId: string): void {
|
|
92
|
+
dirty.set(sessionId, false);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isDirty(sessionId: string): boolean {
|
|
96
|
+
return dirty.get(sessionId) === true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function setPersistTimer(sessionId: string, timer: ReturnType<typeof setTimeout>): void {
|
|
100
|
+
persistTimers.set(sessionId, timer);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function persistTimer(sessionId: string): ReturnType<typeof setTimeout> | undefined {
|
|
104
|
+
return persistTimers.get(sessionId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function dropPersistTimer(sessionId: string): void {
|
|
108
|
+
const timer = persistTimers.get(sessionId);
|
|
109
|
+
if (timer) {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
persistTimers.delete(sessionId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Remove an entry and everything recorded about it. */
|
|
116
|
+
export function dropEntry(sessionId: string): void {
|
|
117
|
+
entries.delete(sessionId);
|
|
118
|
+
dirty.delete(sessionId);
|
|
119
|
+
lastTouched.delete(sessionId);
|
|
120
|
+
dropPersistTimer(sessionId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Evict the oldest clean sessions until the cache is within bounds.
|
|
125
|
+
*
|
|
126
|
+
* Dirty-safety rule: a session with unsaved writes (`isDirty`) must never be
|
|
127
|
+
* evicted, or the user's drafts and editor state would be silently lost. If
|
|
128
|
+
* every candidate is dirty (or is the protected id currently being written),
|
|
129
|
+
* stop evicting rather than lose data.
|
|
130
|
+
*
|
|
131
|
+
* Entries idle past SESSION_IDLE_TTL_MS are evicted first — even when the
|
|
132
|
+
* cache is under the size cap — so long-unused sessions drop out on schedule.
|
|
133
|
+
*/
|
|
134
|
+
export function evictIfNeeded(protectedId: string | null, onEvict: (sessionId: string) => void): void {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const evict = (id: string): void => {
|
|
137
|
+
dropEntry(id);
|
|
138
|
+
onEvict(id);
|
|
139
|
+
};
|
|
140
|
+
// TTL pass: safe to skip only while the timer still holds the unsaved state
|
|
141
|
+
// or the session is the one currently being written.
|
|
142
|
+
for (const [id, touched] of lastTouched) {
|
|
143
|
+
if (now - touched <= SESSION_IDLE_TTL_MS) continue;
|
|
144
|
+
if (id === protectedId) continue;
|
|
145
|
+
if (isDirty(id)) continue;
|
|
146
|
+
evict(id);
|
|
147
|
+
}
|
|
148
|
+
while (entries.size > MAX_CACHED_SESSIONS) {
|
|
149
|
+
let evicted = false;
|
|
150
|
+
for (const id of entries.keys()) {
|
|
151
|
+
if (id === protectedId) continue;
|
|
152
|
+
if (isDirty(id)) continue;
|
|
153
|
+
evict(id);
|
|
154
|
+
evicted = true;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
if (!evicted) break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared harness for the session-state store's suites.
|
|
8
|
+
*
|
|
9
|
+
* Split out when `store.test.ts` passed the per-file ceiling. Every case in
|
|
10
|
+
* every suite needs the same three things — an id that teardown forgets, a
|
|
11
|
+
* subscription that teardown cancels, and a counter — and a per-file copy is how
|
|
12
|
+
* two suites end up disagreeing about what teardown restores.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, setSystemTime } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import { subscribeSessionKey } from '@/shared/lib/workspace/session-state/listeners';
|
|
18
|
+
import { forgetSession } from '@/shared/lib/workspace/session-state/store';
|
|
19
|
+
|
|
20
|
+
/** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
|
|
21
|
+
export const originalFetch = Bun.fetch;
|
|
22
|
+
export const originalWindow = Reflect.get(globalThis, 'window');
|
|
23
|
+
|
|
24
|
+
const touched: string[] = [];
|
|
25
|
+
const unsubs: Array<() => void> = [];
|
|
26
|
+
|
|
27
|
+
/** Register a session id so teardown forgets it. */
|
|
28
|
+
export function use(id: string): string {
|
|
29
|
+
touched.push(id);
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Subscribe and auto-unsubscribe at teardown. */
|
|
34
|
+
export function follow(sessionId: string | null, key: string | null, listener: () => void): () => void {
|
|
35
|
+
const unsubscribe = subscribeSessionKey(sessionId, key, listener);
|
|
36
|
+
unsubs.push(unsubscribe);
|
|
37
|
+
return unsubscribe;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Count how many times a notification reaches a listener. */
|
|
41
|
+
export function counter(): { calls: number; listener: () => void } {
|
|
42
|
+
const state = { calls: 0, listener: () => { state.calls += 1; } };
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Install the suite's teardown. Called once per test file. */
|
|
47
|
+
export function installStoreTeardown(): void {
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
for (const unsubscribe of unsubs.splice(0)) unsubscribe();
|
|
50
|
+
for (const id of touched.splice(0)) forgetSession(id);
|
|
51
|
+
Reflect.set(globalThis, 'window', originalWindow);
|
|
52
|
+
Reflect.set(globalThis, 'fetch', originalFetch);
|
|
53
|
+
setSystemTime();
|
|
54
|
+
});
|
|
55
|
+
}
|