@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
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
import { useImperativeHandle, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
-
import type { CSSProperties, Ref, TargetedKeyboardEvent } from 'preact';
|
|
3
|
-
|
|
4
|
-
import { continuesTyping, mergeTyping, pushRecord } from '@/shared/lib/code/editor/history';
|
|
5
|
-
import { handleEditorKeydown, runEditorCommand, type EditorKeydownDeps } from '@/client/components/common/code-editor/handler';
|
|
6
|
-
import { layerStyle, layerStyles } from '@/shared/lib/code/editor/layers';
|
|
7
|
-
import { CODE_EDITOR_RESET_CSS } from '@/shared/lib/code/editor/reset-css';
|
|
8
|
-
import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
9
|
-
import { useOccurrences } from '@/client/hooks/editor/use-occurrences';
|
|
10
|
-
import { useEditorHistory } from '@/client/hooks/editor/use-editor-history';
|
|
11
|
-
import type { TextRange } from '@/shared/lib/code/editor/commands';
|
|
12
|
-
import type { CodeWindow, LineWindow } from '@/shared/lib/code/lazy-window';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Preact hand-roll of the textarea-over-<pre> highlighting editor
|
|
16
|
-
* (the pattern react-simple-code-editor popularized), trimmed to the
|
|
17
|
-
* feature set this app actually uses.
|
|
18
|
-
*
|
|
19
|
-
* How it works: a transparent <textarea> sits on top of a syntax-highlighted
|
|
20
|
-
* <pre>. Typing, selecting and copying hit the native textarea; the <pre>
|
|
21
|
-
* below just mirrors the text with Shiki markup.
|
|
22
|
-
*
|
|
23
|
-
* For a long document only a window of lines is mirrored (`virtual`): the
|
|
24
|
-
* textarea still holds every line, so editing, selection and copy are
|
|
25
|
-
* untouched, while the `<pre>` carries just the visible ones and reserves the
|
|
26
|
-
* rest as padding — the alignment that keeps the caret over its own row.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
export interface CodeEditorHandle {
|
|
30
|
-
/** The `<textarea>` that owns the document — measurements and focus anchor on it. */
|
|
31
|
-
textarea(): HTMLTextAreaElement | null;
|
|
32
|
-
/** Current selection offsets, or null before the field is mounted. */
|
|
33
|
-
getSelection(): { start: number; end: number } | null;
|
|
34
|
-
/** Select a range; `focus` also moves the keyboard focus into the editor. */
|
|
35
|
-
select(start: number, end: number, focus?: boolean): void;
|
|
36
|
-
/**
|
|
37
|
-
* Replace the whole buffer as ONE undoable edit and leave the caret at
|
|
38
|
-
* `caretStart`/`caretEnd`, WITHOUT moving focus. Used by the find widget's
|
|
39
|
-
* replace, which must not look like a burst of typing to the history (⌘Z has
|
|
40
|
-
* to undo it in one step) and must not steal focus from its own field.
|
|
41
|
-
*/
|
|
42
|
-
applyDocument(value: string, caretStart: number, caretEnd: number): void;
|
|
43
|
-
/** Every range a ⌘D workflow has selected, primary included — the surface paints them. */
|
|
44
|
-
occurrences(): readonly TextRange[];
|
|
45
|
-
/**
|
|
46
|
-
* Run an editor command by name — the palette's path into the editor. It goes
|
|
47
|
-
* through the same dispatcher the keyboard uses, so a command cannot work
|
|
48
|
-
* from a chord and do nothing from the palette.
|
|
49
|
-
*/
|
|
50
|
-
run(command: EditorCommand): void;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface CodeEditorProps {
|
|
54
|
-
value: string;
|
|
55
|
-
onValueChange: (value: string) => void;
|
|
56
|
-
/** Return the Shiki-highlighted HTML for `code`, limited to `range` when the surface is windowed. */
|
|
57
|
-
highlight: (code: string, range: LineWindow | null) => string;
|
|
58
|
-
padding?: number;
|
|
59
|
-
/** Class applied to the inner <pre> holding the highlighted code. */
|
|
60
|
-
preClassName?: string;
|
|
61
|
-
/** Class applied to the transparent <textarea> on top. */
|
|
62
|
-
textareaClassName?: string;
|
|
63
|
-
/** Layout styles for the whole editor; typography must match both layers. */
|
|
64
|
-
style?: CSSProperties;
|
|
65
|
-
/** The `<pre>` layer that actually wraps the code — gutter alignment measures it. */
|
|
66
|
-
preRef?: Ref<HTMLPreElement>;
|
|
67
|
-
className?: string;
|
|
68
|
-
placeholder?: string;
|
|
69
|
-
readOnly?: boolean;
|
|
70
|
-
autoFocus?: boolean;
|
|
71
|
-
/** Renders only this window of lines, with the rest reserved as space. Omit to mirror the whole value. */
|
|
72
|
-
virtual?: CodeWindow | null;
|
|
73
|
-
/** Imperative surface for the find widget (selection, reveal, one-shot replace). */
|
|
74
|
-
handleRef?: Ref<CodeEditorHandle>;
|
|
75
|
-
/**
|
|
76
|
-
* Called when the extra selection ranges change, so the surface can paint
|
|
77
|
-
* them. The textarea holds only the primary selection; everything ⌘D adds
|
|
78
|
-
* lives here.
|
|
79
|
-
*/
|
|
80
|
-
onOccurrencesChange?: (ranges: readonly TextRange[]) => void;
|
|
81
|
-
/** Commands this editor does not own (find bar chords, word wrap, save). */
|
|
82
|
-
onCommand?: (command: EditorCommand) => void;
|
|
83
|
-
/**
|
|
84
|
-
* The extra ⌘D ranges, held by the CALLER. The editor keeps its own copy in a
|
|
85
|
-
* ref for synchronous reads; this prop is what makes the surface a controlled
|
|
86
|
-
* view of them, so the paint and the editing model cannot disagree.
|
|
87
|
-
*/
|
|
88
|
-
occurrences?: readonly TextRange[];
|
|
89
|
-
/** Shiki language id, for the comment syntax. */
|
|
90
|
-
language?: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const BASE_TEXTAREA_CLASS = 'code-editor-native-textarea';
|
|
94
|
-
|
|
95
|
-
export function CodeEditor({
|
|
96
|
-
value,
|
|
97
|
-
onValueChange,
|
|
98
|
-
highlight,
|
|
99
|
-
padding = 0,
|
|
100
|
-
preClassName,
|
|
101
|
-
textareaClassName,
|
|
102
|
-
style,
|
|
103
|
-
preRef,
|
|
104
|
-
className,
|
|
105
|
-
placeholder,
|
|
106
|
-
readOnly,
|
|
107
|
-
autoFocus,
|
|
108
|
-
virtual,
|
|
109
|
-
handleRef,
|
|
110
|
-
occurrences,
|
|
111
|
-
onOccurrencesChange,
|
|
112
|
-
onCommand,
|
|
113
|
-
language = 'javascript',
|
|
114
|
-
}: CodeEditorProps) {
|
|
115
|
-
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
116
|
-
const [captureTab, setCaptureTab] = useState(true);
|
|
117
|
-
const multi = useOccurrences({
|
|
118
|
-
controlled: occurrences,
|
|
119
|
-
onChange: (ranges) => onOccurrencesChange?.(ranges),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// A selection asked for before the field exists (the find widget opens and
|
|
123
|
-
// the panel re-renders in the same tick) is applied as soon as it mounts.
|
|
124
|
-
const pendingSelectionRef = useRef<{ start: number; end: number; focus: boolean } | null>(null);
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* The buffer and selection as of the last event that could precede an edit.
|
|
128
|
-
*
|
|
129
|
-
* `handleChange` has to know which range the BROWSER replaced, and the `input`
|
|
130
|
-
* event reports the state AFTER the edit — useless for locating it. Keydown,
|
|
131
|
-
* select and the component's own writes all land before the edit, so
|
|
132
|
-
* recording there is what makes the diff in `handleChange` unambiguous.
|
|
133
|
-
*
|
|
134
|
-
* The buffer that goes with it comes from the history hook (`undoStack.buffer`),
|
|
135
|
-
* NOT from the `value` prop: the prop lags a keystroke — it only moves once
|
|
136
|
-
* the panel's state has re-rendered, and the next keystroke can arrive first
|
|
137
|
-
* (measured: typing at several carets with a 30 ms gap). A stale `before`
|
|
138
|
-
* turns the diff into a replacement of the whole word, which is what made a
|
|
139
|
-
* five-letter burst take five undos.
|
|
140
|
-
*/
|
|
141
|
-
const lastSelectionRef = useRef<TextRange>({ start: 0, end: 0 });
|
|
142
|
-
|
|
143
|
-
const setSelection = (start: number, end: number, focus: boolean) => {
|
|
144
|
-
lastSelectionRef.current = { start, end };
|
|
145
|
-
const input = inputRef.current;
|
|
146
|
-
if (!input) {
|
|
147
|
-
pendingSelectionRef.current = { start, end, focus };
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
// Focus BEFORE the range: a textarea that is not the active element still
|
|
151
|
-
// stores `selectionStart`/`selectionEnd`, but paints neither a caret nor a
|
|
152
|
-
// selection band — which is why an outline jump scrolled and then looked
|
|
153
|
-
// like it had selected nothing (measured: the range was correct and the
|
|
154
|
-
// field was not focused). `preventScroll` is load-bearing: the editor's
|
|
155
|
-
// `<textarea>` spans the whole document (only the highlighted layer is
|
|
156
|
-
// windowed), so a plain `focus()` makes the browser scroll the container to
|
|
157
|
-
// the caret's position at the END of the buffer region it can see — which
|
|
158
|
-
// clamped the scroller to its maximum and left a search jump showing the
|
|
159
|
-
// file's tail (measured: a hit on line 800 of 900 landed at the bottom with
|
|
160
|
-
// its own highlight unpainted). The surface's own `revealOffset` is the
|
|
161
|
-
// only thing that scrolls.
|
|
162
|
-
if (focus) input.focus({ preventScroll: true });
|
|
163
|
-
input.selectionStart = start;
|
|
164
|
-
input.selectionEnd = end;
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
useImperativeHandle(
|
|
168
|
-
handleRef ?? null,
|
|
169
|
-
() => ({
|
|
170
|
-
textarea: () => inputRef.current,
|
|
171
|
-
getSelection: () => {
|
|
172
|
-
const input = inputRef.current;
|
|
173
|
-
return input ? { start: input.selectionStart, end: input.selectionEnd } : null;
|
|
174
|
-
},
|
|
175
|
-
select: setSelection,
|
|
176
|
-
applyDocument: (next, caretStart, caretEnd) => {
|
|
177
|
-
undoStack.commitEdit({ value: next, selectionStart: caretStart, selectionEnd: caretEnd });
|
|
178
|
-
// Focus is deliberately left where it was: the find widget's Replace
|
|
179
|
-
// button is about to be pressed again, and pulling focus into the
|
|
180
|
-
// document would eat the next keystroke.
|
|
181
|
-
setSelection(caretStart, caretEnd, false);
|
|
182
|
-
},
|
|
183
|
-
occurrences: () => multi.ranges,
|
|
184
|
-
run: (command) => {
|
|
185
|
-
const input = inputRef.current;
|
|
186
|
-
if (input) runEditorCommand(command, keydownDeps(input));
|
|
187
|
-
},
|
|
188
|
-
}),
|
|
189
|
-
// `applyEdits` closes over `onValueChange` and the history ref only, both of
|
|
190
|
-
// which are stable for the component's life.
|
|
191
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
192
|
-
[onValueChange],
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
// Runs on every render: a selection asked for while the field was not yet
|
|
196
|
-
// mounted (the find widget opens in the same tick as the panel's first
|
|
197
|
-
// render) has to land as soon as the textarea exists.
|
|
198
|
-
useLayoutEffect(() => {
|
|
199
|
-
const pending = pendingSelectionRef.current;
|
|
200
|
-
if (!pending || !inputRef.current) return;
|
|
201
|
-
pendingSelectionRef.current = null;
|
|
202
|
-
setSelection(pending.start, pending.end, pending.focus);
|
|
203
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
const undoStack = useEditorHistory({ value, inputRef, onValueChange });
|
|
207
|
-
|
|
208
|
-
/** Everything the dispatcher needs, built once per call from the live refs. */
|
|
209
|
-
const keydownDeps = (input: HTMLTextAreaElement): EditorKeydownDeps => ({
|
|
210
|
-
input,
|
|
211
|
-
language,
|
|
212
|
-
captureTab,
|
|
213
|
-
toggleCaptureTab: () => setCaptureTab((prev) => !prev),
|
|
214
|
-
history: undoStack,
|
|
215
|
-
multi,
|
|
216
|
-
select: (start, end) => setSelection(start, end, true),
|
|
217
|
-
delegate: (command) => {
|
|
218
|
-
onCommand?.(command);
|
|
219
|
-
},
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
const handleKeyDown = (e: TargetedKeyboardEvent<HTMLTextAreaElement>) => {
|
|
223
|
-
// The selection as of the last event that could precede an edit:
|
|
224
|
-
// `handleChange` needs to know which range the browser replaced, and the
|
|
225
|
-
// `input` event reports the selection AFTER the edit.
|
|
226
|
-
lastSelectionRef.current = { start: e.currentTarget.selectionStart, end: e.currentTarget.selectionEnd };
|
|
227
|
-
handleEditorKeydown(e, keydownDeps(e.currentTarget));
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
const handleChange = (e: Event) => {
|
|
231
|
-
const input = e.target as HTMLTextAreaElement;
|
|
232
|
-
const { value: next, selectionStart, selectionEnd } = input;
|
|
233
|
-
const history = undoStack.history;
|
|
234
|
-
const timestamp = Date.now();
|
|
235
|
-
// With extra ranges live, the browser's edit becomes a template: it is
|
|
236
|
-
// diffed out of the OLD buffer and replayed at every other range in one
|
|
237
|
-
// pass, so a keystroke, a paste and an IME composition all need no special
|
|
238
|
-
// case. The result is committed as one history entry, and the ranges
|
|
239
|
-
// collapse to carets so a second keystroke continues at all of them.
|
|
240
|
-
const replicated = multi.replicate(undoStack.buffer, next, lastSelectionRef.current);
|
|
241
|
-
if (replicated) {
|
|
242
|
-
undoStack.markApplied(replicated.value);
|
|
243
|
-
input.value = replicated.value;
|
|
244
|
-
input.selectionStart = replicated.caret;
|
|
245
|
-
input.selectionEnd = replicated.caret;
|
|
246
|
-
lastSelectionRef.current = { start: replicated.caret, end: replicated.caret };
|
|
247
|
-
multi.set(replicated.rest);
|
|
248
|
-
// The burst merge applies here too: without it each character of a word
|
|
249
|
-
// typed at several carets became its own undo entry, so ⌘Z peeled off one
|
|
250
|
-
// letter instead of the word — the exact behaviour the single-caret path
|
|
251
|
-
// had already been fixed for.
|
|
252
|
-
if (continuesTyping(history.stack[history.offset], replicated.value, replicated.caret, timestamp)) {
|
|
253
|
-
mergeTyping(history, replicated.value, replicated.caret, replicated.caret, timestamp);
|
|
254
|
-
} else {
|
|
255
|
-
pushRecord(history, { value: replicated.value, selectionStart: replicated.caret, selectionEnd: replicated.caret, timestamp });
|
|
256
|
-
}
|
|
257
|
-
onValueChange(replicated.value);
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
undoStack.markApplied(next);
|
|
262
|
-
// Merge typing bursts into one word-level undo entry.
|
|
263
|
-
if (continuesTyping(history.stack[history.offset], next, selectionStart, timestamp)) {
|
|
264
|
-
mergeTyping(history, next, selectionStart, selectionEnd, timestamp);
|
|
265
|
-
onValueChange(next);
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
pushRecord(history, { value: next, selectionStart, selectionEnd, timestamp });
|
|
269
|
-
onValueChange(next);
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
const { pre: preStyle, textarea: textareaStyle } = layerStyles(padding, virtual);
|
|
273
|
-
const layer = layerStyle();
|
|
274
|
-
|
|
275
|
-
const highlighted = highlight(value, virtual ?? null);
|
|
276
|
-
|
|
277
|
-
return (
|
|
278
|
-
<div className={className} style={{ position: 'relative', textAlign: 'left', boxSizing: 'border-box', padding: 0, overflow: 'hidden', ...style }}>
|
|
279
|
-
<pre
|
|
280
|
-
ref={preRef}
|
|
281
|
-
className={preClassName}
|
|
282
|
-
aria-hidden="true"
|
|
283
|
-
style={{ ...layer, ...preStyle, position: 'relative', pointerEvents: 'none' }}
|
|
284
|
-
dangerouslySetInnerHTML={{ __html: highlighted + (virtual && !virtual.breakAtEnd ? '' : '<br />') }}
|
|
285
|
-
/>
|
|
286
|
-
<textarea
|
|
287
|
-
ref={inputRef}
|
|
288
|
-
className={`${BASE_TEXTAREA_CLASS}${textareaClassName ? ` ${textareaClassName}` : ''}`}
|
|
289
|
-
style={{ ...layer, ...textareaStyle, position: 'absolute', top: 0, left: 0, height: '100%', width: '100%', resize: 'none', color: 'inherit', overflow: 'hidden', WebkitTextFillColor: 'transparent' }}
|
|
290
|
-
value={value}
|
|
291
|
-
onChange={handleChange}
|
|
292
|
-
onKeyDown={handleKeyDown}
|
|
293
|
-
onSelect={() => {
|
|
294
|
-
const input = inputRef.current;
|
|
295
|
-
if (input) lastSelectionRef.current = { start: input.selectionStart, end: input.selectionEnd };
|
|
296
|
-
}}
|
|
297
|
-
onBlur={() => multi.clear()}
|
|
298
|
-
placeholder={placeholder}
|
|
299
|
-
readOnly={readOnly}
|
|
300
|
-
autoFocus={autoFocus}
|
|
301
|
-
autoCapitalize="off"
|
|
302
|
-
autoComplete="off"
|
|
303
|
-
autoCorrect="off"
|
|
304
|
-
spellcheck={false}
|
|
305
|
-
data-gramm={false}
|
|
306
|
-
/>
|
|
307
|
-
{/* One <style> per editor instance; the rule is a constant so the
|
|
308
|
-
duplicate elements are the same three lines the browser dedupes. */}
|
|
309
|
-
<style dangerouslySetInnerHTML={{ __html: CODE_EDITOR_RESET_CSS }} />
|
|
310
|
-
</div>
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
export default CodeEditor;
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wrapped-row measurement for the line-number gutter.
|
|
3
|
-
*
|
|
4
|
-
* The gutter is a column of one-row line numbers, which is only right while a
|
|
5
|
-
* line fits on one row. With word wrap on, the editor lays a long line out over
|
|
6
|
-
* several rows and every number after it would sit above its own line. How many
|
|
7
|
-
* rows a line takes is a layout fact — font metrics, break opportunities, the
|
|
8
|
-
* width the editor actually got — so it is measured rather than estimated: the
|
|
9
|
-
* same lines are laid out once in a hidden mirror that copies the editor's
|
|
10
|
-
* typography, and each line's height is read back.
|
|
11
|
-
*
|
|
12
|
-
* Measuring every line on every keystroke is linear in the document, which no
|
|
13
|
-
* long file can afford, so heights are cached per line *text*: an edit re-reads
|
|
14
|
-
* the one line it touched, and a document whose lines all fit on one row is
|
|
15
|
-
* never mirrored at all.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* The mirror has to reproduce the editor's line breaking exactly, so every
|
|
20
|
-
* property that can move a break is copied from the live editor's computed
|
|
21
|
-
* style instead of being restated (a second copy of the font stack would drift).
|
|
22
|
-
*/
|
|
23
|
-
const MIRRORED_PROPERTIES = [
|
|
24
|
-
'direction',
|
|
25
|
-
'fontFamily',
|
|
26
|
-
'fontFeatureSettings',
|
|
27
|
-
'fontKerning',
|
|
28
|
-
'fontSize',
|
|
29
|
-
'fontStyle',
|
|
30
|
-
'fontVariantLigatures',
|
|
31
|
-
'fontWeight',
|
|
32
|
-
'letterSpacing',
|
|
33
|
-
'overflowWrap',
|
|
34
|
-
'tabSize',
|
|
35
|
-
'textRendering',
|
|
36
|
-
'textTransform',
|
|
37
|
-
'whiteSpace',
|
|
38
|
-
'wordBreak',
|
|
39
|
-
'wordSpacing',
|
|
40
|
-
] as const;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Newest-first ceiling on the height cache. A long editing session mints one
|
|
44
|
-
* entry per keystroke, so the oldest entries are dropped once it is reached;
|
|
45
|
-
* whatever leaves the cache is simply measured again on the next pass.
|
|
46
|
-
*/
|
|
47
|
-
const HEIGHT_CACHE_LIMIT = 100_000;
|
|
48
|
-
|
|
49
|
-
export interface WrappedLines {
|
|
50
|
-
/** Height in px of a single visual row. */
|
|
51
|
-
rowHeight: number;
|
|
52
|
-
/** Height in px each line of the source occupies once wrapped. */
|
|
53
|
-
heights: number[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
let mirrorHost: HTMLDivElement | null = null;
|
|
57
|
-
let mirrorLines: HTMLDivElement[] = [];
|
|
58
|
-
/** The column the cached heights were measured in; a new one re-wraps every line. */
|
|
59
|
-
let mirrorSignature = '';
|
|
60
|
-
const heightByLine = new Map<string, number>();
|
|
61
|
-
/** Line indexes awaiting a mirror read this pass; reused to keep the pass allocation-free. */
|
|
62
|
-
const pendingLines: number[] = [];
|
|
63
|
-
|
|
64
|
-
/** Off-screen mirror, reused across measurements (one per page). */
|
|
65
|
-
function getMirrorHost(): HTMLDivElement {
|
|
66
|
-
if (!mirrorHost || !mirrorHost.isConnected) {
|
|
67
|
-
mirrorHost = document.createElement('div');
|
|
68
|
-
mirrorHost.setAttribute('aria-hidden', 'true');
|
|
69
|
-
mirrorHost.style.cssText =
|
|
70
|
-
'position:fixed;top:0;left:-100000px;visibility:hidden;pointer-events:none;margin:0;border:0;padding:0;box-sizing:border-box;';
|
|
71
|
-
document.body.appendChild(mirrorHost);
|
|
72
|
-
mirrorLines = [];
|
|
73
|
-
}
|
|
74
|
-
return mirrorHost;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Height in px of `source`'s line box, or null while it cannot be measured (SSR, `line-height: normal`). */
|
|
78
|
-
export function measureRowHeight(source: HTMLPreElement): number | null {
|
|
79
|
-
if (typeof document === 'undefined' || !source.isConnected) return null;
|
|
80
|
-
const rowHeight = Number.parseFloat(getComputedStyle(source).lineHeight);
|
|
81
|
-
return Number.isFinite(rowHeight) && rowHeight > 0 ? rowHeight : null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Height each line of `value` occupies in `source`'s wrap layout, or null when
|
|
86
|
-
* the layout is not measurable yet (SSR, zero width, unresolved line-height) —
|
|
87
|
-
* callers then fall back to one row per line.
|
|
88
|
-
*/
|
|
89
|
-
export function measureWrappedLines(source: HTMLPreElement, value: string): WrappedLines | null {
|
|
90
|
-
const rowHeight = measureRowHeight(source);
|
|
91
|
-
if (rowHeight === null) return null;
|
|
92
|
-
const computed = getComputedStyle(source);
|
|
93
|
-
// The editor's text column: its padding is the gutter's offset, not text area.
|
|
94
|
-
const width =
|
|
95
|
-
source.clientWidth -
|
|
96
|
-
Number.parseFloat(computed.paddingLeft || '0') -
|
|
97
|
-
Number.parseFloat(computed.paddingRight || '0');
|
|
98
|
-
if (width <= 0) return null;
|
|
99
|
-
|
|
100
|
-
const host = getMirrorHost();
|
|
101
|
-
for (const property of MIRRORED_PROPERTIES) host.style[property] = computed[property];
|
|
102
|
-
host.style.lineHeight = `${rowHeight}px`;
|
|
103
|
-
host.style.width = `${width}px`;
|
|
104
|
-
|
|
105
|
-
const signature = `${width}|${rowHeight}|${MIRRORED_PROPERTIES.map((property) => computed[property]).join('|')}`;
|
|
106
|
-
if (signature !== mirrorSignature) {
|
|
107
|
-
mirrorSignature = signature;
|
|
108
|
-
heightByLine.clear();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const lines = value.split('\n');
|
|
112
|
-
const heights = new Array<number>(lines.length);
|
|
113
|
-
let pending = 0;
|
|
114
|
-
for (let i = 0; i < lines.length; i++) {
|
|
115
|
-
const cached = heightByLine.get(lines[i]);
|
|
116
|
-
if (cached === undefined) pendingLines[pending++] = i;
|
|
117
|
-
else heights[i] = cached;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (pending > 0) {
|
|
121
|
-
while (mirrorLines.length < pending) {
|
|
122
|
-
const line = document.createElement('div');
|
|
123
|
-
host.appendChild(line);
|
|
124
|
-
mirrorLines.push(line);
|
|
125
|
-
}
|
|
126
|
-
// Write the whole batch first, then read the whole layout: interleaving the
|
|
127
|
-
// two would force a re-layout per line.
|
|
128
|
-
for (let k = 0; k < pending; k++) mirrorLines[k].textContent = lines[pendingLines[k]];
|
|
129
|
-
for (let k = 0; k < pending; k++) {
|
|
130
|
-
const index = pendingLines[k];
|
|
131
|
-
const height = mirrorLines[k].getBoundingClientRect().height;
|
|
132
|
-
// An empty line has no line box at all, and sub-pixel wrapping would
|
|
133
|
-
// otherwise leave the gutter fractions of a row out of step.
|
|
134
|
-
const rows = height > rowHeight ? Math.round(height / rowHeight) * rowHeight : rowHeight;
|
|
135
|
-
heights[index] = rows;
|
|
136
|
-
heightByLine.set(lines[index], rows);
|
|
137
|
-
}
|
|
138
|
-
if (heightByLine.size > HEIGHT_CACHE_LIMIT) {
|
|
139
|
-
let drop = heightByLine.size - HEIGHT_CACHE_LIMIT / 2;
|
|
140
|
-
for (const key of heightByLine.keys()) {
|
|
141
|
-
if (drop-- <= 0) break;
|
|
142
|
-
heightByLine.delete(key);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return { rowHeight, heights };
|
|
148
|
-
}
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The composer's target picker and the New Worktree dialog, rendered.
|
|
8
|
-
*
|
|
9
|
-
* Unit-testing markup is usually a test of the test, so these cases are the ones
|
|
10
|
-
* a reader would otherwise discover in a browser and nowhere else:
|
|
11
|
-
*
|
|
12
|
-
* - the trigger names the WORKTREE when one is picked, because "kabmo" alone
|
|
13
|
-
* would be equally true of the checkout it is not running in;
|
|
14
|
-
* - the menu's groups are the three the plan names, and creating is offered
|
|
15
|
-
* ABOVE the list so a long list cannot hide it;
|
|
16
|
-
* - the dialog opens on a generated branch with the folder following it, and
|
|
17
|
-
* the folder keeps following until it is edited (the defect this rule
|
|
18
|
-
* prevents: a folder named after the previous branch).
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
22
|
-
import { Window } from 'happy-dom';
|
|
23
|
-
import { h, render } from 'preact';
|
|
24
|
-
import { act } from 'preact/test-utils';
|
|
25
|
-
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
26
|
-
import { TargetPicker, targetLabel } from '@/client/components/workspace/chat-timeline/target-picker/index';
|
|
27
|
-
import { NewWorktreeModal } from '@/client/components/workspace/worktree-modal/index';
|
|
28
|
-
import type { WorkspaceFolderData } from '@/shared/types';
|
|
29
|
-
|
|
30
|
-
const folder: WorkspaceFolderData = {
|
|
31
|
-
id: 1,
|
|
32
|
-
name: 'kabmo',
|
|
33
|
-
project_path: '/repo',
|
|
34
|
-
isExpanded: true,
|
|
35
|
-
hasMore: false,
|
|
36
|
-
totalSessions: 0,
|
|
37
|
-
sessions: [],
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const nativeFetch = Bun.fetch;
|
|
41
|
-
const mounted: HTMLElement[] = [];
|
|
42
|
-
|
|
43
|
-
beforeAll(() => {
|
|
44
|
-
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
afterEach(() => {
|
|
48
|
-
for (const host of mounted.splice(0)) {
|
|
49
|
-
render(null, host);
|
|
50
|
-
host.remove();
|
|
51
|
-
}
|
|
52
|
-
(globalThis as unknown as Record<string, unknown>).fetch = nativeFetch;
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
afterAll(() => {
|
|
56
|
-
restoreDomGlobals();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Mount, with the first render FLUSHED. Preact batches a state update into a
|
|
61
|
-
* microtask, so a `useEffect` that seeds a field (the dialog's generated branch)
|
|
62
|
-
* has not run when `render` returns — reading the inputs then reads empty ones,
|
|
63
|
-
* which is a property of the test harness and not of the dialog.
|
|
64
|
-
*/
|
|
65
|
-
async function mount(node: h.JSX.Element): Promise<HTMLElement> {
|
|
66
|
-
const host = document.createElement('div');
|
|
67
|
-
document.body.appendChild(host);
|
|
68
|
-
await act(async () => {
|
|
69
|
-
render(node, host);
|
|
70
|
-
});
|
|
71
|
-
mounted.push(host);
|
|
72
|
-
return host;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** The picker reads the worktree list on open; answer it without a server. */
|
|
76
|
-
/** Calls the picker made, so a test can tell "not read" from "read empty". */
|
|
77
|
-
let fetchCalls: string[] = [];
|
|
78
|
-
|
|
79
|
-
function stubWorktreeList(worktrees: unknown[] = []): void {
|
|
80
|
-
fetchCalls = [];
|
|
81
|
-
(globalThis as unknown as Record<string, unknown>).fetch = async (input: RequestInfo | URL) => {
|
|
82
|
-
fetchCalls.push(String(input));
|
|
83
|
-
// A plain envelope, not a real `Response`: `Response.json()` resolves on a
|
|
84
|
-
// stream tick that this harness has no reason to wait for, and the picker
|
|
85
|
-
// only reads `ok` and `json()`.
|
|
86
|
-
const payload = { worktrees, base: '/data/worktrees/x', isRepo: true };
|
|
87
|
-
return { ok: true, json: async () => payload } as unknown as Response;
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Collapsed whitespace: the markup carries newlines the reader never sees. */
|
|
92
|
-
function textOf(host: HTMLElement): string {
|
|
93
|
-
return (host.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
describe('targetLabel', () => {
|
|
97
|
-
test('the folder alone when the chat runs in the checkout', () => {
|
|
98
|
-
expect(targetLabel([folder], 1, null)).toBe('kabmo');
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test('folder · worktree when a worktree is picked', () => {
|
|
102
|
-
expect(targetLabel([folder], 1, { folderId: 1, worktreePath: '/data/worktrees/abc/cosmic-dolphin' })).toBe(
|
|
103
|
-
'kabmo · cosmic-dolphin',
|
|
104
|
-
);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('a Windows worktree path names the worktree, not the whole path', () => {
|
|
108
|
-
// The label was `split('/').pop()`, which has nothing to cut in a
|
|
109
|
-
// `\`-separated path: the trigger read `kabmo · C:\Users\…\cosmic-dolphin`
|
|
110
|
-
// on a Windows runner while every POSIX leg passed.
|
|
111
|
-
expect(
|
|
112
|
-
targetLabel([folder], 1, {
|
|
113
|
-
folderId: 1,
|
|
114
|
-
worktreePath: 'C:\\Users\\u\\AppData\\Local\\Temp\\kabmo\\worktrees\\abc\\cosmic-dolphin',
|
|
115
|
-
}),
|
|
116
|
-
).toBe('kabmo · cosmic-dolphin');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test('an unresolved folder still says what it is waiting for', () => {
|
|
120
|
-
expect(targetLabel([], null, null)).toBe('Select Workspace Context');
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe('TargetPicker', () => {
|
|
125
|
-
test('the menu offers the project root, both create paths, and the worktree list', async () => {
|
|
126
|
-
stubWorktreeList([
|
|
127
|
-
{ path: '/data/worktrees/x/old-one', branch: 'wt/old-one', detached: false, missing: false, isPrimary: false, dirty: 0, aheadOfStart: 0, startRef: 'HEAD', sessions: 0 },
|
|
128
|
-
]);
|
|
129
|
-
const host = await mount(
|
|
130
|
-
h(TargetPicker, {
|
|
131
|
-
folders: [folder],
|
|
132
|
-
selectedFolderId: 1,
|
|
133
|
-
setSelectedFolderId: () => {},
|
|
134
|
-
target: null,
|
|
135
|
-
setTarget: () => {},
|
|
136
|
-
}),
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
const trigger = host.querySelector('button');
|
|
140
|
-
expect(trigger?.textContent).toContain('kabmo');
|
|
141
|
-
await act(async () => {
|
|
142
|
-
(trigger as HTMLButtonElement).click();
|
|
143
|
-
});
|
|
144
|
-
// The list is read when the menu opens; each empty `act` drains one round of
|
|
145
|
-
// the fetch's promise chain, the same way the stash section's suite does.
|
|
146
|
-
for (let round = 0; round < 10; round += 1) await act(async () => {});
|
|
147
|
-
|
|
148
|
-
const text = textOf(host);
|
|
149
|
-
expect(text).toContain('Project root');
|
|
150
|
-
expect(text).toContain('Quick worktree');
|
|
151
|
-
expect(text).toContain('New worktree…');
|
|
152
|
-
expect(text).toContain('Worktrees');
|
|
153
|
-
// Creating sits above the LIST: a workspace with many worktrees must not
|
|
154
|
-
// push the action that makes one out of sight. Compared against a listed
|
|
155
|
-
// branch, because the group heading is also spelled "Worktrees".
|
|
156
|
-
expect(fetchCalls.length).toBeGreaterThan(0);
|
|
157
|
-
expect(text).toContain('wt/old-one');
|
|
158
|
-
expect(text.indexOf('Quick worktree')).toBeLessThan(text.indexOf('wt/old-one'));
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test('the trigger shows the picked worktree, not just the folder', async () => {
|
|
162
|
-
const host = await mount(
|
|
163
|
-
h(TargetPicker, {
|
|
164
|
-
folders: [folder],
|
|
165
|
-
selectedFolderId: 1,
|
|
166
|
-
setSelectedFolderId: () => {},
|
|
167
|
-
target: { folderId: 1, worktreePath: '/data/worktrees/abc/cosmic-dolphin' },
|
|
168
|
-
setTarget: () => {},
|
|
169
|
-
}),
|
|
170
|
-
);
|
|
171
|
-
expect(host.querySelector('button')?.textContent).toContain('kabmo · cosmic-dolphin');
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
describe('NewWorktreeModal', () => {
|
|
176
|
-
test('opens on a generated branch, with the folder following it', async () => {
|
|
177
|
-
const host = await mount(
|
|
178
|
-
h(NewWorktreeModal, { isOpen: true, onClose: () => {}, root: '/repo', folderId: 1, folderName: 'kabmo', onCreated: () => {} }),
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
const inputs = [...host.querySelectorAll('input')] as HTMLInputElement[];
|
|
182
|
-
const branch = inputs[0]?.value ?? '';
|
|
183
|
-
const folderName = inputs[2]?.value ?? '';
|
|
184
|
-
expect(branch).toMatch(/^wt\/[a-z]+-[a-z]+$/);
|
|
185
|
-
// The folder is the branch without our namespace, so it is never `wt-x`.
|
|
186
|
-
expect(folderName).toBe(branch.replace(/^wt\//, ''));
|
|
187
|
-
expect(folderName.startsWith('wt-')).toBe(false);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('the generated branch is in the FIRST paint, before any effect flushes', () => {
|
|
191
|
-
const host = document.createElement('div');
|
|
192
|
-
document.body.appendChild(host);
|
|
193
|
-
// Deliberately NOT `mount`: no `act`, so no effect has run. A field seeded
|
|
194
|
-
// by an effect reads empty here, and the browser's own window between paint
|
|
195
|
-
// and the effect flush is exactly when a user types into it — measured: the
|
|
196
|
-
// branch filled as `main` was replaced by the generated name, and the dialog
|
|
197
|
-
// created `wt/calm-iguana` instead of reporting that `main` is taken.
|
|
198
|
-
render(
|
|
199
|
-
h(NewWorktreeModal, { isOpen: true, onClose: () => {}, root: '/repo', folderId: 1, folderName: 'kabmo', onCreated: () => {} }),
|
|
200
|
-
host,
|
|
201
|
-
);
|
|
202
|
-
mounted.push(host);
|
|
203
|
-
|
|
204
|
-
const inputs = [...host.querySelectorAll('input')] as HTMLInputElement[];
|
|
205
|
-
const branch = inputs[0]?.value ?? '';
|
|
206
|
-
expect(branch).toMatch(/^wt\/[a-z]+-[a-z]+$/);
|
|
207
|
-
// One generated name for both fields: deriving the folder from a SECOND
|
|
208
|
-
// call would show a folder the branch does not have.
|
|
209
|
-
expect(inputs[2]?.value ?? '').toBe(branch.replace(/^wt\//, ''));
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test('states what happens to uncommitted changes, and on which project', async () => {
|
|
213
|
-
const host = await mount(
|
|
214
|
-
h(NewWorktreeModal, { isOpen: true, onClose: () => {}, root: '/repo', folderId: 1, folderName: 'kabmo', onCreated: () => {} }),
|
|
215
|
-
);
|
|
216
|
-
const text = textOf(host);
|
|
217
|
-
expect(text).toContain('Uncommitted changes stay in');
|
|
218
|
-
expect(text).toContain('kabmo');
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test('renders nothing while closed', async () => {
|
|
222
|
-
const host = await mount(
|
|
223
|
-
h(NewWorktreeModal, { isOpen: false, onClose: () => {}, root: '/repo', folderId: 1, onCreated: () => {} }),
|
|
224
|
-
);
|
|
225
|
-
expect(host.textContent).toBe('');
|
|
226
|
-
});
|
|
227
|
-
});
|