@cueloop/client 0.1.0-alpha.102 → 0.1.0-alpha.104

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 (126) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/annotation-palette.ts +1 -0
  3. package/src/annotations/components/AnnotationCards.tsx +4 -0
  4. package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
  5. package/src/annotations/discussions.ts +1 -0
  6. package/src/annotations/thread-selection.ts +6 -0
  7. package/src/annotations/use-annotation-surface.tsx +97 -0
  8. package/src/app/App.deleted-scroll.test.tsx +3 -0
  9. package/src/app/App.harness-settings.test.tsx +77 -0
  10. package/src/app/App.plan-render.test.tsx +2 -0
  11. package/src/app/App.plan-review.test.tsx +1 -0
  12. package/src/app/App.readonly.test.tsx +63 -0
  13. package/src/app/App.test.tsx +1 -0
  14. package/src/app/App.tsx +94 -12
  15. package/src/app/App.visual.test.tsx +1 -0
  16. package/src/app/App.walk.test.tsx +1 -0
  17. package/src/app/App.workbench.test.tsx +1 -0
  18. package/src/app/app-screens.tsx +3 -2
  19. package/src/app/app-view-model.ts +11 -1
  20. package/src/app/components/WorkspacePanels.tsx +1 -0
  21. package/src/app/intent-dispatch.test.ts +1 -0
  22. package/src/app/intent-dispatch.ts +26 -0
  23. package/src/app/ready-signal.ts +2 -0
  24. package/src/app/run.ts +2 -0
  25. package/src/app/serve.test.ts +4 -0
  26. package/src/diff/components/AnnotatableFileView.tsx +1 -0
  27. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  28. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  29. package/src/diff/components/DiffContentView.tsx +25 -0
  30. package/src/diff/components/diff-content-view-layout.ts +3 -0
  31. package/src/diff/diff-fold.ts +4 -0
  32. package/src/diff/diff-hunk-curate.ts +3 -0
  33. package/src/diff/diff-intraline.ts +6 -0
  34. package/src/diff/diff-syntax.ts +8 -0
  35. package/src/diff/split-diff.ts +6 -0
  36. package/src/diff/view-diff.ts +18 -0
  37. package/src/diff/walk.ts +3 -0
  38. package/src/index.ts +1 -0
  39. package/src/integrations/attribution.ts +2 -0
  40. package/src/integrations/client-extension-registry.ts +4 -0
  41. package/src/integrations/clipboard.ts +1 -0
  42. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  43. package/src/integrations/components/ShareDialog.tsx +28 -0
  44. package/src/integrations/editor.ts +3 -0
  45. package/src/integrations/github-identity.test.ts +1 -0
  46. package/src/integrations/github-identity.ts +1 -0
  47. package/src/integrations/herdr.ts +2 -0
  48. package/src/integrations/pasted-image.ts +2 -0
  49. package/src/integrations/share.ts +46 -1
  50. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  51. package/src/keyboard/components/SlashComposer.tsx +3 -0
  52. package/src/keyboard/key-bindings.ts +3 -0
  53. package/src/keyboard/keymap.ts +51 -0
  54. package/src/keyboard/skills.ts +2 -0
  55. package/src/keyboard/slash-palette.ts +8 -0
  56. package/src/keyboard/thread-chords.ts +5 -0
  57. package/src/keyboard/use-shared-keyboard.ts +1 -0
  58. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  59. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  60. package/src/markdown/components/ThreadView.tsx +13 -0
  61. package/src/markdown/mark-runs.ts +4 -0
  62. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  63. package/src/markdown/markdown-grid.ts +6 -0
  64. package/src/markdown/markdown-highlight.ts +1 -0
  65. package/src/markdown/thread-code-syntax.ts +3 -0
  66. package/src/markdown/view-plan.ts +52 -0
  67. package/src/performance/perf-report.ts +1 -0
  68. package/src/performance/perf-timings.ts +1 -0
  69. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  70. package/src/prototype/prototype-browser.ts +11 -0
  71. package/src/settings/components/SettingsDialog.tsx +3 -0
  72. package/src/settings/config.test.ts +79 -1
  73. package/src/settings/config.ts +91 -1
  74. package/src/settings/use-settings-dialog.test.tsx +172 -0
  75. package/src/settings/use-settings-dialog.tsx +115 -1
  76. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  77. package/src/settings/use-thread-harness-setting.ts +86 -0
  78. package/src/stories/stories.test.tsx +4 -0
  79. package/src/stories/story.ts +6 -0
  80. package/src/terminal/ansi-screen.ts +5 -0
  81. package/src/terminal/components/terminal-pane.ts +12 -0
  82. package/src/terminal/ghostty-terminal.ts +10 -0
  83. package/src/terminal/kitty-image.ts +3 -0
  84. package/src/terminal/pty.ts +9 -0
  85. package/src/testing/test-support.test.ts +2 -0
  86. package/src/testing/test-support.ts +16 -0
  87. package/src/thread/agent-continuation.test.ts +2 -0
  88. package/src/thread/agent-transcript.ts +7 -0
  89. package/src/thread/components/AgentConfigControls.test.tsx +41 -0
  90. package/src/thread/components/AgentConfigControls.tsx +8 -1
  91. package/src/thread/components/AgentThreadPane.test.tsx +91 -3
  92. package/src/thread/components/AgentThreadPane.tsx +61 -0
  93. package/src/thread/components/ThreadTree.tsx +1 -0
  94. package/src/thread/components/thread-tree.test.ts +1 -0
  95. package/src/thread/thread-controller.curate.test.ts +2 -0
  96. package/src/thread/thread-controller.share.test.ts +47 -2
  97. package/src/thread/thread-controller.ts +129 -4
  98. package/src/thread/tree-view.ts +4 -0
  99. package/src/thread/use-thread-agent.test.tsx +1 -0
  100. package/src/thread/use-thread-agent.ts +5 -0
  101. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  102. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  103. package/src/ui/components/PromptDialog.tsx +2 -0
  104. package/src/ui/components/RootOverlay.tsx +1 -0
  105. package/src/ui/components/ScrollArea.tsx +2 -0
  106. package/src/ui/components/primitives/Button.tsx +1 -0
  107. package/src/ui/components/primitives/Tabs.tsx +1 -0
  108. package/src/ui/components/primitives/Tree.tsx +4 -0
  109. package/src/ui/components/primitives/tree-model.ts +7 -0
  110. package/src/ui/components/syntax-highlight.ts +5 -0
  111. package/src/ui/components/truncate-title.ts +1 -0
  112. package/src/ui/use-frame-measure.ts +3 -0
  113. package/src/ui/use-terminal-virtualizer.ts +6 -0
  114. package/src/workbench/components/ChangesColumn.tsx +7 -0
  115. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  116. package/src/workbench/components/EditorGrid.tsx +8 -0
  117. package/src/workbench/components/FileContentsView.tsx +2 -0
  118. package/src/workbench/components/GridTabContent.tsx +2 -0
  119. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  120. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  121. package/src/workbench/components/TreePane.tsx +2 -0
  122. package/src/workbench/components/editor-grid.ts +3 -0
  123. package/src/workbench/components/file-tree.ts +2 -0
  124. package/src/workbench/use-changes-workbench.ts +5 -0
  125. package/src/workbench/use-remember-layout.ts +2 -0
  126. package/src/workbench/use-repo-changes.ts +2 -0
