@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
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
PDF_MAX_FILE_BYTES,
|
|
29
29
|
unsupportedEditorMessage,
|
|
30
30
|
} from '@/shared/lib/fs/file-kind';
|
|
31
|
+
import { formatBytes } from '@/shared/lib/format/number';
|
|
31
32
|
|
|
32
33
|
const bytes = (...values: number[]): Uint8Array => new Uint8Array(values);
|
|
33
34
|
|
|
@@ -96,8 +97,11 @@ describe('classifyEditorFile', () => {
|
|
|
96
97
|
|
|
97
98
|
// The document ceiling does not reach plain text: a 6 MB source file is still
|
|
98
99
|
// refused, because a textarea holds that buffer for the life of the tab.
|
|
99
|
-
test('plain text keeps the text ceiling', () => {
|
|
100
|
-
|
|
100
|
+
test('plain text keeps the text ceiling, which a real source file clears', () => {
|
|
101
|
+
// 6 MB was over the old 5 MiB ceiling and is a normal generated file, not a
|
|
102
|
+
// refusal. The ceiling moved; this case is what keeps it from moving back.
|
|
103
|
+
expect(classifyEditorFile({ path: 'big.ts', size: 6 * 1024 * 1024 })).toBe('text');
|
|
104
|
+
expect(classifyEditorFile({ path: 'big.log', size: EDITOR_MAX_FILE_BYTES })).toBe('text');
|
|
101
105
|
expect(classifyEditorFile({ path: 'big.log', size: EDITOR_MAX_FILE_BYTES + 1 })).toBe('too-large');
|
|
102
106
|
});
|
|
103
107
|
|
|
@@ -120,24 +124,34 @@ describe('unsupportedEditorMessage', () => {
|
|
|
120
124
|
});
|
|
121
125
|
|
|
122
126
|
test('a size refusal names both the actual size and the ceiling', () => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
// Derived, never spelled out: the point of the sentence is that its number
|
|
128
|
+
// is the one the classifier applied, so a literal here would pass while the
|
|
129
|
+
// two disagreed.
|
|
130
|
+
const message = unsupportedEditorMessage('too-large', 40 * 1024 * 1024);
|
|
131
|
+
expect(message).toContain('40.00 MB');
|
|
132
|
+
expect(message).toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
|
|
126
133
|
});
|
|
127
134
|
|
|
128
135
|
test('a size refusal without a measured size names only the ceiling', () => {
|
|
129
136
|
const message = unsupportedEditorMessage('too-large');
|
|
130
|
-
expect(message).toContain(
|
|
137
|
+
expect(message).toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
|
|
131
138
|
expect(message).not.toContain('undefined');
|
|
132
139
|
});
|
|
133
140
|
|
|
141
|
+
test('the text ceiling leaves room for a real source file and stops short of the knee', () => {
|
|
142
|
+
// 32 MiB: measured, the last size before a tab's heap crosses a gigabyte and
|
|
143
|
+
// a 64 MB file stops opening at all (see the constant's own table).
|
|
144
|
+
expect(EDITOR_MAX_FILE_BYTES).toBe(32 * 1024 * 1024);
|
|
145
|
+
expect(EDITOR_MAX_FILE_BYTES).toBeGreaterThan(DOCUMENT_MAX_FILE_BYTES);
|
|
146
|
+
});
|
|
147
|
+
|
|
134
148
|
// The sentence names the number the classifier actually applied: a PDF is
|
|
135
|
-
// refused against
|
|
136
|
-
// the file was never measured against.
|
|
137
|
-
test('a PDF size refusal names the PDF ceiling', () => {
|
|
149
|
+
// refused against its own ceiling, so naming the text one would describe a
|
|
150
|
+
// limit the file was never measured against.
|
|
151
|
+
test('a PDF size refusal names the PDF ceiling, not the text one', () => {
|
|
138
152
|
const message = unsupportedEditorMessage('too-large', 31 * 1024 * 1024, 'docs/report.pdf');
|
|
139
153
|
expect(message).toContain('31.00 MB');
|
|
140
|
-
expect(message).toContain(
|
|
141
|
-
expect(message).not.toContain(
|
|
154
|
+
expect(message).toContain(formatBytes(PDF_MAX_FILE_BYTES));
|
|
155
|
+
expect(message).not.toContain(formatBytes(EDITOR_MAX_FILE_BYTES));
|
|
142
156
|
});
|
|
143
157
|
});
|
|
@@ -143,16 +143,43 @@ export function getImageMimeFromBytes(bytes: Uint8Array): string | null {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* The largest file the editor will open when its bytes reach the page
|
|
146
|
+
* The largest text file the editor will open when its bytes reach the page
|
|
147
|
+
* WHOLE.
|
|
147
148
|
*
|
|
148
|
-
* Not a policy knob: the editor holds the whole file in one string
|
|
149
|
-
*
|
|
150
|
-
*
|
|
149
|
+
* Not a policy knob: the editor holds the whole file in one string, CodeMirror
|
|
150
|
+
* holds a second representation of it, and the whole thing is posted back on
|
|
151
|
+
* every save — so a ceiling has to exist.
|
|
151
152
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
153
|
+
* **32 MiB, measured.** The number used to be 5 MiB, and its justification died
|
|
154
|
+
* with the `<textarea>`: that limit existed because a textarea stops responding,
|
|
155
|
+
* and the surface is CodeMirror 6 now. Re-measured on the assembled app (a
|
|
156
|
+
* generated source file, Files panel open, Chromium):
|
|
157
|
+
*
|
|
158
|
+
* | File | Open | Keystroke | Save | JS heap |
|
|
159
|
+
* |---|---|---|---|---|
|
|
160
|
+
* | 5 MB | 99 ms | 17 ms | 289 ms | 73 MB |
|
|
161
|
+
* | 10 MB | 147 ms | 32 ms | 494 ms | 73 MB |
|
|
162
|
+
* | 20 MB | 212 ms | 34 ms | 969 ms | 73 MB |
|
|
163
|
+
* | 30 MB | 323 ms | 39 ms | 2 292 ms | — |
|
|
164
|
+
* | 40 MB | 283 ms | 45 ms | 1 793 ms | — |
|
|
165
|
+
* | 50 MB | 372 ms | 72 ms | 2 228 ms | **1 078 MB** |
|
|
166
|
+
* | 64 MB | never finished loading | — | — | — |
|
|
167
|
+
*
|
|
168
|
+
* The curve is flat through 40 MB and breaks at 50: the heap goes from a few
|
|
169
|
+
* hundred megabytes to over a gigabyte for ONE tab, and a 64 MB file does not
|
|
170
|
+
* open at all. 32 MiB is the last size with a comfortable margin below that
|
|
171
|
+
* knee — ten times any source file, and the point past which a tab is a risk to
|
|
172
|
+
* every other tab in the browser rather than a convenience.
|
|
173
|
+
*
|
|
174
|
+
* Two costs had to be removed before this number was reachable, and both were
|
|
175
|
+
* linear-in-file-size work paid per keystroke: the outline's scan-and-render of
|
|
176
|
+
* every declaration (`outline/rows.ts`), and a full `doc.toString()` on every
|
|
177
|
+
* render (`code-surface/index.tsx`).
|
|
178
|
+
*
|
|
179
|
+
* A DOCX and an XLSX are parsed in the page too, but they get their own number —
|
|
180
|
+
* see `DOCUMENT_MAX_FILE_BYTES`.
|
|
154
181
|
*/
|
|
155
|
-
export const EDITOR_MAX_FILE_BYTES =
|
|
182
|
+
export const EDITOR_MAX_FILE_BYTES = 32 * 1024 * 1024;
|
|
156
183
|
|
|
157
184
|
/**
|
|
158
185
|
* The largest Word document or workbook the editor opens.
|
|
@@ -160,9 +187,10 @@ export const EDITOR_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
|
160
187
|
* `mammoth` and `read-excel-file` parse the fetched `ArrayBuffer` in the page,
|
|
161
188
|
* so the file does sit in memory — but it is unzipped and converted once and
|
|
162
189
|
* then released, where a text buffer is held for as long as the tab is open and
|
|
163
|
-
* re-posted on every save. That is why these get
|
|
164
|
-
*
|
|
165
|
-
*
|
|
190
|
+
* re-posted on every save. That is why these get a ceiling of their own rather
|
|
191
|
+
* than the text one: the reason the text limit exists (a buffer that must stay
|
|
192
|
+
* editable, and a save that re-posts it) does not apply to a read-only
|
|
193
|
+
* conversion.
|
|
166
194
|
*
|
|
167
195
|
* Measured on a 15 MB workbook: the fetch, the unzip and the grid render all
|
|
168
196
|
* complete, and the panel stays responsive.
|
|
@@ -170,8 +198,13 @@ export const EDITOR_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
|
170
198
|
export const DOCUMENT_MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
171
199
|
|
|
172
200
|
/**
|
|
173
|
-
* The largest PDF the editor opens
|
|
174
|
-
*
|
|
201
|
+
* The largest PDF the editor opens.
|
|
202
|
+
*
|
|
203
|
+
* Its own number rather than the text ceiling, because a PDF is the one document
|
|
204
|
+
* the page never holds — and the only one whose limit is not about a buffer at
|
|
205
|
+
* all (see below). It is no longer the largest of the three: the TEXT ceiling
|
|
206
|
+
* moved past it, which is fine because the two numbers answer different
|
|
207
|
+
* questions rather than ranking a shared budget.
|
|
175
208
|
*
|
|
176
209
|
* `PdfView` points an `<iframe>` at `/api/fs/raw`, so the bytes go straight to
|
|
177
210
|
* Chrome's own viewer, which fetches them with range requests (verified: a
|
|
@@ -180,7 +213,8 @@ export const DOCUMENT_MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
|
180
213
|
* Nothing in Kabmo buffers the file, so the text ceiling has no reason to
|
|
181
214
|
* apply to it — and applying it was the bug: a 6 MB PDF dragged from Finder was
|
|
182
215
|
* refused as "larger than the 5.00 MB the editor opens" before the document
|
|
183
|
-
* branch was ever reached, because the size test runs first.
|
|
216
|
+
* branch was ever reached, because the size test runs first. The 30 MB here is
|
|
217
|
+
* Chrome's own plugin's comfort zone, not Kabmo's memory.
|
|
184
218
|
*/
|
|
185
219
|
export const PDF_MAX_FILE_BYTES = 30 * 1024 * 1024;
|
|
186
220
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `pathFromFileUrl` — the platform-specific half of a dropped-file reference.
|
|
8
|
+
*
|
|
9
|
+
* Pinned here rather than through the scope route because the Windows case is
|
|
10
|
+
* invisible on the machine this repository is developed on: the route's own test
|
|
11
|
+
* is `testPosix`, so the drive-letter branch had no test at all until a Windows
|
|
12
|
+
* CI run refused every dropped file.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { pathFromFileUrl } from '@/shared/lib/fs/file-url';
|
|
17
|
+
|
|
18
|
+
describe('pathFromFileUrl', () => {
|
|
19
|
+
test('a POSIX file URL decodes to the path it names', () => {
|
|
20
|
+
expect(pathFromFileUrl('file:///tmp/x/b.ts')).toBe('/tmp/x/b.ts');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a percent-encoded space is decoded', () => {
|
|
24
|
+
expect(pathFromFileUrl('file:///tmp/x/My%20Notes.md')).toBe('/tmp/x/My Notes.md');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a Windows drive letter loses the leading slash, in every spelling', () => {
|
|
28
|
+
// All three forms answer `/C:/…` from `new URL().pathname`, and a leading
|
|
29
|
+
// slash on win32 resolves against the CURRENT DRIVE's root — a path no
|
|
30
|
+
// workspace contains.
|
|
31
|
+
expect(pathFromFileUrl('file:///C:/Users/x/b.ts')).toBe('C:/Users/x/b.ts');
|
|
32
|
+
expect(pathFromFileUrl('file://C:/Users/x/b.ts')).toBe('C:/Users/x/b.ts');
|
|
33
|
+
expect(pathFromFileUrl('file://C:\\Users\\x\\b.ts')).toBe('C:/Users/x/b.ts');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a percent-encoded drive path is decoded before the slash is dropped', () => {
|
|
37
|
+
expect(pathFromFileUrl('file:///D:/a/My%20Notes.md')).toBe('D:/a/My Notes.md');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a path that is not a file URL is refused rather than guessed at', () => {
|
|
41
|
+
expect(pathFromFileUrl('/tmp/x/b.ts')).toBeNull();
|
|
42
|
+
expect(pathFromFileUrl('https://example.com/x.ts')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a malformed escape is refused, not thrown', () => {
|
|
46
|
+
expect(pathFromFileUrl('file:///tmp/%E0%A4%A')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a lowercase drive letter is kept as written', () => {
|
|
50
|
+
expect(pathFromFileUrl('file:///c:/x/b.ts')).toBe('c:/x/b.ts');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `file://` URL to the path it names.
|
|
8
|
+
*
|
|
9
|
+
* Shared rather than private to the scope route, because the transformation is
|
|
10
|
+
* platform-specific and a private copy can only be exercised on the platform it
|
|
11
|
+
* was written on: the Windows branch below was wrong for as long as it lived
|
|
12
|
+
* inside a route whose test skipped Windows.
|
|
13
|
+
*
|
|
14
|
+
* Two rules, both measured:
|
|
15
|
+
*
|
|
16
|
+
* - **The pathname is percent-encoded** — a space is `%20`, and a Finder drop of
|
|
17
|
+
* `My Notes.md` is exactly that — so decoding is not optional.
|
|
18
|
+
* `decodeURIComponent` throws on a malformed escape, which is answered as
|
|
19
|
+
* "not a path" rather than surfacing as a 500.
|
|
20
|
+
* - **A WINDOWS drive letter loses its leading slash.** `new URL('file://C:\a\b.ts')`
|
|
21
|
+
* and the canonical `file:///C:/a/b.ts` BOTH answer `/C:/a/b.ts`, and
|
|
22
|
+
* `path.resolve` on win32 turns that into `\C:\a\b.ts` — a path under the
|
|
23
|
+
* current DRIVE's root, which no workspace contains. On the Windows CI leg
|
|
24
|
+
* every dropped file was refused with "outside every workspace", so a file
|
|
25
|
+
* dropped on the editor opened nothing at all. `fileURLToPath` drops the same
|
|
26
|
+
* slash; this does it without a `node:url` import in a shared module.
|
|
27
|
+
*/
|
|
28
|
+
export function pathFromFileUrl(reference: string): string | null {
|
|
29
|
+
if (!/^file:\/\//i.test(reference)) return null;
|
|
30
|
+
try {
|
|
31
|
+
const pathname = decodeURIComponent(new URL(reference).pathname);
|
|
32
|
+
// `/C:/…` → `C:/…`; `/tmp/…` is left alone.
|
|
33
|
+
return /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A unified patch → the two documents it compares.
|
|
8
|
+
*
|
|
9
|
+
* What this pins is the shape a chat tool card depends on: a card has a PATCH
|
|
10
|
+
* and no files, and it draws the same merge surface the diff panel does — so the
|
|
11
|
+
* sides it hands over must be exactly what the patch says they were. A context
|
|
12
|
+
* row belongs to both, a `-` row only to the original, a `+` row only to the new
|
|
13
|
+
* one; everything else (the `diff --git` header, `index`, `---`/`+++`, mode and
|
|
14
|
+
* rename lines, the "No newline" marker) is metadata and must not appear in
|
|
15
|
+
* either document.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
|
|
20
|
+
import { patchSides } from '@/shared/lib/fs/patch-sides';
|
|
21
|
+
|
|
22
|
+
const GIT_DIFF = [
|
|
23
|
+
'diff --git a/src/a.ts b/src/a.ts',
|
|
24
|
+
'index 1111111..2222222 100644',
|
|
25
|
+
'--- a/src/a.ts',
|
|
26
|
+
'+++ b/src/a.ts',
|
|
27
|
+
'@@ -1,3 +1,3 @@',
|
|
28
|
+
' const one = 1;',
|
|
29
|
+
'-const two = 2;',
|
|
30
|
+
'+const two = 22;',
|
|
31
|
+
' const three = 3;',
|
|
32
|
+
'',
|
|
33
|
+
].join('\n');
|
|
34
|
+
|
|
35
|
+
describe('patchSides', () => {
|
|
36
|
+
test('splits a git diff into the documents it compares', () => {
|
|
37
|
+
const sides = patchSides(GIT_DIFF);
|
|
38
|
+
|
|
39
|
+
expect(sides.original).toBe('const one = 1;\nconst two = 2;\nconst three = 3;');
|
|
40
|
+
expect(sides.next).toBe('const one = 1;\nconst two = 22;\nconst three = 3;');
|
|
41
|
+
expect(sides.additions).toBe(1);
|
|
42
|
+
expect(sides.deletions).toBe(1);
|
|
43
|
+
expect(sides.hasHunk).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('keeps the header lines out of both documents', () => {
|
|
47
|
+
// A `--- a/x` header would otherwise land in the original as `-- a/x`, and
|
|
48
|
+
// the merge view would draw a chunk that is not a change.
|
|
49
|
+
const sides = patchSides(GIT_DIFF);
|
|
50
|
+
for (const line of ['diff --git', 'index ', '--- a/', '+++ b/']) {
|
|
51
|
+
expect(sides.original.includes(line)).toBe(false);
|
|
52
|
+
expect(sides.next.includes(line)).toBe(false);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a new file has an empty original and the whole new document', () => {
|
|
57
|
+
const sides = patchSides(['@@ -0,0 +1,2 @@', '+alpha', '+beta'].join('\n'));
|
|
58
|
+
|
|
59
|
+
expect(sides.original).toBe('');
|
|
60
|
+
expect(sides.next).toBe('alpha\nbeta');
|
|
61
|
+
expect(sides.additions).toBe(2);
|
|
62
|
+
expect(sides.deletions).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a deleted file is the mirror image', () => {
|
|
66
|
+
const sides = patchSides(['@@ -1,1 +0,0 @@', '-gone'].join('\n'));
|
|
67
|
+
|
|
68
|
+
expect(sides.original).toBe('gone');
|
|
69
|
+
expect(sides.next).toBe('');
|
|
70
|
+
expect(sides.deletions).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a header-only patch has no rows, so the caller can fall back to text', () => {
|
|
74
|
+
const sides = patchSides('--- a/x\n+++ b/x\n');
|
|
75
|
+
|
|
76
|
+
expect(sides.rows).toBe(0);
|
|
77
|
+
expect(sides.hasHunk).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('an empty patch is the empty answer, not a one-line document', () => {
|
|
81
|
+
expect(patchSides('')).toEqual({ original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false });
|
|
82
|
+
expect(patchSides(' \n')).toEqual({ original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('counts rows without the blank line a trailing newline leaves behind', () => {
|
|
86
|
+
const sides = patchSides(['@@ -1 +1 @@', '-a', '+b'].join('\n'));
|
|
87
|
+
|
|
88
|
+
expect(sides.rows).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a hunk starting mid-file keeps its real line numbers', () => {
|
|
92
|
+
// Without the padding the change would be line 1 of the rebuilt document,
|
|
93
|
+
// and the card's gutter would name a line the file does not have it on.
|
|
94
|
+
const sides = patchSides(['@@ -43,2 +43,2 @@', ' context', '-old', '+new'].join('\n'));
|
|
95
|
+
|
|
96
|
+
expect(sides.original.split('\n')).toHaveLength(44);
|
|
97
|
+
expect(sides.original.split('\n')[42]).toBe('context');
|
|
98
|
+
expect(sides.original.split('\n')[43]).toBe('old');
|
|
99
|
+
expect(sides.next.split('\n')[43]).toBe('new');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('the gap between two hunks is restored from their own headers', () => {
|
|
103
|
+
const sides = patchSides(
|
|
104
|
+
['@@ -2,1 +2,1 @@', '-a', '+b', '@@ -10,1 +10,1 @@', '-c', '+d'].join('\n'),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Line 2 is the first change, line 10 the second, and everything between is
|
|
108
|
+
// unchanged content the patch did not carry.
|
|
109
|
+
const original = sides.original.split('\n');
|
|
110
|
+
expect(original[1]).toBe('a');
|
|
111
|
+
expect(original[9]).toBe('c');
|
|
112
|
+
expect(original).toHaveLength(10);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A unified patch as its two DOCUMENTS.
|
|
8
|
+
*
|
|
9
|
+
* This replaced a table parser (`diff-parser.ts`) that produced per-row line
|
|
10
|
+
* numbers and paired split-view rows. Both of those are now the merge view's
|
|
11
|
+
* job — `@codemirror/merge` computes the alignment itself from two documents —
|
|
12
|
+
* so the only thing still needed from a patch is what the two sides were.
|
|
13
|
+
*
|
|
14
|
+
* That matters in one place: a chat tool card has a PATCH and no documents, and
|
|
15
|
+
* the card draws the same merge surface the diff panel does. Reconstructing the
|
|
16
|
+
* sides here keeps one renderer for both, instead of the card growing a second
|
|
17
|
+
* diff implementation that drifts.
|
|
18
|
+
*
|
|
19
|
+
* **The gap between hunks is restored as blank lines, and that is what keeps the
|
|
20
|
+
* gutter honest.** A hunk header names the line it starts on (`@@ -43,7 +43,8`),
|
|
21
|
+
* and a document rebuilt by concatenating hunks would number the change from 1.
|
|
22
|
+
* Padding each side up to the header's own counters puts the change back on the
|
|
23
|
+
* line the file has it on — and because the padding is unchanged content, the
|
|
24
|
+
* merge view collapses it into one marker (`contentMode: 'diff'`).
|
|
25
|
+
*
|
|
26
|
+
* The counters are the patch's own (`+`/`-` rows, hunk headers excluded), which
|
|
27
|
+
* is what the card's header prints. The diff panel deliberately does NOT use
|
|
28
|
+
* them: it has the real documents, and its counts must describe the rows the
|
|
29
|
+
* merge view actually draws (a collapsed or whitespace-folded change is not a
|
|
30
|
+
* changed row on screen).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** One side's text, and what the patch said about it. */
|
|
34
|
+
export interface PatchSides {
|
|
35
|
+
/** The document before the change, with the hunk gaps as blank lines. */
|
|
36
|
+
original: string;
|
|
37
|
+
/** The document after it, the same way. */
|
|
38
|
+
next: string;
|
|
39
|
+
additions: number;
|
|
40
|
+
deletions: number;
|
|
41
|
+
/** Content rows the patch produced — zero for a header-only or empty patch. */
|
|
42
|
+
rows: number;
|
|
43
|
+
/** The patch named at least one hunk (`@@`), so it is a real diff. */
|
|
44
|
+
hasHunk: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMPTY: PatchSides = { original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false };
|
|
48
|
+
|
|
49
|
+
/** Header lines that describe the patch rather than its content. */
|
|
50
|
+
const HEADER_PREFIXES = [
|
|
51
|
+
'diff --git',
|
|
52
|
+
'index ',
|
|
53
|
+
'---',
|
|
54
|
+
'+++',
|
|
55
|
+
'new file mode',
|
|
56
|
+
'deleted file mode',
|
|
57
|
+
'similarity index',
|
|
58
|
+
'dissimilarity index',
|
|
59
|
+
'rename from',
|
|
60
|
+
'rename to',
|
|
61
|
+
'copy from',
|
|
62
|
+
'copy to',
|
|
63
|
+
'old mode',
|
|
64
|
+
'new mode',
|
|
65
|
+
'\\ No newline',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** `@@ -43,7 +43,8 @@` → the two starting lines; anything else → null. */
|
|
69
|
+
function hunkStarts(line: string): { old: number; next: number } | null {
|
|
70
|
+
const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
71
|
+
if (!match) return null;
|
|
72
|
+
return { old: Number(match[1]), next: Number(match[2]) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Pad `lines` with blanks until it holds `count` lines. */
|
|
76
|
+
function padTo(lines: string[], count: number): void {
|
|
77
|
+
while (lines.length < count) lines.push('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function patchSides(patch: string): PatchSides {
|
|
81
|
+
if (!patch.trim()) return EMPTY;
|
|
82
|
+
|
|
83
|
+
const before: string[] = [];
|
|
84
|
+
const after: string[] = [];
|
|
85
|
+
let additions = 0;
|
|
86
|
+
let deletions = 0;
|
|
87
|
+
let rows = 0;
|
|
88
|
+
let hasHunk = false;
|
|
89
|
+
|
|
90
|
+
const lines = patch.split(/\r?\n/);
|
|
91
|
+
// A patch ends with a newline, and `split` turns that into a final empty
|
|
92
|
+
// string. It is the terminator, not a blank row: keeping it put an empty line
|
|
93
|
+
// at the end of BOTH documents, which the merge view drew as a chunk.
|
|
94
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
95
|
+
|
|
96
|
+
for (const raw of lines) {
|
|
97
|
+
if (HEADER_PREFIXES.some((prefix) => raw.startsWith(prefix))) continue;
|
|
98
|
+
|
|
99
|
+
if (raw.startsWith('@@')) {
|
|
100
|
+
hasHunk = true;
|
|
101
|
+
const starts = hunkStarts(raw);
|
|
102
|
+
// `-0,0` (a new file) has no original line to reach, and `padTo` is a
|
|
103
|
+
// no-op for it because the count is below what is already there.
|
|
104
|
+
if (starts) {
|
|
105
|
+
padTo(before, starts.old - 1);
|
|
106
|
+
padTo(after, starts.next - 1);
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (raw.startsWith('+')) {
|
|
112
|
+
additions++;
|
|
113
|
+
rows++;
|
|
114
|
+
after.push(raw.slice(1));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (raw.startsWith('-')) {
|
|
118
|
+
deletions++;
|
|
119
|
+
rows++;
|
|
120
|
+
before.push(raw.slice(1));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// A context row (` ` prefix) is on BOTH sides; a bare line is what a
|
|
124
|
+
// header-less patch carries, and it is context too.
|
|
125
|
+
const text = raw.startsWith(' ') ? raw.slice(1) : raw;
|
|
126
|
+
rows++;
|
|
127
|
+
before.push(text);
|
|
128
|
+
after.push(text);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
original: before.join('\n'),
|
|
133
|
+
next: after.join('\n'),
|
|
134
|
+
additions,
|
|
135
|
+
deletions,
|
|
136
|
+
rows,
|
|
137
|
+
hasHunk,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -45,6 +45,22 @@ export const TOPIC_SIDEBAR = 'sidebar';
|
|
|
45
45
|
* the structure needs a JSONL scan to produce. */
|
|
46
46
|
export const TOPIC_SIDEBAR_STATUS = 'sidebar:status';
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The last-activity stamp of each session with a run IN FLIGHT, keyed by id.
|
|
50
|
+
*
|
|
51
|
+
* Split from `sidebar` for the same reason `sidebar:status` is, but the cost
|
|
52
|
+
* difference is far larger: a transcript grows on every tool call, so this
|
|
53
|
+
* changes per tool call while the structure is a 216 KB payload over 731
|
|
54
|
+
* sessions (measured on the author's install) that must be re-scanned to
|
|
55
|
+
* produce. Only a RUNNING session's stamp can move between turn boundaries, so
|
|
56
|
+
* only those are carried — the payload is one ISO string per live run
|
|
57
|
+
* (measured: 87 bytes for one), against 216 KB for the whole list.
|
|
58
|
+
*
|
|
59
|
+
* The structure remains the authority for every other row; this only ever
|
|
60
|
+
* ADVANCES a stamp the structure already carries (see `applySidebarActivity`).
|
|
61
|
+
*/
|
|
62
|
+
export const TOPIC_SIDEBAR_ACTIVITY = 'sidebar:activity';
|
|
63
|
+
|
|
48
64
|
/** One session's live conversation: agent frames, queue and modes. */
|
|
49
65
|
export function sessionTopic(sessionId: string): string {
|
|
50
66
|
return `session:${sessionId}`;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `⋯` rule, asserted as arithmetic.
|
|
8
|
+
*
|
|
9
|
+
* Each case is a failure the toolbars had: a row that wrapped into two rows
|
|
10
|
+
* instead of hiding a control, a control hidden while the width was still
|
|
11
|
+
* unknown (a menu flashing on every mount), and a pinned control buried in the
|
|
12
|
+
* menu because the row happened to be narrow.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
TOOLBAR_ITEM_GAP_PX,
|
|
19
|
+
TOOLBAR_OVERFLOW_TRIGGER_PX,
|
|
20
|
+
toolbarHasOverflow,
|
|
21
|
+
toolbarVisibleClusters,
|
|
22
|
+
} from '@/shared/lib/ui/toolbar-overflow';
|
|
23
|
+
|
|
24
|
+
/** Save, find, wrap, zoom, copy, modes, full screen — the editor's real row. */
|
|
25
|
+
const widths = [26, 26, 26, 52, 52, 52, 26];
|
|
26
|
+
const pinned = [true, false, false, false, false, true, true];
|
|
27
|
+
|
|
28
|
+
/** Row width needed to draw `visible` plus the trigger when anything is hidden. */
|
|
29
|
+
function rowWidth(visible: boolean[]): number {
|
|
30
|
+
const shown = widths.filter((_, index) => visible[index]);
|
|
31
|
+
const overflow = toolbarHasOverflow(visible);
|
|
32
|
+
return (
|
|
33
|
+
shown.reduce((sum, width) => sum + width, 0) +
|
|
34
|
+
TOOLBAR_ITEM_GAP_PX * Math.max(0, shown.length - 1) +
|
|
35
|
+
(overflow ? TOOLBAR_OVERFLOW_TRIGGER_PX + TOOLBAR_ITEM_GAP_PX : 0)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('toolbarVisibleClusters', () => {
|
|
40
|
+
test('an unmeasured row shows every cluster', () => {
|
|
41
|
+
// Not "hide everything": the row is measured a layout effect later, and a
|
|
42
|
+
// menu that flashes on every mount is worse than one frame of a full row.
|
|
43
|
+
expect(toolbarVisibleClusters({ widths, pinned, available: 0 }).every(Boolean)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a row wide enough for everything shows everything, with no trigger', () => {
|
|
47
|
+
const total = widths.reduce((sum, width) => sum + width, 0) + TOOLBAR_ITEM_GAP_PX * (widths.length - 1);
|
|
48
|
+
const visible = toolbarVisibleClusters({ widths, pinned, available: total });
|
|
49
|
+
expect(visible.every(Boolean)).toBe(true);
|
|
50
|
+
expect(toolbarHasOverflow(visible)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a narrow row hides what does not fit and reserves the trigger', () => {
|
|
54
|
+
// 200px of row: the three pinned clusters (26 + 52 + 26) plus their gaps
|
|
55
|
+
// leave room for one unpinned control and the `⋯` trigger, and no more.
|
|
56
|
+
const visible = toolbarVisibleClusters({ widths, pinned, available: 200 });
|
|
57
|
+
expect(toolbarHasOverflow(visible)).toBe(true);
|
|
58
|
+
// The promise the arithmetic has to keep: what stays plus the trigger fits.
|
|
59
|
+
expect(rowWidth(visible)).toBeLessThanOrEqual(200);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a pinned cluster stays on the row at any width', () => {
|
|
63
|
+
// A 120px row fits one 26px control plus the trigger, and the pinned width
|
|
64
|
+
// modes (52px) would not fit at all on their own — they stay regardless,
|
|
65
|
+
// because a menu-only mode button is a mode the reader never finds.
|
|
66
|
+
const visible = toolbarVisibleClusters({ widths, pinned, available: 120 });
|
|
67
|
+
expect(visible[0]).toBe(true);
|
|
68
|
+
expect(visible[5]).toBe(true);
|
|
69
|
+
expect(visible[6]).toBe(true);
|
|
70
|
+
// The unpinned controls are what gives way.
|
|
71
|
+
expect(visible[1]).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('order is preserved: what stays is a subsequence, not a re-sort', () => {
|
|
75
|
+
const visible = toolbarVisibleClusters({ widths, pinned, available: 200 });
|
|
76
|
+
const shownIndexes = visible.flatMap((isShown, index) => (isShown ? [index] : []));
|
|
77
|
+
expect([...shownIndexes].sort((a, b) => a - b)).toEqual(shownIndexes);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('no clusters is no row', () => {
|
|
81
|
+
expect(toolbarVisibleClusters({ widths: [], pinned: [], available: 500 })).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
});
|