@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34
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 +131 -5
- package/src/App.export.test.tsx +23 -5
- package/src/App.plan-review.test.tsx +133 -32
- package/src/App.readonly.test.tsx +35 -13
- package/src/App.rendering.test.tsx +14 -4
- package/src/App.review-panel.test.tsx +27 -15
- package/src/App.submit-confirm.test.tsx +14 -13
- package/src/App.test.tsx +50 -13
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +505 -101
- package/src/App.walk.test.tsx +31 -11
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +29 -10
- package/src/components/AnnotationCard.tsx +57 -37
- 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 +23 -5
- package/src/components/ConfirmDialog.tsx +10 -2
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +35 -1
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +213 -101
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +35 -3
- package/src/components/PlanSheet.tsx +160 -100
- package/src/components/PromptDialog.tsx +19 -3
- package/src/components/ReviewPanel.stories.tsx +17 -19
- package/src/components/ReviewPanel.tsx +17 -32
- package/src/components/ReviewRail.stories.tsx +159 -47
- package/src/components/ReviewRail.tsx +206 -62
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +11 -5
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +2 -2
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +12 -1
- package/src/components/primitives/Tabs.tsx +40 -39
- 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 +77 -16
- 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 +13 -5
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +77 -16
- package/src/session-controller.ts +319 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +97 -23
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- package/src/version.ts +5 -0
- 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/components/ComposeBar.stories.tsx +0 -18
- package/src/components/ComposeBar.tsx +0 -54
package/src/test-support.ts
CHANGED
|
@@ -61,53 +61,127 @@ export async function typeText(setup: TestRendererSetup, text: string): Promise<
|
|
|
61
61
|
await settle(setup);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// Below the 60s per-test budget so a genuinely stuck wait throws its frame
|
|
65
|
+
// error (useful) before bun's bare "timed out" fires. Generous because a
|
|
66
|
+
// contended CI runner starves the render loop and daemon round-trips.
|
|
67
|
+
const WAIT_DEADLINE_MS = 45_000;
|
|
68
|
+
|
|
69
|
+
/** One macrotask turn: hands the event loop back so the in-process daemon can
|
|
70
|
+
* read its socket and apply the write before the next poll. Tight render-pass
|
|
71
|
+
* bursts starve that read on a contended CI runner - the round-trip then never
|
|
72
|
+
* lands and the wait hangs the whole budget. */
|
|
73
|
+
function yieldEventLoop(): Promise<void> {
|
|
74
|
+
return new Promise((resolve) => setTimeout(resolve, 2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Log a still-waiting line at most this often, so only genuinely slow (i.e.
|
|
78
|
+
* flaking) waits are noisy; fast waits stay silent. */
|
|
79
|
+
const PROGRESS_LOG_MS = 4000;
|
|
80
|
+
|
|
81
|
+
/** The last render frame's tail, so a progress line shows what is on screen. */
|
|
82
|
+
function frameTail(frame: string, rows = 6): string {
|
|
83
|
+
return frame.split("\n").slice(-rows).join("\n");
|
|
84
|
+
}
|
|
65
85
|
|
|
66
86
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* the
|
|
87
|
+
* Poll gently: one render pass, one frame check, one macrotask yield. No
|
|
88
|
+
* multi-pass bursts, so the daemon (same event loop) always gets a turn to
|
|
89
|
+
* process IO between checks. The deadline bounds the whole wait. Slow waits log
|
|
90
|
+
* progress (poll count + frame-change count) so a CI stall shows whether the
|
|
91
|
+
* render froze or the content simply never arrived.
|
|
72
92
|
*/
|
|
73
93
|
async function waitForFramePredicate(
|
|
74
94
|
setup: TestRendererSetup,
|
|
75
95
|
predicate: (frame: string) => boolean,
|
|
96
|
+
label: string,
|
|
76
97
|
): Promise<string> {
|
|
77
98
|
allowEventLoopUpdates();
|
|
78
|
-
const
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
const deadline = start + WAIT_DEADLINE_MS;
|
|
101
|
+
let polls = 0;
|
|
102
|
+
let frameChanges = 0;
|
|
103
|
+
let lastFrame = "";
|
|
104
|
+
let lastLog = start;
|
|
79
105
|
for (;;) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
await setup.renderOnce();
|
|
107
|
+
const frame = setup.captureCharFrame();
|
|
108
|
+
polls++;
|
|
109
|
+
if (frame !== lastFrame) {
|
|
110
|
+
frameChanges++;
|
|
111
|
+
lastFrame = frame;
|
|
86
112
|
}
|
|
113
|
+
if (predicate(frame)) return frame;
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (now > deadline) {
|
|
116
|
+
const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
|
|
117
|
+
throw new Error(
|
|
118
|
+
`waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
122
|
+
lastLog = now;
|
|
123
|
+
console.error(
|
|
124
|
+
`[waitFor] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
await yieldEventLoop();
|
|
87
128
|
}
|
|
88
129
|
}
|
|
89
130
|
|
|
90
131
|
/** Wait until a state predicate holds (daemon round-trips included). */
|
|
91
|
-
export async function waitForState(
|
|
132
|
+
export async function waitForState(
|
|
133
|
+
setup: TestRendererSetup,
|
|
134
|
+
predicate: () => boolean,
|
|
135
|
+
label = "state",
|
|
136
|
+
): Promise<void> {
|
|
92
137
|
allowEventLoopUpdates();
|
|
93
|
-
const
|
|
138
|
+
const start = Date.now();
|
|
139
|
+
const deadline = start + WAIT_DEADLINE_MS;
|
|
140
|
+
let polls = 0;
|
|
141
|
+
let frameChanges = 0;
|
|
142
|
+
let lastFrame = "";
|
|
143
|
+
let lastLog = start;
|
|
94
144
|
for (;;) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
145
|
+
if (predicate()) return;
|
|
146
|
+
await setup.renderOnce();
|
|
147
|
+
const frame = setup.captureCharFrame();
|
|
148
|
+
polls++;
|
|
149
|
+
if (frame !== lastFrame) {
|
|
150
|
+
frameChanges++;
|
|
151
|
+
lastFrame = frame;
|
|
152
|
+
}
|
|
153
|
+
if (predicate()) return;
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (now > deadline) {
|
|
156
|
+
const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
|
|
157
|
+
throw new Error(
|
|
158
|
+
`waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
162
|
+
lastLog = now;
|
|
163
|
+
console.error(
|
|
164
|
+
`[waitForState] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`,
|
|
165
|
+
);
|
|
101
166
|
}
|
|
167
|
+
await yieldEventLoop();
|
|
102
168
|
}
|
|
103
169
|
}
|
|
104
170
|
|
|
105
171
|
/** Wait until the frame contains the needle (daemon round-trips included). */
|
|
106
172
|
export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
107
|
-
return waitForFramePredicate(
|
|
173
|
+
return waitForFramePredicate(
|
|
174
|
+
setup,
|
|
175
|
+
(frame) => frame.includes(needle),
|
|
176
|
+
`text ${JSON.stringify(needle)}`,
|
|
177
|
+
);
|
|
108
178
|
}
|
|
109
179
|
|
|
110
180
|
/** Wait until the frame no longer contains the needle. */
|
|
111
181
|
export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
112
|
-
return waitForFramePredicate(
|
|
182
|
+
return waitForFramePredicate(
|
|
183
|
+
setup,
|
|
184
|
+
(frame) => !frame.includes(needle),
|
|
185
|
+
`text-gone ${JSON.stringify(needle)}`,
|
|
186
|
+
);
|
|
113
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/version.ts
ADDED
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.");
|