@@ -111,10 +111,12 @@ export function treeRows(history: SessionHistory): TreeRow[] {
111
111
  };
112
112
 
113
113
  if (label !== undefined) row.label = label;
114
+
114
115
  rows.push(row);
115
116
  const siblings = children.get(entry.id) ?? [];
116
117
 
117
118
  if (siblings.length === 0) return;
119
+
118
120
  const trunk = trunkChild(siblings);
119
121
 
120
122
  for (const child of siblings) if (child !== trunk) walk(child, depth + 1, nextRound);
@@ -133,9 +135,11 @@ export function treeRows(history: SessionHistory): TreeRow[] {
133
135
  */
134
136
  export function entryTarget(history: SessionHistory, entryId: string): EntryTarget | null {
135
137
  if (entryId === tipOf(history)) return { kind: "here" };
138
+
136
139
  const tipBranch = Object.entries(history.tips).find(([, tip]) => tip === entryId);
137
140
 
138
141
  if (tipBranch) return { kind: "switch", branch: tipBranch[0] };
142
+
139
143
  const holds = (branch: string): boolean =>
140
144
  pathOf(history, tipOf(history, branch)).some((entry) => entry.id === entryId);
141
145
  const branch = holds(history.branch)
@@ -30,6 +30,7 @@ test("late configuration and action responses cannot replace a newer accepted st
30
30
  return new Promise((resolve) => {
31
31
  first = resolve;
32
32
  });
33
+
33
34
  state = { ...initial, phase: { kind: "running" } };
34
35
 
35
36
  return state;
@@ -38,10 +38,12 @@ export function useThreadAgent(id: string, home?: string, injected?: ThreadAgent
38
38
 
39
39
  const configure = async (api: ThreadAgentClient): Promise<void> => {
40
40
  if (!api.agentConfigure) return;
41
+
41
42
  const requested = ++revision.current;
42
43
  const configured = await api.agentConfigure({ id });
43
44
 
44
45
  if (!cancelled && requested === revision.current) setState(configured);
46
+
45
47
  await refresh(api);
46
48
  };
47
49
 
@@ -59,6 +61,7 @@ export function useThreadAgent(id: string, home?: string, injected?: ThreadAgent
59
61
 
60
62
  return;
61
63
  }
64
+
62
65
  unsubscribe = subscribeThreadState({
63
66
  connect: connectThreadObserver({ home, autostart: true }),
64
67
  matches: (event) => event.event === "agent.updated" && event.sessionId === id,
@@ -98,11 +101,13 @@ export function useThreadAgent(id: string, home?: string, injected?: ThreadAgent
98
101
  request: (client: ThreadAgentClient) => Promise<ThreadAgentState>,
99
102
  ): Promise<boolean> => {
100
103
  if (!client) return false;
104
+
101
105
  try {
102
106
  const requested = ++revision.current;
103
107
  const result = await request(client);
104
108
 
105
109
  if (requested === revision.current) setState(result);
110
+
106
111
  setError("");
107
112
 
108
113
  return true;
@@ -19,6 +19,7 @@ const noop = (): void => {};
19
19
 
20
20
  function findById(node: Renderable, id: string): Renderable | undefined {
21
21
  if (node.id === id) return node;
22
+
22
23
  for (const child of node.getChildren()) {
23
24
  const found = findById(child, id);
24
25
 
@@ -102,12 +103,14 @@ describe("the diff sheet's scrollbar", () => {
102
103
 
103
104
  if (!(scroller instanceof ScrollBoxRenderable))
104
105
  throw new Error("diff-scroll is not a scrollbox");
106
+
105
107
  const countThumbRows = (): number =>
106
108
  setup.captureSpans().lines.filter((line) => {
107
109
  let column = 0;
108
110
 
109
111
  for (const span of line.spans) {
110
112
  column += span.text.length;
113
+
111
114
  if (column >= width) return span.bg !== undefined && hex(span.bg) === DARK.textDim;
112
115
  }
113
116
 
@@ -26,6 +26,7 @@ export interface ThumbGeometry {
26
26
 
27
27
  export function thumbGeometry(state: ScrollState, track: number): ThumbGeometry | null {
28
28
  if (track <= 0 || state.height <= state.viewport || state.viewport <= 0) return null;
29
+
29
30
  const height = Math.max(1, Math.round((state.viewport / state.height) * track));
30
31
  const maxTop = state.height - state.viewport;
31
32
  const row = Math.round((state.top / maxTop) * (track - height));
@@ -64,7 +65,9 @@ export function OverlayScrollbar({ scrollbox, theme }: OverlayScrollbarProps): R
64
65
 
65
66
  const reveal = (): void => {
66
67
  setVisible(true);
68
+
67
69
  if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
70
+
68
71
  hideTimerRef.current = setTimeout(() => {
69
72
  if (!draggingRef.current) setVisible(false);
70
73
  }, HIDE_DELAY_MS);
@@ -73,6 +76,7 @@ export function OverlayScrollbar({ scrollbox, theme }: OverlayScrollbarProps): R
73
76
  // reveal on real scroll movement, never on first mount, then hide once idle
74
77
  useEffect(() => {
75
78
  if (prevTopRef.current !== null && state.top !== prevTopRef.current) reveal();
79
+
76
80
  prevTopRef.current = state.top;
77
81
  // eslint-disable-next-line react-hooks/exhaustive-deps
78
82
  }, [state.top]);
@@ -91,6 +95,7 @@ export function OverlayScrollbar({ scrollbox, theme }: OverlayScrollbarProps): R
91
95
  const box = scrollbox.current;
92
96
 
93
97
  if (!box || !geometry) return;
98
+
94
99
  const row = screenY - (trackRef.current?.y ?? 0) - Math.floor(geometry.height / 2);
95
100
  const ratio = Math.min(1, Math.max(0, row / Math.max(1, track - geometry.height)));
96
101
 
@@ -45,10 +45,12 @@ export function PromptDialog({
45
45
 
46
46
  useEffect(() => {
47
47
  if (!inputRef.current) return;
48
+
48
49
  inputRef.current.focus();
49
50
  inputRef.current.cursorOffset = value.length;
50
51
  // eslint-disable-next-line react-hooks/exhaustive-deps
51
52
  }, []);
53
+
52
54
  if (!isOpen) return null;
53
55
 
54
56
  return (
@@ -28,6 +28,7 @@ export function RootOverlayProvider({ children }: { children: React.ReactNode })
28
28
  clearOverlay: (key) =>
29
29
  setOverlays((current) => {
30
30
  if (!current.has(key)) return current;
31
+
31
32
  const next = new Map(current);
32
33
 
33
34
  next.delete(key);
@@ -35,11 +35,13 @@ export function ScrollArea({
35
35
  );
36
36
  const attach = (node: ScrollBoxRenderable | null): void => {
37
37
  innerRef.current = node;
38
+
38
39
  if (scrollRef) scrollRef.current = node;
39
40
  };
40
41
 
41
42
  useLayoutEffect(() => {
42
43
  if (revealId === undefined) return;
44
+
43
45
  innerRef.current?.scrollChildIntoView(`tree-row-${revealId}`);
44
46
  }, [revealId]);
45
47
 
@@ -48,6 +48,7 @@ export function Button({
48
48
  onMouseUp={(event: MouseEvent) => {
49
49
  // Consume the press so it never bubbles to an ancestor's onMouseUp (e.g. a card).
50
50
  event.stopPropagation();
51
+
51
52
  if (!isDisabled) onPress();
52
53
  }}
53
54
  >
@@ -60,6 +60,7 @@ export function TabList({ children }: TabListProps): React.ReactNode {
60
60
  const tabs = useContext(TabsContext);
61
61
 
62
62
  if (!tabs) throw new Error("TabList must render inside Tabs");
63
+
63
64
  const tokens = useComponentTheme(tabs.theme);
64
65
  const items: { id: string; label: string }[] = [];
65
66
 
@@ -31,7 +31,9 @@ function rowGlyph(isFolder: boolean, expanded: boolean, icons: TreeIcons): strin
31
31
 
32
32
  function toneColor(tone: TreeTone, theme: Theme): string {
33
33
  if (tone === "green") return theme.green;
34
+
34
35
  if (tone === "blue") return theme.blue;
36
+
35
37
  if (tone === "red") return theme.red;
36
38
 
37
39
  return theme.textDim;
@@ -90,9 +92,11 @@ export function Tree({
90
92
  lastClick.current?.id === row.id && isDoubleClick(lastClick.current.stamp, stamp);
91
93
 
92
94
  lastClick.current = { id: row.id, stamp };
95
+
93
96
  if (row.isFolder) onToggle?.(row.id);
94
97
  else {
95
98
  onSelect?.(row.id);
99
+
96
100
  if (doubleClick) onDoubleSelect?.(row.id);
97
101
  }
98
102
  }}
@@ -66,11 +66,15 @@ export function flattenTree(nodes: readonly TreeNode[], options: FlattenOptions)
66
66
  };
67
67
 
68
68
  if (node.status !== undefined) leaf.status = node.status;
69
+
69
70
  if (node.badge !== undefined) leaf.badge = node.badge;
71
+
70
72
  if (node.icon !== undefined) leaf.icon = node.icon;
73
+
71
74
  rows.push(leaf);
72
75
  continue;
73
76
  }
77
+
74
78
  const collapsed = options.flattenEmptyDirectories
75
79
  ? collapseChain(node)
76
80
  : { label: node.label, tail: node, chainIds: [node.id] };
@@ -85,8 +89,11 @@ export function flattenTree(nodes: readonly TreeNode[], options: FlattenOptions)
85
89
  };
86
90
 
87
91
  if (node.badge !== undefined) row.badge = node.badge;
92
+
88
93
  if (node.icon !== undefined) row.icon = node.icon;
94
+
89
95
  rows.push(row);
96
+
90
97
  if (expanded) walk(collapsed.tail.children ?? [], depth + 1);
91
98
  }
92
99
  };
@@ -19,6 +19,7 @@ export function filetypeFor(language?: string): string | undefined {
19
19
  const info = (language ?? "").toLowerCase();
20
20
 
21
21
  if (!info) return undefined;
22
+
22
23
  const aliases: SyntaxAliases = {
23
24
  ts: "typescript",
24
25
  js: "javascript",
@@ -40,6 +41,7 @@ export function filetypeForPath(path: string): string | undefined {
40
41
  const byBasename = basenameToFiletype.get(basename);
41
42
 
42
43
  if (byBasename) return byBasename;
44
+
43
45
  const dotIndex = basename.lastIndexOf(".");
44
46
 
45
47
  if (dotIndex <= 0) return undefined;
@@ -90,6 +92,7 @@ export function syntaxStyleFor(theme: Theme): SyntaxStyle {
90
92
  const cached = syntaxStyleCache.get(theme);
91
93
 
92
94
  if (cached) return cached;
95
+
93
96
  const style = SyntaxStyle.fromStyles(syntaxGroupStyles(theme));
94
97
 
95
98
  syntaxStyleCache.set(theme, style);
@@ -109,6 +112,7 @@ export function referenceStyleFor(theme: Theme): ReferenceStyle {
109
112
  const cached = referenceStyleCache.get(theme);
110
113
 
111
114
  if (cached) return cached;
115
+
112
116
  const entry: ReferenceStyle = {
113
117
  style: SyntaxStyle.fromStyles({ reference: { fg: theme.accent } }),
114
118
  styleId: 0,
@@ -126,6 +130,7 @@ export function colorForSyntaxGroup(group: string, theme: Theme): string | undef
126
130
  const direct = styles[group];
127
131
 
128
132
  if (direct) return direct.fg;
133
+
129
134
  // capture names are dotted (e.g. "keyword.control"); match the broadest prefix
130
135
  const base = group.split(".")[0]!;
131
136
 
@@ -7,6 +7,7 @@ export function truncateTitle(title: string, maxWidth: number): string {
7
7
  const oneLine = title.replace(/\n/g, " ");
8
8
 
9
9
  if (maxWidth <= 0) return "";
10
+
10
11
  const characters = [...oneLine];
11
12
 
12
13
  if (characters.length <= maxWidth) return oneLine;
@@ -39,6 +39,7 @@ export function subscribeToFrames(renderer: Renderer, measure: Measure): () => v
39
39
 
40
40
  return () => {
41
41
  measures.delete(measure);
42
+
42
43
  if (measures.size > 0) return;
43
44
 
44
45
  renderer.off("frame", notify);
@@ -59,6 +60,7 @@ export function useFrameMeasure<T>(
59
60
  useEffect(() => {
60
61
  // a measurement only needed while a popover is open subscribes no per-frame listener when closed
61
62
  if (!active) return;
63
+
62
64
  const measure = (): void => {
63
65
  const next = read();
64
66
 
@@ -66,6 +68,7 @@ export function useFrameMeasure<T>(
66
68
  };
67
69
 
68
70
  measure();
71
+
69
72
  if (renderer) return subscribeToFrames(renderer, measure);
70
73
  // eslint-disable-next-line react-hooks/exhaustive-deps
71
74
  }, [renderer, active]);
@@ -125,6 +125,7 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
125
125
  const scrollbox = options.scrollbox.current;
126
126
 
127
127
  if (!scrollbox) return null;
128
+
128
129
  if (container.current?.scrollbox !== scrollbox) {
129
130
  container.current = { scrollbox, view: asElement(scrollContainerView(scrollbox)) };
130
131
  }
@@ -135,6 +136,7 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
135
136
  const known = viewOfRenderable.current.get(renderable);
136
137
 
137
138
  if (known) return known;
139
+
138
140
  const view = asElement(rowView(renderable));
139
141
 
140
142
  viewOfRenderable.current.set(renderable, view);
@@ -168,9 +170,11 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
168
170
  const scrollbox = options.scrollbox.current;
169
171
 
170
172
  if (!scrollbox) return;
173
+
171
174
  const next = { width: scrollbox.viewport.width, height: scrollbox.viewport.height };
172
175
 
173
176
  if (next.width === last.width && next.height === last.height) return;
177
+
174
178
  last = next;
175
179
  callback(next);
176
180
  });
@@ -184,6 +188,7 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
184
188
  const scrollbox = options.scrollbox.current;
185
189
 
186
190
  if (!scrollbox || scrollbox.scrollTop === last) return;
191
+
187
192
  last = scrollbox.scrollTop;
188
193
  callback(last, false);
189
194
  };
@@ -242,6 +247,7 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
242
247
  totalSize: virtualizer.getTotalSize(),
243
248
  measureRef: (index) => (renderable) => {
244
249
  if (!renderable) return;
250
+
245
251
  const view = viewFor(renderable);
246
252
 
247
253
  indexOfView.current.set(view, index);
@@ -133,15 +133,21 @@ export function ChangesFileTree({
133
133
 
134
134
  useKeyboard((key) => {
135
135
  if (!focused || rows.length === 0) return;
136
+
136
137
  if (key.name === "j" || key.name === "down")
137
138
  return setCursor(Math.min(cursorIndex + 1, rows.length - 1));
139
+
138
140
  if (key.name === "k" || key.name === "up") return setCursor(Math.max(cursorIndex - 1, 0));
141
+
139
142
  const row = rows[cursorIndex];
140
143
 
141
144
  if (!row) return;
145
+
142
146
  if (key.name === "return" || key.name === "enter")
143
147
  return row.isFolder ? toggle(row.id) : onSelectFile(row.id, true);
148
+
144
149
  if (key.name === "l" && row.isFolder && collapsedIds.has(row.id)) return toggle(row.id);
150
+
145
151
  if (key.name === "h" && row.isFolder && !collapsedIds.has(row.id)) return toggle(row.id);
146
152
  });
147
153
 
@@ -162,6 +168,7 @@ export function ChangesFileTree({
162
168
  const index = rows.findIndex((row) => row.id === id);
163
169
 
164
170
  if (index >= 0) setCursor(index);
171
+
165
172
  onSelectFile(id);
166
173
  }}
167
174
  onDoubleSelect={(id) => onSelectFile(id, true)}
@@ -12,6 +12,7 @@ import { DARK } from "../../appearance/theme";
12
12
 
13
13
  function findById(node: Renderable, id: string): Renderable | undefined {
14
14
  if (node.id === id) return node;
15
+
15
16
  for (const child of node.getChildren()) {
16
17
  const found = findById(child, id);
17
18
 
@@ -89,6 +90,7 @@ test("keyboard navigation reveals the selected changed file", async () => {
89
90
  const scrollbox = findById(setup.renderer.root, "tree-scroll");
90
91
 
91
92
  if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("tree scrollbox missing");
93
+
92
94
  const scrollSteps: number[] = [];
93
95
 
94
96
  for (const [index, key] of Array.from({ length: 15 }, () => "j").entries()) {
@@ -173,6 +175,7 @@ test("horizontal name scrolling reveals the final extension beside the status",
173
175
  const label = findById(setup.renderer.root, `tree-label-${path}`);
174
176
 
175
177
  if (!(label instanceof TextRenderable)) throw new Error("file label missing");
178
+
176
179
  expect(label.maxScrollX).toBeGreaterThan(0);
177
180
  label.scrollX = label.maxScrollX;
178
181
  await settle(setup);
@@ -91,6 +91,7 @@ function EditorTabButton({
91
91
 
92
92
  lastClick.current = stamp;
93
93
  onSelect();
94
+
94
95
  if (doubleClick && tab.preview) onKeep?.();
95
96
  }}
96
97
  onMouseOver={() => setHovered(true)}
@@ -152,6 +153,7 @@ export function visibleTabWindow(
152
153
  ): TabWindow {
153
154
  // unmeasured strip: show everything, the header clips until the width is known
154
155
  if (stripWidth <= 0) return { first: 0, end: tabs.length };
156
+
155
157
  const width = (index: number): number => tabCellWidth(tabs[index]!, commentCount(tabs[index]!));
156
158
  const fitEnd = (first: number): number => {
157
159
  let used = first > 0 ? 1 : 0;
@@ -161,6 +163,7 @@ export function visibleTabWindow(
161
163
  const remaining = end + 1 < tabs.length ? 1 : 0;
162
164
 
163
165
  if (used + width(end) + remaining > stripWidth) break;
166
+
164
167
  used += width(end);
165
168
  end++;
166
169
  }
@@ -255,7 +258,9 @@ function EditorGroupPane({
255
258
 
256
259
  useSharedKeyboard((key) => {
257
260
  if (!menuOpen) return;
261
+
258
262
  if (key.name === "escape") return menuControl.closeMenu();
263
+
259
264
  const direction = splitDirectionForKey(key.name);
260
265
 
261
266
  if (direction && canSplit(direction)) {
@@ -266,6 +271,7 @@ function EditorGroupPane({
266
271
 
267
272
  useEffect(() => {
268
273
  if (!menuOpen) return;
274
+
269
275
  setOverlay(
270
276
  "split-menu",
271
277
  <box
@@ -282,6 +288,7 @@ function EditorGroupPane({
282
288
  <SplitMenu
283
289
  onPick={(direction) => {
284
290
  if (!canSplit(direction)) return;
291
+
285
292
  props.onSplit(group.id, direction);
286
293
  menuControl.closeMenu();
287
294
  }}
@@ -313,6 +320,7 @@ function EditorGroupPane({
313
320
  setWasZoomed(props.zoomed);
314
321
  setFirstVisible(0);
315
322
  }
323
+
316
324
  const activeIndex = Math.max(
317
325
  0,
318
326
  group.tabs.findIndex((tab) => tab.id === active?.id),
@@ -63,6 +63,7 @@ export function FileContentsView({
63
63
  </box>
64
64
  );
65
65
  }
66
+
66
67
  if (loaded.lines === null) {
67
68
  // a file that no longer reads in the Changes/Project view is one the working tree deleted;
68
69
  // the bottom pad lifts the text one row so it lines up with the footer-shortened thread empty state
@@ -74,6 +75,7 @@ export function FileContentsView({
74
75
  </box>
75
76
  );
76
77
  }
78
+
77
79
  const content = loaded.lines.join("\n");
78
80
 
79
81
  return (
@@ -141,6 +141,7 @@ export function GridTabContent(props: {
141
141
  />
142
142
  );
143
143
  }
144
+
144
145
  if (tab.kind !== "file") {
145
146
  return (
146
147
  <ChangesTabBody
@@ -156,6 +157,7 @@ export function GridTabContent(props: {
156
157
  />
157
158
  );
158
159
  }
160
+
159
161
  // a single-file tab shows that file's rows alone, so its row indices are its own: comments and
160
162
  // the caret report back in whole-diff indices; a single-file tab omits only the collapse action
161
163
  const fileRowIndices = props.rows.flatMap((row, index) => (row.file === tab.path ? [index] : []));
@@ -11,6 +11,7 @@ import { DARK } from "../../appearance/theme";
11
11
 
12
12
  function findById(node: Renderable, id: string): Renderable | undefined {
13
13
  if (node.id === id) return node;
14
+
14
15
  for (const child of node.getChildren()) {
15
16
  const found = findById(child, id);
16
17
 
@@ -64,6 +65,7 @@ test("keyboard navigation reveals the selected project row", async () => {
64
65
  const scrollbox = findById(setup.renderer.root, "tree-scroll");
65
66
 
66
67
  if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("tree scrollbox missing");
68
+
67
69
  const scrollSteps: number[] = [];
68
70
 
69
71
  for (const [index, key] of Array.from({ length: 15 }, () => "j").entries()) {
@@ -101,6 +103,7 @@ test("project wheel scrolling accelerates during a gesture and resets after a pa
101
103
  const scrollbox = findById(setup.renderer.root, "tree-scroll");
102
104
 
103
105
  if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("tree scrollbox missing");
106
+
104
107
  const acceleration = scrollbox.scrollAcceleration;
105
108
  const first = acceleration.tick(1000);
106
109
  const burst = Array.from({ length: 10 }, (_, index) => acceleration.tick(1016 + index * 16));
@@ -50,16 +50,22 @@ export function ProjectTreeView({
50
50
 
51
51
  useKeyboard((key) => {
52
52
  if (!focused || rows.length === 0) return;
53
+
53
54
  if (key.name === "j" || key.name === "down")
54
55
  return setCursorId(rows[Math.min(cursorIndex + 1, rows.length - 1)]!.id);
56
+
55
57
  if (key.name === "k" || key.name === "up")
56
58
  return setCursorId(rows[Math.max(cursorIndex - 1, 0)]!.id);
59
+
57
60
  const row = rows[cursorIndex];
58
61
 
59
62
  if (!row) return;
63
+
60
64
  if (key.name === "return" || key.name === "enter")
61
65
  return row.isFolder ? toggle(row.id) : onSelectFile(row.id, true);
66
+
62
67
  if (key.name === "l" && row.isFolder && !expandedIds.has(row.id)) return toggle(row.id);
68
+
63
69
  if (key.name === "h" && row.isFolder && expandedIds.has(row.id)) return toggle(row.id);
64
70
  });
65
71
 
@@ -69,6 +75,7 @@ export function ProjectTreeView({
69
75
 
70
76
  const refreshFiles = async (): Promise<void> => {
71
77
  if (requestInFlight) return;
78
+
72
79
  requestInFlight = true;
73
80
 
74
81
  try {
@@ -112,6 +119,7 @@ export function ProjectTreeView({
112
119
  </box>
113
120
  );
114
121
  }
122
+
115
123
  if (paths.length === 0) {
116
124
  return (
117
125
  <box
@@ -42,6 +42,7 @@ function trailerFor(row: TreeRow): string {
42
42
  const parts: string[] = [];
43
43
 
44
44
  if (row.tips.length) parts.push(`← ${row.tips.join(", ")}`);
45
+
45
46
  if (row.label !== undefined) parts.push(`⚑ ${row.label}`);
46
47
 
47
48
  return parts.length ? ` ${parts.join(" ")}` : "";
@@ -67,6 +68,7 @@ export function TreePane({
67
68
  // the trunk draws last, so a long history opens with its tip below the fold: keep the row in view
68
69
  useEffect(() => {
69
70
  if (revealId === undefined) return;
71
+
70
72
  try {
71
73
  scrollRef.current?.scrollChildIntoView(`tree-row-${revealId}`);
72
74
  } catch {
@@ -221,9 +221,11 @@ export function keepFileTab(node: EditorNode, groupId: string, tabId: string): E
221
221
  /** Drop empty groups and collapse a branch that ends up with one child; null when nothing remains. */
222
222
  export function pruneEmpty(node: EditorNode): EditorNode | null {
223
223
  if (node.type === "group") return node.tabs.length > 0 ? node : null;
224
+
224
225
  const kept = node.children.map(pruneEmpty).filter((child): child is EditorNode => child !== null);
225
226
 
226
227
  if (kept.length === 0) return null;
228
+
227
229
  if (kept.length === 1) return kept[0]!;
228
230
 
229
231
  return { ...node, children: kept };
@@ -254,6 +256,7 @@ export function splitGroup(
254
256
  const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
255
257
 
256
258
  if (active === undefined) return group;
259
+
257
260
  const movedTab: EditorTab =
258
261
  active.kind === "welcome" ? { ...active, id: freshId() } : { ...active, preview: false };
259
262
  const newGroup = makeGroup([movedTab]);
@@ -77,6 +77,7 @@ export function buildFileTree(
77
77
  : { id: prefix, label: segment, children: new Map() };
78
78
  level.set(segment, node);
79
79
  }
80
+
80
81
  if (!isLeaf) level = node.children!;
81
82
  });
82
83
  }
@@ -104,6 +105,7 @@ export function buildPathTree(paths: readonly string[]): TreeNode[] {
104
105
  : { id: prefix, label: segment, children: new Map() };
105
106
  level.set(segment, node);
106
107
  }
108
+
107
109
  if (!isLeaf) level = node.children!;
108
110
  });
109
111
  }
@@ -50,6 +50,7 @@ function initialPanes(options?: ChangesWorkbenchOptions): InitialPanes {
50
50
  zoomed: layout.zoomChanges && changesOpen,
51
51
  };
52
52
  }
53
+
53
54
  const welcomeSeed = options?.seed === "welcome";
54
55
 
55
56
  return {
@@ -96,13 +97,16 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
96
97
 
97
98
  const syncSession = (sessionId: string | undefined, isDiff: boolean): void => {
98
99
  if (sessionId === undefined || sessionId === seenSession.current) return;
100
+
99
101
  seenSession.current = sessionId;
100
102
  setProjectOpen(isDiff);
101
103
  setChangesOpen(isDiff);
102
104
  setProjectMode(isDiff ? (options?.layout?.projectMode ?? "changes") : "tree");
103
105
  rememberedChanges.current = isDiff;
106
+
104
107
  // a non-diff thread has no Changes editor; leaving zoom on would strand the hidden Thread pane
105
108
  if (!isDiff) setZoomed(false);
109
+
106
110
  setGrid(makeGroup([changesTab()]));
107
111
  setFocusedGroup(null);
108
112
  };
@@ -148,6 +152,7 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
148
152
 
149
153
  return makeGroup([changesTab()]);
150
154
  }
155
+
151
156
  // re-home focus when the closed group was pruned away, so the next open has a live target
152
157
  setFocusedGroup((current) =>
153
158
  current !== null && containsGroup(pruned, current) ? current : firstGroupId(pruned),
@@ -24,8 +24,10 @@ export function useRememberLayout(
24
24
 
25
25
  return;
26
26
  }
27
+
27
28
  // only the active workbench persists: on a bare launch App's unused panes must not clobber the shell's
28
29
  if (!active || layout === undefined) return;
30
+
29
31
  persistLayout(layoutFromPanes({ threads, changesOpen, projectOpen, zoomChanges }));
30
32
  }, [active, layout, threads, changesOpen, projectOpen, zoomChanges]);
31
33
  }
@@ -51,6 +51,7 @@ export function useRepoChanges({
51
51
 
52
52
  const refreshChanges = async (): Promise<void> => {
53
53
  if (requestInFlight) return;
54
+
54
55
  requestInFlight = true;
55
56
 
56
57
  try {
@@ -83,6 +84,7 @@ export function useRepoChanges({
83
84
 
84
85
  return () => {
85
86
  cancelled = true;
87
+
86
88
  if (timer !== null) clearInterval(timer);
87
89
  };
88
90
  }, [visible, diffSourceKey, refreshAutomatically]);