@cueloop/client 0.1.0-alpha.67 → 0.1.0-alpha.69
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/package.json +7 -6
- package/src/App.appearance.test.tsx +8 -6
- package/src/App.diff.test.tsx +3 -3
- package/src/App.export.test.tsx +9 -3
- package/src/App.plan-review.test.tsx +10 -8
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +17 -28
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +3 -4
- package/src/App.test.tsx +14 -18
- package/src/App.tsx +117 -42
- package/src/App.walk.test.tsx +2 -2
- package/src/App.workbench.test.tsx +85 -14
- package/src/app-screens.tsx +26 -10
- package/src/app-view-model.ts +17 -3
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.tsx +6 -6
- package/src/components/AppShell.tsx +62 -15
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +69 -0
- package/src/components/ChangesColumn.tsx +3 -2
- package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
- package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
- package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
- package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
- package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tabs.test.tsx +17 -4
- package/src/components/EditorGrid.tsx +49 -19
- package/src/components/FileContentsView.test.tsx +3 -3
- package/src/components/FileContentsView.tsx +37 -17
- package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
- package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
- package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
- package/src/components/ProjectTreeView.tsx +3 -2
- package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
- package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.tsx +3 -2
- package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
- package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
- package/src/components/ThreadsSidebar.tsx +5 -4
- package/src/components/TreePane.tsx +3 -2
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
- package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
- package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
- package/src/config.test.ts +19 -6
- package/src/config.ts +26 -6
- package/src/ghostty-terminal.test.ts +2 -1
- package/src/ghostty-terminal.ts +27 -4
- package/src/intent-dispatch.test.ts +2 -0
- package/src/intent-dispatch.ts +1 -1
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +5 -1
- package/src/keymap.ts +1 -1
- package/src/prototype-browser.ts +8 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/session-controller.live-diff.test.ts +198 -0
- package/src/session-controller.ts +141 -26
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +32 -18
- package/src/test-support.test.ts +12 -0
- package/src/test-support.ts +69 -10
- package/src/theme-presets.ts +20 -0
- package/src/theme.ts +18 -0
- package/src/thread-chords.ts +1 -1
- package/src/use-annotation-surface.tsx +12 -2
- package/src/use-settings-dialog.tsx +4 -4
- package/src/view-diff.test.ts +44 -1
- package/src/view-diff.ts +81 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
- package/src/components/AnnotationCard.stories.tsx +0 -94
- package/src/components/AnnotationCard.tsx +0 -250
- package/src/components/MarkerPopover.stories.tsx +0 -58
- package/src/components/MarkerPopover.tsx +0 -109
- package/src/components/WelcomeSurface.stories.tsx +0 -12
- package/src/components/WelcomeSurface.tsx +0 -52
package/src/test-support.ts
CHANGED
|
@@ -7,10 +7,59 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import type
|
|
10
|
+
import { cloneElement, isValidElement, type ReactNode } from "react";
|
|
11
|
+
import type { TestRendererOptions, TestRendererSetup } from "@opentui/core/testing";
|
|
12
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
13
|
+
import type { AppProps } from "./App";
|
|
11
14
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
15
|
+
/**
|
|
16
|
+
* A probe for the App's ready signal in an in-process suite: pass `onReady`
|
|
17
|
+
* to the App and await `ready` before the first interaction, instead of
|
|
18
|
+
* waiting for a piece of text and hoping the keyboard is bound by then.
|
|
19
|
+
*/
|
|
20
|
+
export interface AppReadyProbe {
|
|
21
|
+
/** Pass as the App's `onReady` prop. */
|
|
22
|
+
onReady: () => void;
|
|
23
|
+
/** Resolves once the App has fired its ready signal. */
|
|
24
|
+
ready: Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function appReadyProbe(): AppReadyProbe {
|
|
28
|
+
let resolve: () => void = () => {};
|
|
29
|
+
const ready = new Promise<void>((done) => {
|
|
30
|
+
resolve = done;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return { onReady: () => resolve(), ready };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Boot an App element and resolve once it has fired its ready signal: the
|
|
38
|
+
* first usable screen is painted and its keyboard handlers are subscribed.
|
|
39
|
+
* Every App suite starts here so no boot waits on a piece of text and hopes.
|
|
40
|
+
*/
|
|
41
|
+
export async function renderReadyApp(
|
|
42
|
+
element: ReactNode,
|
|
43
|
+
options: TestRendererOptions,
|
|
44
|
+
): Promise<TestRendererSetup> {
|
|
45
|
+
if (!isValidElement<AppProps>(element)) throw new Error("renderReadyApp needs an App element");
|
|
46
|
+
const probe = appReadyProbe();
|
|
47
|
+
const setup = await testRender(cloneElement(element, { onReady: probe.onReady }), options);
|
|
48
|
+
|
|
49
|
+
await probe.ready;
|
|
50
|
+
|
|
51
|
+
return setup;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The CUELOOP_CONFIG path that isolates a test from the developer's real user
|
|
56
|
+
* config: a file inside the test home that does not exist unless the test
|
|
57
|
+
* writes it. Subprocess harnesses set this in the child env; in-process suites
|
|
58
|
+
* use `isolateUserConfig`.
|
|
59
|
+
*/
|
|
60
|
+
export function isolatedUserConfigPath(home: string, fileName = "no-config.toml"): string {
|
|
61
|
+
return join(home, fileName);
|
|
62
|
+
}
|
|
14
63
|
|
|
15
64
|
/**
|
|
16
65
|
* Isolate the user config: point CUELOOP_CONFIG into the test home so
|
|
@@ -20,10 +69,10 @@ export const WAIT_PASSES = { maxPasses: 400 };
|
|
|
20
69
|
* defaults; pass a file name for suites that write and assert a config of
|
|
21
70
|
* their own. Call in beforeEach; invoke the returned restore in afterEach.
|
|
22
71
|
*/
|
|
23
|
-
export function isolateUserConfig(home: string, fileName
|
|
72
|
+
export function isolateUserConfig(home: string, fileName?: string): () => void {
|
|
24
73
|
const priorUserConfig = process.env.CUELOOP_CONFIG;
|
|
25
74
|
|
|
26
|
-
process.env.CUELOOP_CONFIG =
|
|
75
|
+
process.env.CUELOOP_CONFIG = isolatedUserConfigPath(home, fileName);
|
|
27
76
|
|
|
28
77
|
return () => {
|
|
29
78
|
if (priorUserConfig === undefined) delete process.env.CUELOOP_CONFIG;
|
|
@@ -114,17 +163,27 @@ export interface FrameLocation {
|
|
|
114
163
|
column: number;
|
|
115
164
|
}
|
|
116
165
|
|
|
117
|
-
/** Locate the first
|
|
118
|
-
export function
|
|
119
|
-
const frame = setup.captureCharFrame();
|
|
120
|
-
|
|
166
|
+
/** Locate the first line of a text frame containing the needle; null when absent. */
|
|
167
|
+
export function locateTextInFrame(frame: string, needle: string): FrameLocation | null {
|
|
121
168
|
for (const [row, line] of frame.split("\n").entries()) {
|
|
122
169
|
const column = line.indexOf(needle);
|
|
123
170
|
|
|
124
171
|
if (column !== -1) return { row, column };
|
|
125
172
|
}
|
|
126
173
|
|
|
127
|
-
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Locate the first visual line containing the needle: 0-based row and start column. */
|
|
178
|
+
export function locateText(setup: TestRendererSetup, needle: string): FrameLocation {
|
|
179
|
+
const frame = setup.captureCharFrame();
|
|
180
|
+
const location = locateTextInFrame(frame, needle);
|
|
181
|
+
|
|
182
|
+
if (location === null) {
|
|
183
|
+
throw new Error(`locateText ${JSON.stringify(needle)} not found.\nframe:\n${frame}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return location;
|
|
128
187
|
}
|
|
129
188
|
|
|
130
189
|
/** The 0-based visual row of the needle - for relative vertical-layout assertions. */
|
package/src/theme-presets.ts
CHANGED
|
@@ -50,6 +50,10 @@ const ROSE_PINE_MOON: Theme = {
|
|
|
50
50
|
markCommentBackground: "#382a37",
|
|
51
51
|
insertedForeground: "#9ccfd8",
|
|
52
52
|
deletedForeground: "#eb6f92",
|
|
53
|
+
insertedBackground: "#243a3f",
|
|
54
|
+
insertedEmphasisBackground: "#31555c",
|
|
55
|
+
deletedBackground: "#3a2530",
|
|
56
|
+
deletedEmphasisBackground: "#563643",
|
|
53
57
|
backdrop: "transparent",
|
|
54
58
|
};
|
|
55
59
|
|
|
@@ -71,6 +75,10 @@ const CATPPUCCIN_MOCHA: Theme = {
|
|
|
71
75
|
markCommentBackground: "#33222b",
|
|
72
76
|
insertedForeground: "#a6e3a1",
|
|
73
77
|
deletedForeground: "#f38ba8",
|
|
78
|
+
insertedBackground: "#26372b",
|
|
79
|
+
insertedEmphasisBackground: "#375140",
|
|
80
|
+
deletedBackground: "#382530",
|
|
81
|
+
deletedEmphasisBackground: "#553844",
|
|
74
82
|
backdrop: "transparent",
|
|
75
83
|
};
|
|
76
84
|
|
|
@@ -92,6 +100,10 @@ const TOKYO_NIGHT: Theme = {
|
|
|
92
100
|
markCommentBackground: "#2f2230",
|
|
93
101
|
insertedForeground: "#9ece6a",
|
|
94
102
|
deletedForeground: "#f7768e",
|
|
103
|
+
insertedBackground: "#233028",
|
|
104
|
+
insertedEmphasisBackground: "#33493a",
|
|
105
|
+
deletedBackground: "#32232e",
|
|
106
|
+
deletedEmphasisBackground: "#4d3543",
|
|
95
107
|
backdrop: "transparent",
|
|
96
108
|
};
|
|
97
109
|
|
|
@@ -113,6 +125,10 @@ const GRUVBOX_DARK: Theme = {
|
|
|
113
125
|
markCommentBackground: "#3a2823",
|
|
114
126
|
insertedForeground: "#b8bb26",
|
|
115
127
|
deletedForeground: "#fb4934",
|
|
128
|
+
insertedBackground: "#333521",
|
|
129
|
+
insertedEmphasisBackground: "#4a4d2c",
|
|
130
|
+
deletedBackground: "#3c2a24",
|
|
131
|
+
deletedEmphasisBackground: "#5a3a30",
|
|
116
132
|
backdrop: "transparent",
|
|
117
133
|
};
|
|
118
134
|
|
|
@@ -134,6 +150,10 @@ const NORD: Theme = {
|
|
|
134
150
|
markCommentBackground: "#3a2f33",
|
|
135
151
|
insertedForeground: "#a3be8c",
|
|
136
152
|
deletedForeground: "#bf616a",
|
|
153
|
+
insertedBackground: "#333d33",
|
|
154
|
+
insertedEmphasisBackground: "#454f42",
|
|
155
|
+
deletedBackground: "#3c2f33",
|
|
156
|
+
deletedEmphasisBackground: "#544045",
|
|
137
157
|
backdrop: "transparent",
|
|
138
158
|
};
|
|
139
159
|
|
package/src/theme.ts
CHANGED
|
@@ -22,6 +22,12 @@ export interface Theme {
|
|
|
22
22
|
markCommentBackground: string;
|
|
23
23
|
insertedForeground: string;
|
|
24
24
|
deletedForeground: string;
|
|
25
|
+
/** The band behind a whole added line, and the stronger tint under its intra-line changed words. */
|
|
26
|
+
insertedBackground: string;
|
|
27
|
+
insertedEmphasisBackground: string;
|
|
28
|
+
/** The band behind a whole removed line, and the stronger tint under its intra-line changed words. */
|
|
29
|
+
deletedBackground: string;
|
|
30
|
+
deletedEmphasisBackground: string;
|
|
25
31
|
/** Layer behind centered dialogs; transparent keeps the session visible. */
|
|
26
32
|
backdrop: string;
|
|
27
33
|
}
|
|
@@ -42,6 +48,10 @@ export function dimmedTheme(theme: Theme): Theme {
|
|
|
42
48
|
blue: theme.textDim,
|
|
43
49
|
insertedForeground: theme.textDim,
|
|
44
50
|
deletedForeground: theme.textDim,
|
|
51
|
+
insertedBackground: theme.panel,
|
|
52
|
+
insertedEmphasisBackground: theme.elevated,
|
|
53
|
+
deletedBackground: theme.panel,
|
|
54
|
+
deletedEmphasisBackground: theme.elevated,
|
|
45
55
|
};
|
|
46
56
|
}
|
|
47
57
|
|
|
@@ -63,6 +73,10 @@ export const DARK: Theme = {
|
|
|
63
73
|
markCommentBackground: "#3d2a2e",
|
|
64
74
|
insertedForeground: "#62d96b",
|
|
65
75
|
deletedForeground: "#ff6b6b",
|
|
76
|
+
insertedBackground: "#16321d",
|
|
77
|
+
insertedEmphasisBackground: "#245c30",
|
|
78
|
+
deletedBackground: "#341c1f",
|
|
79
|
+
deletedEmphasisBackground: "#5a2a2e",
|
|
66
80
|
backdrop: "transparent",
|
|
67
81
|
};
|
|
68
82
|
|
|
@@ -89,5 +103,9 @@ export const LIGHT: Theme = {
|
|
|
89
103
|
markCommentBackground: "#f2e2e8",
|
|
90
104
|
insertedForeground: "#2f8a3e",
|
|
91
105
|
deletedForeground: "#c0392b",
|
|
106
|
+
insertedBackground: "#d8f5dd",
|
|
107
|
+
insertedEmphasisBackground: "#a9e6b6",
|
|
108
|
+
deletedBackground: "#fbdcdc",
|
|
109
|
+
deletedEmphasisBackground: "#f5b7b7",
|
|
92
110
|
backdrop: "transparent",
|
|
93
111
|
};
|
package/src/thread-chords.ts
CHANGED
|
@@ -33,7 +33,7 @@ export const DIFF_CHORD_ENTRIES = [
|
|
|
33
33
|
{ keys: "⌥x / ⌥X", label: "reject the change / the hunk" },
|
|
34
34
|
{ keys: "⌥u", label: "restore the last rejection" },
|
|
35
35
|
{ keys: "⌥c", label: "collapse the file to its band" },
|
|
36
|
-
{ keys: "⌥d", label: "
|
|
36
|
+
{ keys: "⌥d", label: "split / stacked (when wide)" },
|
|
37
37
|
{ keys: "⌥k", label: "start the guided walk" },
|
|
38
38
|
] as const;
|
|
39
39
|
|
|
@@ -73,6 +73,12 @@ export interface AnnotationSurfaceOptions {
|
|
|
73
73
|
quickActions: QuickAction[];
|
|
74
74
|
tokens: Theme;
|
|
75
75
|
observer: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Whether this surface accepts comments. False for a non-diff thread's live
|
|
78
|
+
* working-tree diff, which is view-only: its rows are diff rows, but the
|
|
79
|
+
* thread anchors comments in plan coordinates, so a draft here would misanchor.
|
|
80
|
+
*/
|
|
81
|
+
commentsEnabled?: boolean;
|
|
76
82
|
/** A verdict is in: no draft may open; the app answers with its read-only status. */
|
|
77
83
|
resolved: boolean;
|
|
78
84
|
/** True while a menu, dialog, or overlay owns the keyboard. */
|
|
@@ -152,6 +158,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
152
158
|
quickActions,
|
|
153
159
|
tokens,
|
|
154
160
|
observer,
|
|
161
|
+
commentsEnabled = true,
|
|
155
162
|
resolved,
|
|
156
163
|
suspended,
|
|
157
164
|
onComposingChange,
|
|
@@ -283,6 +290,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
283
290
|
}));
|
|
284
291
|
|
|
285
292
|
const openCompose = (state: ComposeState): void => {
|
|
293
|
+
// a view-only surface (a non-diff thread's live diff) never opens a draft
|
|
294
|
+
if (!commentsEnabled) return;
|
|
286
295
|
if (observer) return onObserverBlocked?.("observer");
|
|
287
296
|
if (resolved) return onObserverBlocked?.("resolved");
|
|
288
297
|
composerReady.current = false;
|
|
@@ -296,7 +305,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
296
305
|
setCompose(null);
|
|
297
306
|
setComposeText("");
|
|
298
307
|
};
|
|
299
|
-
|
|
308
|
+
// open only while typing the token: a leading "/" with no space yet
|
|
309
|
+
const slashActive = compose !== null && /^\/\S*$/.test(composeText);
|
|
300
310
|
const slashItems = slashActive
|
|
301
311
|
? slashFilter(slashItemsFrom(quickActions), composeText.slice(1).trim())
|
|
302
312
|
: [];
|
|
@@ -417,7 +427,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
417
427
|
return true;
|
|
418
428
|
}
|
|
419
429
|
if (key.name === "return" || key.name === "tab") {
|
|
420
|
-
openCompose({ ...activeCompose, seed: slashItems[selected]!.
|
|
430
|
+
openCompose({ ...activeCompose, seed: `/${slashItems[selected]!.name} ` });
|
|
421
431
|
|
|
422
432
|
return true;
|
|
423
433
|
}
|
|
@@ -142,9 +142,9 @@ export function useSettingsDialog(params: {
|
|
|
142
142
|
},
|
|
143
143
|
{
|
|
144
144
|
key: "diffView",
|
|
145
|
-
label: "Diff view (when
|
|
145
|
+
label: "Diff view (when wide)",
|
|
146
146
|
kind: "cycle",
|
|
147
|
-
options: ["
|
|
147
|
+
options: ["Split", "Stacked"],
|
|
148
148
|
},
|
|
149
149
|
],
|
|
150
150
|
},
|
|
@@ -191,7 +191,7 @@ export function useSettingsDialog(params: {
|
|
|
191
191
|
];
|
|
192
192
|
const settingsValues = {
|
|
193
193
|
autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
|
|
194
|
-
diffView: diffView === "split" ? "Split" : "
|
|
194
|
+
diffView: diffView === "split" ? "Split" : "Stacked",
|
|
195
195
|
theme: THEME_LABELS[themeName],
|
|
196
196
|
};
|
|
197
197
|
const cycleSetting = (rowKey: string): void => {
|
|
@@ -201,7 +201,7 @@ export function useSettingsDialog(params: {
|
|
|
201
201
|
setAutoClose(next);
|
|
202
202
|
persistAutoClose(next);
|
|
203
203
|
} else if (rowKey === "diffView") {
|
|
204
|
-
const next: DiffViewMode = diffView === "
|
|
204
|
+
const next: DiffViewMode = diffView === "stacked" ? "split" : "stacked";
|
|
205
205
|
|
|
206
206
|
setDiffView(next);
|
|
207
207
|
persistDiffView(next);
|
package/src/view-diff.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { makeAnchor, type Annotation } from "@cueloop/schema";
|
|
3
|
+
import { diffRowAnchor, diffRowBlocks, diffRows, fileRowRange, fileTargetMarks } from "./view-diff";
|
|
3
4
|
|
|
4
5
|
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
5
6
|
index 111..222 100644
|
|
@@ -67,3 +68,45 @@ describe("diffRowAnchor", () => {
|
|
|
67
68
|
expect(anchor.suffix.length).toBeGreaterThan(0);
|
|
68
69
|
});
|
|
69
70
|
});
|
|
71
|
+
|
|
72
|
+
describe("fileTargetMarks", () => {
|
|
73
|
+
// two files whose added line is byte-identical; a note on one must never mark the other
|
|
74
|
+
const TWO_FILE = `diff --git a/a.ts b/a.ts
|
|
75
|
+
--- a/a.ts
|
|
76
|
+
+++ b/a.ts
|
|
77
|
+
@@ -1,1 +1,2 @@
|
|
78
|
+
const keep = 0;
|
|
79
|
+
+const dup = 1;
|
|
80
|
+
diff --git a/b.ts b/b.ts
|
|
81
|
+
--- a/b.ts
|
|
82
|
+
+++ b/b.ts
|
|
83
|
+
@@ -1,1 +1,2 @@
|
|
84
|
+
const keep = 0;
|
|
85
|
+
+const dup = 1;
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
test("a file-target note marks only its own file, never identical text in another", () => {
|
|
89
|
+
// Arrange - anchor the added line inside a.ts, file-relative like the controller does
|
|
90
|
+
const rows = diffRows(TWO_FILE);
|
|
91
|
+
const range = fileRowRange(rows, "a.ts")!;
|
|
92
|
+
const fileRows = rows.slice(range.start, range.end);
|
|
93
|
+
const addRelative = fileRows.findIndex((row) => row.kind === "add");
|
|
94
|
+
const quote = fileRows[addRelative]!.text.replace(/\n$/, "");
|
|
95
|
+
const anchor = makeAnchor(diffRowBlocks(fileRows), addRelative, 0, quote.length, addRelative);
|
|
96
|
+
const annotation: Annotation = {
|
|
97
|
+
id: "a1",
|
|
98
|
+
kind: "comment",
|
|
99
|
+
anchor,
|
|
100
|
+
target: { kind: "file", path: "a.ts", rev: "worktree" },
|
|
101
|
+
body: "note",
|
|
102
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Act
|
|
106
|
+
const marked = [...fileTargetMarks([annotation], rows).keys()];
|
|
107
|
+
|
|
108
|
+
// Assert - every marked row sits inside a.ts's range, and b.ts's identical line is untouched
|
|
109
|
+
expect(marked.length).toBeGreaterThan(0);
|
|
110
|
+
expect(marked.every((index) => index >= range.start && index < range.end)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/view-diff.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
|
|
9
9
|
import {
|
|
10
|
+
annotationTarget,
|
|
10
11
|
isAddressed,
|
|
11
12
|
resolveAnchor,
|
|
12
13
|
type Annotation,
|
|
@@ -167,6 +168,86 @@ export function marksByRows(
|
|
|
167
168
|
return marksByIndex;
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* A whole file rendered as context rows, so the diff sheet can show and annotate a plain file the
|
|
173
|
+
* same way it does a diff: every line is unchanged, numbered on both sides (the file view collapses
|
|
174
|
+
* that to one number). A trailing newline does not add an empty final row.
|
|
175
|
+
*/
|
|
176
|
+
export function fileContentsRows(path: string, contents: string): DiffRow[] {
|
|
177
|
+
const lines = contents.replace(/\n$/, "").split("\n");
|
|
178
|
+
|
|
179
|
+
return lines.map((text, index) => ({
|
|
180
|
+
kind: "ctx",
|
|
181
|
+
text,
|
|
182
|
+
file: path,
|
|
183
|
+
oldLine: index + 1,
|
|
184
|
+
newLine: index + 1,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The contiguous row range a file occupies in the aggregate diff, or null when it is not shown. */
|
|
189
|
+
export function fileRowRange(rows: DiffRow[], path: string): { start: number; end: number } | null {
|
|
190
|
+
const start = rows.findIndex((row) => row.file === path);
|
|
191
|
+
|
|
192
|
+
if (start === -1) return null;
|
|
193
|
+
let end = start;
|
|
194
|
+
|
|
195
|
+
while (end < rows.length && rows[end]!.file === path) end++;
|
|
196
|
+
|
|
197
|
+
return { start, end };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Marks for file-target notes on the aggregate working-tree diff. Each note resolves against only
|
|
202
|
+
* its own file's rows, then the row index and span shift back to aggregate coordinates - so a quote
|
|
203
|
+
* never attaches to the same text in another file or on the opposite diff side.
|
|
204
|
+
*/
|
|
205
|
+
export function fileTargetMarks(
|
|
206
|
+
annotations: Annotation[],
|
|
207
|
+
rows: DiffRow[],
|
|
208
|
+
focusedId?: string,
|
|
209
|
+
): Map<number, Mark[]> {
|
|
210
|
+
const byFile = new Map<string, Annotation[]>();
|
|
211
|
+
|
|
212
|
+
for (const annotation of annotations) {
|
|
213
|
+
const target = annotationTarget(annotation);
|
|
214
|
+
|
|
215
|
+
if (target.kind !== "file") continue;
|
|
216
|
+
const forPath = byFile.get(target.path) ?? [];
|
|
217
|
+
|
|
218
|
+
forPath.push(annotation);
|
|
219
|
+
byFile.set(target.path, forPath);
|
|
220
|
+
}
|
|
221
|
+
const result = new Map<number, Mark[]>();
|
|
222
|
+
|
|
223
|
+
for (const [path, fileAnnotations] of byFile) {
|
|
224
|
+
const range = fileRowRange(rows, path);
|
|
225
|
+
|
|
226
|
+
if (!range) continue;
|
|
227
|
+
const base = range.start;
|
|
228
|
+
const fileMarks = marksByRows(fileAnnotations, rows.slice(range.start, range.end), focusedId);
|
|
229
|
+
|
|
230
|
+
for (const [relativeRow, marks] of fileMarks) {
|
|
231
|
+
result.set(
|
|
232
|
+
relativeRow + base,
|
|
233
|
+
marks.map((mark) =>
|
|
234
|
+
mark.span
|
|
235
|
+
? {
|
|
236
|
+
...mark,
|
|
237
|
+
span: {
|
|
238
|
+
start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + base },
|
|
239
|
+
end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + base },
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
: mark,
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
170
251
|
/**
|
|
171
252
|
* Comments per file path across the diff: each discussion counts its comments (root plus
|
|
172
253
|
* replies) toward the file its span ends in. Feeds the changed-files tree and tab badges, so a
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The composer's auto-grow row math as a pure function: hard newlines and soft
|
|
3
|
-
* wrapping both add rows, growth stops at the cap, and a non-positive width
|
|
4
|
-
* (before layout is known) degrades to a hard-newline count. The live textarea
|
|
5
|
-
* scrolls internally past the cap, so this only needs to get the growth curve
|
|
6
|
-
* and the ceiling right.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, expect, test } from "bun:test";
|
|
10
|
-
import { COMPOSE_MAX_ROWS, composeRowCount } from "./AnnotationCard";
|
|
11
|
-
|
|
12
|
-
describe("composeRowCount", () => {
|
|
13
|
-
const width = 10;
|
|
14
|
-
|
|
15
|
-
test("empty and short text are one row", () => {
|
|
16
|
-
expect(composeRowCount("", width)).toBe(1);
|
|
17
|
-
expect(composeRowCount("hi", width)).toBe(1);
|
|
18
|
-
expect(composeRowCount("exactly-10", width)).toBe(1); // length 10 / width 10
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("hard newlines add rows", () => {
|
|
22
|
-
expect(composeRowCount("a\nb", width)).toBe(2);
|
|
23
|
-
expect(composeRowCount("a\nb\nc", width)).toBe(3);
|
|
24
|
-
// a blank trailing line still occupies a row
|
|
25
|
-
expect(composeRowCount("a\n", width)).toBe(2);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("a soft-wrapped long line grows the box", () => {
|
|
29
|
-
expect(composeRowCount("a".repeat(11), width)).toBe(2); // ceil(11/10)
|
|
30
|
-
expect(composeRowCount("a".repeat(25), width)).toBe(3); // ceil(25/10)
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("wrapping and hard newlines combine", () => {
|
|
34
|
-
// 1 row for "short" plus ceil(21/10)=3 rows for the long line
|
|
35
|
-
expect(composeRowCount("short\n" + "b".repeat(21), width)).toBe(4);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("growth is capped", () => {
|
|
39
|
-
expect(composeRowCount("a".repeat(100), width)).toBe(COMPOSE_MAX_ROWS);
|
|
40
|
-
expect(composeRowCount("l\ni\nn\ne\ns\nx", width)).toBe(COMPOSE_MAX_ROWS);
|
|
41
|
-
expect(composeRowCount("a\nb", width, 1)).toBe(1); // custom cap
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("a non-positive width counts hard newlines only, never divides by zero", () => {
|
|
45
|
-
expect(composeRowCount("a".repeat(100), 0)).toBe(1);
|
|
46
|
-
expect(composeRowCount("a\nb\nc", 0)).toBe(3);
|
|
47
|
-
expect(composeRowCount("a".repeat(100), -5)).toBe(1);
|
|
48
|
-
});
|
|
49
|
-
});
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { DARK } from "../theme";
|
|
3
|
-
import type { Story, StoryMeta } from "./story";
|
|
4
|
-
import { AnnotationCard } from "./AnnotationCard";
|
|
5
|
-
|
|
6
|
-
export const meta: StoryMeta = { title: "Cards/AnnotationCard" };
|
|
7
|
-
|
|
8
|
-
const draftCallbacks = { onInput: () => {}, onSave: () => {}, onCancel: () => {} };
|
|
9
|
-
|
|
10
|
-
export const CommentDraft: Story = {
|
|
11
|
-
render: () => (
|
|
12
|
-
<AnnotationCard
|
|
13
|
-
kind="comment"
|
|
14
|
-
quote="persists sessions"
|
|
15
|
-
draft={{ text: "Which daemon?", ...draftCallbacks }}
|
|
16
|
-
/>
|
|
17
|
-
),
|
|
18
|
-
expectedColors: [DARK.blue],
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export const SavedSelected: Story = {
|
|
22
|
-
render: () => (
|
|
23
|
-
<AnnotationCard
|
|
24
|
-
kind="comment"
|
|
25
|
-
quote="persists sessions"
|
|
26
|
-
saved={{
|
|
27
|
-
body: "Which daemon owns this?",
|
|
28
|
-
isSelected: true,
|
|
29
|
-
isOrphan: false,
|
|
30
|
-
author: "me",
|
|
31
|
-
tone: DARK.blue,
|
|
32
|
-
editing: null,
|
|
33
|
-
onPress: () => {},
|
|
34
|
-
}}
|
|
35
|
-
/>
|
|
36
|
-
),
|
|
37
|
-
expectedColors: [DARK.blue],
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export const SavedCollaborator: Story = {
|
|
41
|
-
render: () => (
|
|
42
|
-
<AnnotationCard
|
|
43
|
-
kind="comment"
|
|
44
|
-
quote="persists sessions"
|
|
45
|
-
saved={{
|
|
46
|
-
body: "Which daemon owns this?",
|
|
47
|
-
isSelected: false,
|
|
48
|
-
isOrphan: false,
|
|
49
|
-
author: "Priya",
|
|
50
|
-
tone: DARK.blue,
|
|
51
|
-
editing: null,
|
|
52
|
-
onPress: () => {},
|
|
53
|
-
}}
|
|
54
|
-
/>
|
|
55
|
-
),
|
|
56
|
-
expectedColors: [DARK.blue],
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export const SavedOrphan: Story = {
|
|
60
|
-
render: () => (
|
|
61
|
-
<AnnotationCard
|
|
62
|
-
kind="comment"
|
|
63
|
-
quote="a removed passage"
|
|
64
|
-
saved={{
|
|
65
|
-
body: "The passage is gone.",
|
|
66
|
-
isSelected: false,
|
|
67
|
-
isOrphan: true,
|
|
68
|
-
author: "me",
|
|
69
|
-
tone: DARK.blue,
|
|
70
|
-
editing: null,
|
|
71
|
-
onPress: () => {},
|
|
72
|
-
}}
|
|
73
|
-
/>
|
|
74
|
-
),
|
|
75
|
-
expectedColors: [DARK.blue],
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
export const SavedEditing: Story = {
|
|
79
|
-
render: () => (
|
|
80
|
-
<AnnotationCard
|
|
81
|
-
kind="comment"
|
|
82
|
-
quote="move the store"
|
|
83
|
-
saved={{
|
|
84
|
-
body: "old body",
|
|
85
|
-
isSelected: true,
|
|
86
|
-
isOrphan: false,
|
|
87
|
-
author: "me",
|
|
88
|
-
tone: DARK.blue,
|
|
89
|
-
editing: { text: "new body", ...draftCallbacks },
|
|
90
|
-
onPress: () => {},
|
|
91
|
-
}}
|
|
92
|
-
/>
|
|
93
|
-
),
|
|
94
|
-
};
|