@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.
Files changed (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. 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
- });