@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.35
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 +27 -26
- package/src/App.autoclose.test.tsx +7 -4
- package/src/App.diff.test.tsx +130 -4
- package/src/App.export.test.tsx +22 -4
- package/src/App.plan-review.test.tsx +127 -27
- package/src/App.readonly.test.tsx +31 -8
- package/src/App.rendering.test.tsx +12 -3
- package/src/App.review-panel.test.tsx +11 -3
- package/src/App.submit-confirm.test.tsx +12 -7
- package/src/App.test.tsx +46 -9
- package/src/App.toast.test.tsx +10 -3
- package/src/App.tsx +400 -100
- package/src/App.walk.test.tsx +30 -10
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +28 -9
- package/src/components/AnnotationCard.tsx +53 -38
- package/src/components/Breadcrumb.tsx +7 -1
- package/src/components/CodeBlock.stories.tsx +19 -2
- package/src/components/CodeBlock.tsx +24 -9
- package/src/components/CompletionOverlay.tsx +13 -3
- package/src/components/ConfirmCard.stories.tsx +22 -3
- package/src/components/ConfirmCard.tsx +22 -5
- package/src/components/ConfirmDialog.tsx +9 -1
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +18 -2
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +184 -98
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.tsx +19 -3
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +5 -1
- package/src/components/MenuBar.tsx +18 -2
- package/src/components/PlanSheet.stories.tsx +34 -2
- package/src/components/PlanSheet.tsx +141 -32
- package/src/components/PromptDialog.tsx +18 -2
- package/src/components/ReviewPanel.stories.tsx +13 -3
- package/src/components/ReviewPanel.tsx +8 -4
- package/src/components/ReviewRail.stories.tsx +145 -72
- package/src/components/ReviewRail.tsx +177 -38
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +8 -3
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.tsx +17 -2
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +9 -1
- package/src/components/primitives/Tabs.tsx +24 -4
- package/src/components/stories-app.tsx +34 -6
- package/src/components/story-fixtures.ts +11 -3
- package/src/components/story.ts +3 -1
- package/src/components/syntax-highlight.ts +47 -12
- package/src/components/theme-context.stories.tsx +6 -1
- package/src/components/theme-context.tsx +7 -1
- package/src/config.test.ts +178 -2
- package/src/config.ts +121 -23
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +13 -4
- package/src/editor.ts +31 -5
- package/src/herdr.ts +8 -2
- package/src/index.ts +9 -1
- package/src/integrations.ts +3 -1
- package/src/intent-dispatch.test.ts +193 -9
- package/src/intent-dispatch.ts +151 -18
- package/src/key-bindings.test.ts +19 -9
- package/src/key-bindings.ts +36 -14
- package/src/keymap.test.ts +171 -26
- package/src/keymap.ts +83 -16
- package/src/review-panel.test.ts +3 -1
- package/src/review-panel.ts +9 -2
- package/src/serve.test.ts +11 -3
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +65 -17
- package/src/session-controller.ts +301 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +16 -6
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- package/src/view-diff.ts +15 -4
- package/src/view-plan.test.ts +40 -6
- package/src/view-plan.ts +71 -28
- package/src/walk.test.ts +7 -2
- package/src/walk.ts +7 -2
package/src/share.ts
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* Share button, so the two share exactly the same path.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SHARE_HOST,
|
|
11
|
+
DEFAULT_SHARE_PORT,
|
|
12
|
+
SHARE_UPLOAD_USER,
|
|
13
|
+
packSessionBlob,
|
|
14
|
+
} from "@cueloop/daemon/share-blob";
|
|
10
15
|
import type { Annotation, ReviewSession } from "@cueloop/schema";
|
|
11
16
|
import { copyToClipboard } from "./clipboard";
|
|
12
17
|
|
|
@@ -28,9 +33,17 @@ export function shareIdFromLine(line: string): string | undefined {
|
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/** Upload the session to the gateway and copy the resulting ssh line. */
|
|
31
|
-
export async function publishShare(
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
export async function publishShare(
|
|
37
|
+
session: ReviewSession,
|
|
38
|
+
target: ShareTarget = {},
|
|
39
|
+
): Promise<ShareResult> {
|
|
40
|
+
const { stdout, stderr, code } = await runShareSsh(
|
|
41
|
+
"cueloop-share",
|
|
42
|
+
packSessionBlob(session),
|
|
43
|
+
target,
|
|
44
|
+
);
|
|
45
|
+
if (code !== 0)
|
|
46
|
+
throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
34
47
|
const line = stdout.trim();
|
|
35
48
|
if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
|
|
36
49
|
const copied = await copyToClipboard(line);
|
|
@@ -58,17 +71,37 @@ export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
|
|
|
58
71
|
* them on their next refresh. Owner-gated at the gateway; the notes stay
|
|
59
72
|
* unauthored (the planner's), unioned by id.
|
|
60
73
|
*/
|
|
61
|
-
export async function pushShare(
|
|
62
|
-
|
|
74
|
+
export async function pushShare(
|
|
75
|
+
shareId: string,
|
|
76
|
+
annotations: Array<Omit<Annotation, "createdAt">>,
|
|
77
|
+
target: ShareTarget = {},
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const { stderr, code } = await runShareSsh(
|
|
80
|
+
"cueloop-push",
|
|
81
|
+
Buffer.from(JSON.stringify({ shareId, annotations })),
|
|
82
|
+
target,
|
|
83
|
+
);
|
|
63
84
|
if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
/** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
|
|
67
|
-
async function runShareSsh(
|
|
88
|
+
async function runShareSsh(
|
|
89
|
+
command: string,
|
|
90
|
+
stdin: Buffer,
|
|
91
|
+
target: ShareTarget,
|
|
92
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
68
93
|
const host = target.host ?? DEFAULT_SHARE_HOST;
|
|
69
94
|
const port = target.port ?? DEFAULT_SHARE_PORT;
|
|
70
95
|
const proc = Bun.spawn(
|
|
71
|
-
[
|
|
96
|
+
[
|
|
97
|
+
"ssh",
|
|
98
|
+
"-p",
|
|
99
|
+
String(port),
|
|
100
|
+
"-o",
|
|
101
|
+
"StrictHostKeyChecking=accept-new",
|
|
102
|
+
`${SHARE_UPLOAD_USER}@${host}`,
|
|
103
|
+
command,
|
|
104
|
+
],
|
|
72
105
|
{ stdin, stdout: "pipe", stderr: "pipe" },
|
|
73
106
|
);
|
|
74
107
|
const [stdout, stderr, code] = await Promise.all([
|
package/src/test-support.ts
CHANGED
|
@@ -113,7 +113,7 @@ async function waitForFramePredicate(
|
|
|
113
113
|
if (predicate(frame)) return frame;
|
|
114
114
|
const now = Date.now();
|
|
115
115
|
if (now > deadline) {
|
|
116
|
-
const frozen = frameChanges <= 1 ? "
|
|
116
|
+
const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
|
|
117
117
|
throw new Error(
|
|
118
118
|
`waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
119
119
|
);
|
|
@@ -121,7 +121,7 @@ async function waitForFramePredicate(
|
|
|
121
121
|
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
122
122
|
lastLog = now;
|
|
123
123
|
console.error(
|
|
124
|
-
`[waitFor] still waiting for ${label}
|
|
124
|
+
`[waitFor] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
|
|
125
125
|
);
|
|
126
126
|
}
|
|
127
127
|
await yieldEventLoop();
|
|
@@ -153,14 +153,16 @@ export async function waitForState(
|
|
|
153
153
|
if (predicate()) return;
|
|
154
154
|
const now = Date.now();
|
|
155
155
|
if (now > deadline) {
|
|
156
|
-
const frozen = frameChanges <= 1 ? "
|
|
156
|
+
const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
|
|
157
157
|
throw new Error(
|
|
158
158
|
`waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
159
159
|
);
|
|
160
160
|
}
|
|
161
161
|
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
162
162
|
lastLog = now;
|
|
163
|
-
console.error(
|
|
163
|
+
console.error(
|
|
164
|
+
`[waitForState] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`,
|
|
165
|
+
);
|
|
164
166
|
}
|
|
165
167
|
await yieldEventLoop();
|
|
166
168
|
}
|
|
@@ -168,10 +170,18 @@ export async function waitForState(
|
|
|
168
170
|
|
|
169
171
|
/** Wait until the frame contains the needle (daemon round-trips included). */
|
|
170
172
|
export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
171
|
-
return waitForFramePredicate(
|
|
173
|
+
return waitForFramePredicate(
|
|
174
|
+
setup,
|
|
175
|
+
(frame) => frame.includes(needle),
|
|
176
|
+
`text ${JSON.stringify(needle)}`,
|
|
177
|
+
);
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
/** Wait until the frame no longer contains the needle. */
|
|
175
181
|
export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
176
|
-
return waitForFramePredicate(
|
|
182
|
+
return waitForFramePredicate(
|
|
183
|
+
setup,
|
|
184
|
+
(frame) => !frame.includes(needle),
|
|
185
|
+
`text-gone ${JSON.stringify(needle)}`,
|
|
186
|
+
);
|
|
177
187
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named theme presets selectable from Settings and `[ui] theme`. The branded
|
|
3
|
+
* "cueloop" default keeps the transparent terminal-through look; each of the
|
|
4
|
+
* well-known palettes paints its own opaque background so it reads exactly like
|
|
5
|
+
* the published theme. Every preset fills the full Theme token contract, so a
|
|
6
|
+
* `[theme]` per-token override still layers cleanly on top of the chosen base.
|
|
7
|
+
*
|
|
8
|
+
* Each palette maps its first-party spec onto cueloop's semantic slots. Where a
|
|
9
|
+
* palette has no dedicated green (Rosé Pine), additions use its positive accent
|
|
10
|
+
* by that palette's own convention.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DARK, type Theme } from "./theme";
|
|
14
|
+
|
|
15
|
+
/** Selectable theme name; the persisted `[ui] theme` value and cycle key. */
|
|
16
|
+
export type ThemeName =
|
|
17
|
+
| "cueloop"
|
|
18
|
+
| "rose-pine-moon"
|
|
19
|
+
| "catppuccin-mocha"
|
|
20
|
+
| "tokyo-night"
|
|
21
|
+
| "gruvbox-dark"
|
|
22
|
+
| "nord";
|
|
23
|
+
|
|
24
|
+
/** The branded default; the base every layered config starts from. */
|
|
25
|
+
export const DEFAULT_THEME_NAME: ThemeName = "cueloop";
|
|
26
|
+
|
|
27
|
+
/** Rosé Pine Moon - rosepinetheme.com. Foam stands in for additions (no green in the palette). */
|
|
28
|
+
const ROSE_PINE_MOON: Theme = {
|
|
29
|
+
background: "#232136",
|
|
30
|
+
panel: "#232136",
|
|
31
|
+
elevated: "#2a273f",
|
|
32
|
+
border: "#44415a",
|
|
33
|
+
text: "#e0def4",
|
|
34
|
+
textMuted: "#908caa",
|
|
35
|
+
textDim: "#6e6a86",
|
|
36
|
+
accent: "#ea9a97",
|
|
37
|
+
accentInk: "#232136",
|
|
38
|
+
green: "#9ccfd8",
|
|
39
|
+
red: "#eb6f92",
|
|
40
|
+
blue: "#3e8fb0",
|
|
41
|
+
cursorBackground: "#382a2f",
|
|
42
|
+
markCommentBackground: "#382a37",
|
|
43
|
+
insertedForeground: "#9ccfd8",
|
|
44
|
+
deletedForeground: "#eb6f92",
|
|
45
|
+
backdrop: "transparent",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Catppuccin Mocha - catppuccin.com. Mauve is the signature accent. */
|
|
49
|
+
const CATPPUCCIN_MOCHA: Theme = {
|
|
50
|
+
background: "#1e1e2e",
|
|
51
|
+
panel: "#1e1e2e",
|
|
52
|
+
elevated: "#313244",
|
|
53
|
+
border: "#45475a",
|
|
54
|
+
text: "#cdd6f4",
|
|
55
|
+
textMuted: "#a6adc8",
|
|
56
|
+
textDim: "#6c7086",
|
|
57
|
+
accent: "#cba6f7",
|
|
58
|
+
accentInk: "#1e1e2e",
|
|
59
|
+
green: "#a6e3a1",
|
|
60
|
+
red: "#f38ba8",
|
|
61
|
+
blue: "#89b4fa",
|
|
62
|
+
cursorBackground: "#2e2740",
|
|
63
|
+
markCommentBackground: "#33222b",
|
|
64
|
+
insertedForeground: "#a6e3a1",
|
|
65
|
+
deletedForeground: "#f38ba8",
|
|
66
|
+
backdrop: "transparent",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Tokyo Night - the folke "night" variant. Magenta is the signature accent. */
|
|
70
|
+
const TOKYO_NIGHT: Theme = {
|
|
71
|
+
background: "#1a1b26",
|
|
72
|
+
panel: "#1a1b26",
|
|
73
|
+
elevated: "#292e42",
|
|
74
|
+
border: "#3b4261",
|
|
75
|
+
text: "#c0caf5",
|
|
76
|
+
textMuted: "#a9b1d6",
|
|
77
|
+
textDim: "#565f89",
|
|
78
|
+
accent: "#bb9af7",
|
|
79
|
+
accentInk: "#1a1b26",
|
|
80
|
+
green: "#9ece6a",
|
|
81
|
+
red: "#f7768e",
|
|
82
|
+
blue: "#7aa2f7",
|
|
83
|
+
cursorBackground: "#2b2740",
|
|
84
|
+
markCommentBackground: "#2f2230",
|
|
85
|
+
insertedForeground: "#9ece6a",
|
|
86
|
+
deletedForeground: "#f7768e",
|
|
87
|
+
backdrop: "transparent",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Gruvbox Dark - morhetz/gruvbox, medium contrast. Orange is the warm signature accent. */
|
|
91
|
+
const GRUVBOX_DARK: Theme = {
|
|
92
|
+
background: "#282828",
|
|
93
|
+
panel: "#282828",
|
|
94
|
+
elevated: "#3c3836",
|
|
95
|
+
border: "#504945",
|
|
96
|
+
text: "#ebdbb2",
|
|
97
|
+
textMuted: "#d5c4a1",
|
|
98
|
+
textDim: "#928374",
|
|
99
|
+
accent: "#fe8019",
|
|
100
|
+
accentInk: "#282828",
|
|
101
|
+
green: "#b8bb26",
|
|
102
|
+
red: "#fb4934",
|
|
103
|
+
blue: "#83a598",
|
|
104
|
+
cursorBackground: "#3a2f20",
|
|
105
|
+
markCommentBackground: "#3a2823",
|
|
106
|
+
insertedForeground: "#b8bb26",
|
|
107
|
+
deletedForeground: "#fb4934",
|
|
108
|
+
backdrop: "transparent",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Nord - nordtheme.com, the official 16-color spec. Frost cyan is the signature accent. */
|
|
112
|
+
const NORD: Theme = {
|
|
113
|
+
background: "#2e3440",
|
|
114
|
+
panel: "#2e3440",
|
|
115
|
+
elevated: "#3b4252",
|
|
116
|
+
border: "#434c5e",
|
|
117
|
+
text: "#eceff4",
|
|
118
|
+
textMuted: "#d8dee9",
|
|
119
|
+
textDim: "#4c566a",
|
|
120
|
+
accent: "#88c0d0",
|
|
121
|
+
accentInk: "#2e3440",
|
|
122
|
+
green: "#a3be8c",
|
|
123
|
+
red: "#bf616a",
|
|
124
|
+
blue: "#81a1c1",
|
|
125
|
+
cursorBackground: "#2a3540",
|
|
126
|
+
markCommentBackground: "#3a2f33",
|
|
127
|
+
insertedForeground: "#a3be8c",
|
|
128
|
+
deletedForeground: "#bf616a",
|
|
129
|
+
backdrop: "transparent",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** All selectable themes by name; "cueloop" is the branded transparent default. */
|
|
133
|
+
export const THEME_PRESETS: Record<ThemeName, Theme> = {
|
|
134
|
+
cueloop: DARK,
|
|
135
|
+
"rose-pine-moon": ROSE_PINE_MOON,
|
|
136
|
+
"catppuccin-mocha": CATPPUCCIN_MOCHA,
|
|
137
|
+
"tokyo-night": TOKYO_NIGHT,
|
|
138
|
+
"gruvbox-dark": GRUVBOX_DARK,
|
|
139
|
+
nord: NORD,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Cycle order for the Settings theme row; the registry's insertion order, branded default first. */
|
|
143
|
+
export const THEME_NAMES: ThemeName[] = Object.keys(THEME_PRESETS) as ThemeName[];
|
|
144
|
+
|
|
145
|
+
/** Human labels for the Settings cycle row, keyed by the persisted name. */
|
|
146
|
+
export const THEME_LABELS: Record<ThemeName, string> = {
|
|
147
|
+
cueloop: "cueloop",
|
|
148
|
+
"rose-pine-moon": "Rosé Pine Moon",
|
|
149
|
+
"catppuccin-mocha": "Catppuccin Mocha",
|
|
150
|
+
"tokyo-night": "Tokyo Night",
|
|
151
|
+
"gruvbox-dark": "Gruvbox Dark",
|
|
152
|
+
nord: "Nord",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Resolve a persisted theme name to its token set; unknown names fall back to the branded default. */
|
|
156
|
+
export function themeForName(name: string): Theme {
|
|
157
|
+
return THEME_PRESETS[name as ThemeName] ?? DARK;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A preset with the user's `[theme]` per-token overrides layered on top - the live-switch equivalent of config load. */
|
|
161
|
+
export function composeTheme(name: string, overrides: Partial<Theme>): Theme {
|
|
162
|
+
return { ...themeForName(name), ...overrides };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** True when the name is a known preset (guards a persisted `[ui] theme` value). */
|
|
166
|
+
export function isThemeName(name: string): name is ThemeName {
|
|
167
|
+
return name in THEME_PRESETS;
|
|
168
|
+
}
|
package/src/theme.ts
CHANGED
|
@@ -20,7 +20,6 @@ export interface Theme {
|
|
|
20
20
|
blue: string;
|
|
21
21
|
cursorBackground: string;
|
|
22
22
|
markCommentBackground: string;
|
|
23
|
-
markSuggestionBackground: string;
|
|
24
23
|
insertedForeground: string;
|
|
25
24
|
deletedForeground: string;
|
|
26
25
|
/** Layer behind centered dialogs; transparent keeps the session visible. */
|
|
@@ -62,8 +61,7 @@ export const DARK: Theme = {
|
|
|
62
61
|
blue: "#84a6e8",
|
|
63
62
|
cursorBackground: "#33262a",
|
|
64
63
|
markCommentBackground: "#3d2a2e",
|
|
65
|
-
markSuggestionBackground: "#20351f",
|
|
66
64
|
insertedForeground: "#62d96b",
|
|
67
65
|
deletedForeground: "#ff6b6b",
|
|
68
66
|
backdrop: "transparent",
|
|
69
|
-
}
|
|
67
|
+
};
|
package/src/view-diff.ts
CHANGED
|
@@ -64,18 +64,29 @@ export function diffRows(patchText: string): DiffRow[] {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function fileLabel(file: FileDiffMetadata): string {
|
|
67
|
-
return file.prevName && file.prevName !== file.name
|
|
67
|
+
return file.prevName && file.prevName !== file.name
|
|
68
|
+
? `${file.prevName} → ${file.name}`
|
|
69
|
+
: file.name;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** Quote-primary anchor for a diff row: neighbors as context selectors. */
|
|
71
|
-
export function diffRowAnchor(
|
|
73
|
+
export function diffRowAnchor(
|
|
74
|
+
rows: DiffRow[],
|
|
75
|
+
rowIndex: number,
|
|
76
|
+
): { quote: string; prefix: string; suffix: string } {
|
|
72
77
|
const row = rows[rowIndex]!;
|
|
73
78
|
const prev = rows[rowIndex - 1];
|
|
74
79
|
const next = rows[rowIndex + 1];
|
|
75
80
|
return {
|
|
76
81
|
quote: row.text,
|
|
77
|
-
prefix:
|
|
78
|
-
|
|
82
|
+
prefix:
|
|
83
|
+
prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del")
|
|
84
|
+
? prev.text.slice(-24)
|
|
85
|
+
: "",
|
|
86
|
+
suffix:
|
|
87
|
+
next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del")
|
|
88
|
+
? next.text.slice(0, 24)
|
|
89
|
+
: "",
|
|
79
90
|
};
|
|
80
91
|
}
|
|
81
92
|
|
package/src/view-plan.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
nextWorkBlock,
|
|
8
8
|
overlayMarks,
|
|
9
9
|
renderedOffsetFor,
|
|
10
|
+
spanFromRange,
|
|
10
11
|
spanKey,
|
|
11
12
|
startSpan,
|
|
12
13
|
workRangeForRendered,
|
|
@@ -53,8 +54,12 @@ describe("buildDisplay reconciliation", () => {
|
|
|
53
54
|
const display = buildDisplay(BASE, working);
|
|
54
55
|
|
|
55
56
|
// Assert
|
|
56
|
-
expect(
|
|
57
|
-
|
|
57
|
+
expect(
|
|
58
|
+
display.some((block) => block.type === "del" && block.base!.text === "second item"),
|
|
59
|
+
).toBe(true);
|
|
60
|
+
expect(
|
|
61
|
+
display.some((block) => block.type === "add" && block.work!.text.includes("closing note")),
|
|
62
|
+
).toBe(true);
|
|
58
63
|
expect(display.some((block) => block.type === "mod")).toBe(false);
|
|
59
64
|
});
|
|
60
65
|
|
|
@@ -76,8 +81,14 @@ describe("blockRuns + overlayMarks", () => {
|
|
|
76
81
|
const runs = blockRuns(block, true);
|
|
77
82
|
|
|
78
83
|
// Assert
|
|
79
|
-
const oldSide = runs
|
|
80
|
-
|
|
84
|
+
const oldSide = runs
|
|
85
|
+
.filter((run) => run.role !== "ins")
|
|
86
|
+
.map((run) => run.text)
|
|
87
|
+
.join("");
|
|
88
|
+
const newSide = runs
|
|
89
|
+
.filter((run) => run.role !== "del")
|
|
90
|
+
.map((run) => run.text)
|
|
91
|
+
.join("");
|
|
81
92
|
expect(oldSide).toBe("plain old text here");
|
|
82
93
|
expect(newSide).toBe("plain new text here");
|
|
83
94
|
});
|
|
@@ -91,7 +102,10 @@ describe("blockRuns + overlayMarks", () => {
|
|
|
91
102
|
const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
|
|
92
103
|
|
|
93
104
|
// Assert
|
|
94
|
-
const markedText = marked
|
|
105
|
+
const markedText = marked
|
|
106
|
+
.filter((run) => run.role === "mark-comment")
|
|
107
|
+
.map((run) => run.text)
|
|
108
|
+
.join("");
|
|
95
109
|
expect(markedText).toBe("words");
|
|
96
110
|
// del run text untouched
|
|
97
111
|
expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
|
|
@@ -102,7 +116,9 @@ describe("blockRuns + overlayMarks", () => {
|
|
|
102
116
|
const block = buildDisplay("keep\n", "keep added\n")[0]!;
|
|
103
117
|
|
|
104
118
|
// Act
|
|
105
|
-
const runs = overlayMarks(blockRuns(block, true), [
|
|
119
|
+
const runs = overlayMarks(blockRuns(block, true), [
|
|
120
|
+
{ start: 0, end: 10, role: "mark-comment" },
|
|
121
|
+
]);
|
|
106
122
|
|
|
107
123
|
// Assert
|
|
108
124
|
expect(runs.some((run) => run.role === "ins")).toBe(true);
|
|
@@ -230,6 +246,24 @@ describe("span mode", () => {
|
|
|
230
246
|
// Assert
|
|
231
247
|
expect(text.slice(span.start, span.end)).toBe("two three four");
|
|
232
248
|
});
|
|
249
|
+
|
|
250
|
+
test("spanFromRange snaps a mid-word mouse drag to whole words", () => {
|
|
251
|
+
// Arrange - a drag from inside "two" to inside "three"
|
|
252
|
+
const dragStart = text.indexOf("two") + 1;
|
|
253
|
+
const dragEnd = text.indexOf("three") + 2;
|
|
254
|
+
|
|
255
|
+
// Act
|
|
256
|
+
const span = spanFromRange(0, text, dragStart, dragEnd)!;
|
|
257
|
+
|
|
258
|
+
// Assert - snaps outward to cover both touched words, ready for w/b/l/h
|
|
259
|
+
expect(text.slice(span.start, span.end)).toBe("two three");
|
|
260
|
+
expect(spanKey(span, "l", text)).toMatchObject({ wordEnd: 3 });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("spanFromRange returns null when the range covers no word", () => {
|
|
264
|
+
// Assert - a range past the last word has nothing to select
|
|
265
|
+
expect(spanFromRange(0, text, text.length, text.length)).toBeNull();
|
|
266
|
+
});
|
|
233
267
|
});
|
|
234
268
|
|
|
235
269
|
describe("cut / restore round-trip", () => {
|
package/src/view-plan.ts
CHANGED
|
@@ -12,10 +12,10 @@ import {
|
|
|
12
12
|
lcsDiff,
|
|
13
13
|
parseBlocks,
|
|
14
14
|
resolveAnchor,
|
|
15
|
-
wordDiff,
|
|
16
15
|
type Annotation,
|
|
17
16
|
type Block,
|
|
18
17
|
} from "@cueloop/schema";
|
|
18
|
+
import { wordLevelChanges } from "./diff-intraline";
|
|
19
19
|
|
|
20
20
|
// ── display model ─────────────────────────────
|
|
21
21
|
|
|
@@ -62,7 +62,11 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
|
|
|
62
62
|
}
|
|
63
63
|
const workBlocks = parseBlocks(workingContent);
|
|
64
64
|
const signature = (block: Block) => block.kind + "\0" + block.text;
|
|
65
|
-
const ops = lcsDiff(
|
|
65
|
+
const ops = lcsDiff(
|
|
66
|
+
baseBlocks,
|
|
67
|
+
workBlocks,
|
|
68
|
+
(baseBlock, workBlock) => signature(baseBlock) === signature(workBlock),
|
|
69
|
+
);
|
|
66
70
|
const display: DisplayBlock[] = [];
|
|
67
71
|
let opIndex = 0;
|
|
68
72
|
while (opIndex < ops.length) {
|
|
@@ -93,18 +97,34 @@ export function buildDisplay(baseContent: string, workingContent?: string): Disp
|
|
|
93
97
|
deletedIndex++;
|
|
94
98
|
addedIndex++;
|
|
95
99
|
} else if (deletedBlocks.length - deletedIndex >= addedBlocks.length - addedIndex) {
|
|
96
|
-
display.push({
|
|
100
|
+
display.push({
|
|
101
|
+
type: "del",
|
|
102
|
+
kind: deletedBlocks[deletedIndex]!.kind,
|
|
103
|
+
base: deletedBlocks[deletedIndex]!,
|
|
104
|
+
});
|
|
97
105
|
deletedIndex++;
|
|
98
106
|
} else {
|
|
99
|
-
display.push({
|
|
107
|
+
display.push({
|
|
108
|
+
type: "add",
|
|
109
|
+
kind: addedBlocks[addedIndex]!.kind,
|
|
110
|
+
work: addedBlocks[addedIndex]!,
|
|
111
|
+
});
|
|
100
112
|
addedIndex++;
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
115
|
while (deletedIndex < deletedBlocks.length) {
|
|
104
|
-
display.push({
|
|
116
|
+
display.push({
|
|
117
|
+
type: "del",
|
|
118
|
+
kind: deletedBlocks[deletedIndex]!.kind,
|
|
119
|
+
base: deletedBlocks[deletedIndex++]!,
|
|
120
|
+
});
|
|
105
121
|
}
|
|
106
122
|
while (addedIndex < addedBlocks.length) {
|
|
107
|
-
display.push({
|
|
123
|
+
display.push({
|
|
124
|
+
type: "add",
|
|
125
|
+
kind: addedBlocks[addedIndex]!.kind,
|
|
126
|
+
work: addedBlocks[addedIndex++]!,
|
|
127
|
+
});
|
|
108
128
|
}
|
|
109
129
|
}
|
|
110
130
|
numberOrderedListItems(display);
|
|
@@ -121,14 +141,7 @@ function numberOrderedListItems(display: DisplayBlock[]): void {
|
|
|
121
141
|
|
|
122
142
|
// ── marks (annotation highlights + keyboard span) ──
|
|
123
143
|
|
|
124
|
-
export type RunRole =
|
|
125
|
-
| "plain"
|
|
126
|
-
| "ins"
|
|
127
|
-
| "del"
|
|
128
|
-
| "mark-comment"
|
|
129
|
-
| "mark-suggestion"
|
|
130
|
-
| "kspan"
|
|
131
|
-
| "mark-focus";
|
|
144
|
+
export type RunRole = "plain" | "ins" | "del" | "mark-comment" | "kspan" | "mark-focus";
|
|
132
145
|
|
|
133
146
|
export interface StyleRun {
|
|
134
147
|
text: string;
|
|
@@ -172,12 +185,7 @@ export function marksByDisplay(
|
|
|
172
185
|
marks.push({
|
|
173
186
|
start: resolved.start,
|
|
174
187
|
end: resolved.end,
|
|
175
|
-
role:
|
|
176
|
-
annotation.id === focusedId
|
|
177
|
-
? "mark-focus"
|
|
178
|
-
: annotation.kind === "suggestion"
|
|
179
|
-
? "mark-suggestion"
|
|
180
|
-
: "mark-comment",
|
|
188
|
+
role: annotation.id === focusedId ? "mark-focus" : "mark-comment",
|
|
181
189
|
annotationId: annotation.id,
|
|
182
190
|
});
|
|
183
191
|
marksByIndex.set(entry.displayIndex, marks);
|
|
@@ -188,15 +196,19 @@ export function marksByDisplay(
|
|
|
188
196
|
/** Base runs for a display block: plain text, or word-diff for mod blocks. */
|
|
189
197
|
export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
|
|
190
198
|
if (block.type === "mod" && markup) {
|
|
191
|
-
const
|
|
199
|
+
const changes = wordLevelChanges(block.base!.text, block.work!.text);
|
|
192
200
|
const runs: StyleRun[] = [];
|
|
193
201
|
let workOffset = 0;
|
|
194
|
-
for (const
|
|
195
|
-
if (
|
|
196
|
-
runs.push({ text:
|
|
202
|
+
for (const change of changes) {
|
|
203
|
+
if (change.kind === "removed") {
|
|
204
|
+
runs.push({ text: change.text, role: "del", start: null });
|
|
197
205
|
} else {
|
|
198
|
-
runs.push({
|
|
199
|
-
|
|
206
|
+
runs.push({
|
|
207
|
+
text: change.text,
|
|
208
|
+
role: change.kind === "added" ? "ins" : "plain",
|
|
209
|
+
start: workOffset,
|
|
210
|
+
});
|
|
211
|
+
workOffset += change.text.length;
|
|
200
212
|
}
|
|
201
213
|
}
|
|
202
214
|
return runs;
|
|
@@ -294,7 +306,10 @@ export function workRangeForRendered(
|
|
|
294
306
|
}
|
|
295
307
|
|
|
296
308
|
/** Line delta between two revision contents, for the sheet-header summary. */
|
|
297
|
-
export function revisionDelta(
|
|
309
|
+
export function revisionDelta(
|
|
310
|
+
previousContent: string,
|
|
311
|
+
nextContent: string,
|
|
312
|
+
): { added: number; removed: number } {
|
|
298
313
|
const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
|
|
299
314
|
let added = 0;
|
|
300
315
|
let removed = 0;
|
|
@@ -319,7 +334,8 @@ export function wordRanges(text: string): [number, number][] {
|
|
|
319
334
|
const ranges: [number, number][] = [];
|
|
320
335
|
const wordPattern = /\S+/g;
|
|
321
336
|
let match: RegExpExecArray | null;
|
|
322
|
-
while ((match = wordPattern.exec(text)))
|
|
337
|
+
while ((match = wordPattern.exec(text)))
|
|
338
|
+
ranges.push([match.index, match.index + match[0]!.length]);
|
|
323
339
|
return ranges;
|
|
324
340
|
}
|
|
325
341
|
|
|
@@ -329,6 +345,33 @@ export function startSpan(displayIndex: number, text: string): SpanState | null
|
|
|
329
345
|
return { displayIndex, wordIndex: 0, wordEnd: 0, start: words[0]![0], end: words[0]![1] };
|
|
330
346
|
}
|
|
331
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Build a word-granular span from a raw char range (a mouse drag-selection),
|
|
350
|
+
* snapping to every word the range overlaps so w/b/l/h keep working after. Null
|
|
351
|
+
* when the range covers no word.
|
|
352
|
+
*/
|
|
353
|
+
export function spanFromRange(
|
|
354
|
+
displayIndex: number,
|
|
355
|
+
text: string,
|
|
356
|
+
start: number,
|
|
357
|
+
end: number,
|
|
358
|
+
): SpanState | null {
|
|
359
|
+
const words = wordRanges(text);
|
|
360
|
+
const wordIndex = words.findIndex((word) => word[1] > start);
|
|
361
|
+
if (wordIndex === -1) return null;
|
|
362
|
+
let wordEnd = wordIndex;
|
|
363
|
+
for (let index = wordIndex; index < words.length && words[index]![0] < end; index++) {
|
|
364
|
+
wordEnd = index;
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
displayIndex,
|
|
368
|
+
wordIndex,
|
|
369
|
+
wordEnd,
|
|
370
|
+
start: words[wordIndex]![0],
|
|
371
|
+
end: words[wordEnd]![1],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
332
375
|
export function spanKey(span: SpanState, key: string, text: string): SpanState {
|
|
333
376
|
const words = wordRanges(text);
|
|
334
377
|
const nextSpan = { ...span };
|
package/src/walk.test.ts
CHANGED
|
@@ -72,7 +72,9 @@ describe("firstUnviewedIndex", () => {
|
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
test("all viewed resumes on the end card, not the first file", () => {
|
|
75
|
-
expect(firstUnviewedIndex(files, new Set(["src/a.ts", "src/b.ts", "src/c.ts"]))).toBe(
|
|
75
|
+
expect(firstUnviewedIndex(files, new Set(["src/a.ts", "src/b.ts", "src/c.ts"]))).toBe(
|
|
76
|
+
files.length,
|
|
77
|
+
);
|
|
76
78
|
});
|
|
77
79
|
});
|
|
78
80
|
|
|
@@ -93,7 +95,10 @@ describe("noteForFile", () => {
|
|
|
93
95
|
|
|
94
96
|
test("finds the note anchored at the file path", () => {
|
|
95
97
|
// Arrange
|
|
96
|
-
const annotations = [
|
|
98
|
+
const annotations = [
|
|
99
|
+
note("src/a.ts", "Renames b and adds c."),
|
|
100
|
+
note("src/b.ts", "Swaps the line."),
|
|
101
|
+
];
|
|
97
102
|
|
|
98
103
|
// Assert
|
|
99
104
|
expect(noteForFile(annotations, "src/b.ts")).toBe("Swaps the line.");
|
package/src/walk.ts
CHANGED
|
@@ -35,7 +35,10 @@ export function walkFiles(rows: DiffRow[]): WalkFile[] {
|
|
|
35
35
|
if (row.kind === "add") file.added += 1;
|
|
36
36
|
else file.removed += 1;
|
|
37
37
|
if (file.preview.length < WALK_PREVIEW_ROWS) {
|
|
38
|
-
file.preview.push({
|
|
38
|
+
file.preview.push({
|
|
39
|
+
sign: row.kind === "add" ? "+" : "-",
|
|
40
|
+
text: row.text.replace(/\n$/, ""),
|
|
41
|
+
});
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
return files;
|
|
@@ -61,5 +64,7 @@ export function viewedCount(files: WalkFile[], viewedPaths: ReadonlySet<string>)
|
|
|
61
64
|
* quote is the file path (the note contract in the shared review core).
|
|
62
65
|
*/
|
|
63
66
|
export function noteForFile(annotations: Annotation[], path: string): string | undefined {
|
|
64
|
-
return annotations.find(
|
|
67
|
+
return annotations.find(
|
|
68
|
+
(annotation) => annotation.kind === "note" && annotation.anchor.quote === path,
|
|
69
|
+
)?.body;
|
|
65
70
|
}
|