@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.67
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 +6 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +71 -39
- package/src/App.plan-review.test.tsx +20 -21
- package/src/App.readonly.test.tsx +7 -3
- package/src/App.test.tsx +73 -14
- package/src/App.tsx +375 -129
- package/src/App.walk.test.tsx +14 -15
- package/src/App.workbench.test.tsx +390 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +61 -0
- package/src/app-screens.tsx +176 -45
- package/src/app-view-model.ts +7 -65
- package/src/components/AnnotationCard.tsx +1 -1
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +47 -42
- package/src/components/AppShell.tsx +212 -100
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/ChangesColumn.tsx +49 -26
- package/src/components/DiffSheet.scroll.test.tsx +38 -55
- package/src/components/DiffSheet.split.test.tsx +140 -0
- package/src/components/DiffSheet.stories.tsx +55 -53
- package/src/components/DiffSheet.syntax.test.tsx +24 -7
- package/src/components/DiffSheet.tsx +607 -276
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +105 -0
- package/src/components/EditorGrid.tsx +338 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +78 -0
- package/src/components/InboxList.tsx +159 -30
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +82 -0
- package/src/components/PromptDialog.tsx +8 -1
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/SettingsDialog.tsx +2 -1
- package/src/components/SurfaceScrollbar.stories.tsx +34 -0
- package/src/components/SurfaceScrollbar.test.tsx +111 -0
- package/src/components/SurfaceScrollbar.tsx +93 -0
- package/src/components/ThreadFooter.stories.tsx +2 -2
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +12 -7
- package/src/components/ThreadView.tsx +62 -1440
- package/src/components/ThreadsSidebar.tsx +14 -1
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/WelcomeSurface.stories.tsx +12 -0
- package/src/components/WelcomeSurface.tsx +52 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
- package/src/components/diff-sheet-layout.test.ts +125 -55
- package/src/components/diff-sheet-layout.ts +3 -103
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +15 -0
- package/src/components/file-tree.ts +59 -4
- package/src/components/pages/Workspace.stories.tsx +2 -2
- package/src/components/primitives/IconButton.tsx +21 -2
- package/src/components/primitives/Tree.tsx +13 -4
- package/src/components/primitives/icons.ts +27 -4
- package/src/components/session-tree.test.ts +20 -0
- package/src/components/session-tree.ts +16 -2
- package/src/components/stories-app.tsx +2 -2
- package/src/config.test.ts +20 -49
- package/src/config.ts +26 -28
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-syntax.test.ts +16 -0
- package/src/diff-syntax.ts +6 -3
- package/src/discussions.ts +79 -0
- package/src/intent-dispatch.test.ts +31 -11
- package/src/intent-dispatch.ts +93 -48
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +3 -25
- package/src/keymap.ts +17 -18
- package/src/mark-runs.ts +120 -0
- package/src/pty.ts +10 -2
- package/src/run.ts +17 -7
- package/src/serve.test.ts +4 -8
- package/src/session-controller.curate.test.ts +75 -0
- package/src/session-controller.share.test.ts +1 -0
- package/src/session-controller.tree.test.ts +26 -0
- package/src/session-controller.ts +153 -29
- package/src/slash-palette.ts +83 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +3 -3
- package/src/test-support.ts +39 -8
- package/src/thread-chords.test.ts +25 -9
- package/src/thread-chords.ts +33 -8
- package/src/use-annotation-surface.tsx +837 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +14 -24
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/view-diff.ts +114 -0
- package/src/components/ReviewPanel.stories.tsx +0 -100
- package/src/components/ReviewPanel.tsx +0 -103
- package/src/components/ReviewRail.stories.tsx +0 -232
- package/src/components/ReviewRail.tsx +0 -327
- package/src/review-panel.test.ts +0 -113
- package/src/review-panel.ts +0 -83
package/src/keymap.test.ts
CHANGED
|
@@ -389,6 +389,7 @@ describe("diff mode", () => {
|
|
|
389
389
|
["v", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
|
|
390
390
|
["x", [{ type: "rejectChange" }]],
|
|
391
391
|
["e", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
|
|
392
|
+
["s", [{ type: "toggleDiffView" }]],
|
|
392
393
|
["q", [{ type: "exit" }]],
|
|
393
394
|
];
|
|
394
395
|
|
|
@@ -490,31 +491,8 @@ describe("guided walk", () => {
|
|
|
490
491
|
});
|
|
491
492
|
});
|
|
492
493
|
|
|
493
|
-
describe("
|
|
494
|
-
|
|
495
|
-
["b", [{ type: "cycleReviewPanel" }]],
|
|
496
|
-
["]", [{ type: "resizeReviewPanel", direction: 1 }]],
|
|
497
|
-
["[", [{ type: "resizeReviewPanel", direction: -1 }]],
|
|
498
|
-
];
|
|
499
|
-
|
|
500
|
-
for (const view of ["plan", "diff"] as const) {
|
|
501
|
-
for (const [name, expected] of table) {
|
|
502
|
-
test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
503
|
-
expect(reduceKey(state({ view }), key(name))).toEqual(expected);
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
|
|
509
|
-
// Arrange
|
|
510
|
-
const observer = state({ readOnly: true });
|
|
511
|
-
|
|
512
|
-
// Assert
|
|
513
|
-
expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
|
|
514
|
-
expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
test("span mode still owns b as a span primitive, not a panel cycle", () => {
|
|
494
|
+
describe("span and walk own their keys", () => {
|
|
495
|
+
test("span mode owns b as a span primitive", () => {
|
|
518
496
|
// Assert
|
|
519
497
|
expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([
|
|
520
498
|
{ type: "spanKey", name: "b" },
|
package/src/keymap.ts
CHANGED
|
@@ -33,6 +33,9 @@ export type Intent =
|
|
|
33
33
|
| { type: "rejectHunk" }
|
|
34
34
|
| { type: "rejectChange" }
|
|
35
35
|
| { type: "restoreCuration" }
|
|
36
|
+
| { type: "foldFile" }
|
|
37
|
+
| { type: "unfoldFile" }
|
|
38
|
+
| { type: "toggleDiffView" }
|
|
36
39
|
| { type: "nextAnnotation" }
|
|
37
40
|
| { type: "prevAnnotation" }
|
|
38
41
|
| { type: "walkStart" }
|
|
@@ -48,8 +51,6 @@ export type Intent =
|
|
|
48
51
|
| { type: "finishReview" }
|
|
49
52
|
| { type: "optInAutoClose" }
|
|
50
53
|
| { type: "dismissCompletion" }
|
|
51
|
-
| { type: "cycleReviewPanel" }
|
|
52
|
-
| { type: "resizeReviewPanel"; direction: -1 | 1 }
|
|
53
54
|
| { type: "toggleTree" }
|
|
54
55
|
| { type: "treeMove"; direction: -1 | 1 }
|
|
55
56
|
| { type: "treeGo" }
|
|
@@ -151,14 +152,8 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
if (state.view === "inbox") return inboxGrammar(state, name);
|
|
154
|
-
// span mode owns its single-letter keys (b slides the span back)
|
|
155
|
-
// review-panel controls claim them
|
|
155
|
+
// span mode owns its single-letter keys (b slides the span back)
|
|
156
156
|
if (state.spanMode) return spanGrammar(state, name);
|
|
157
|
-
// the review panel rides both plan and diff reviews; collapsing and resizing
|
|
158
|
-
// are view state, so the read-only gate above lets them through
|
|
159
|
-
const reviewPanel = reviewPanelGrammar(action);
|
|
160
|
-
|
|
161
|
-
if (reviewPanel) return reviewPanel;
|
|
162
157
|
if (state.view === "diff") return diffGrammar(state, action);
|
|
163
158
|
|
|
164
159
|
return planGrammar(state, action, name);
|
|
@@ -258,15 +253,6 @@ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
258
253
|
return [];
|
|
259
254
|
}
|
|
260
255
|
|
|
261
|
-
/** The review-panel controls: cycle the mode, widen and narrow the rail. */
|
|
262
|
-
function reviewPanelGrammar(action: string | undefined): Intent[] | null {
|
|
263
|
-
if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
|
|
264
|
-
if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
|
|
265
|
-
if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
|
|
266
|
-
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
256
|
function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
271
257
|
if (!state.hasInboxItems) return [];
|
|
272
258
|
if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
|
|
@@ -301,6 +287,9 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
|
301
287
|
|
|
302
288
|
return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
|
|
303
289
|
}
|
|
290
|
+
const viewToggle = viewToggleIntent(action);
|
|
291
|
+
|
|
292
|
+
if (viewToggle) return viewToggle;
|
|
304
293
|
// restore un-does a curated-out rejection from the rail; same owner gate as reject
|
|
305
294
|
if (action === "restore_curation") {
|
|
306
295
|
if (state.resolved) return status("review submitted - read-only");
|
|
@@ -382,6 +371,16 @@ function navigationIntent(action: string | undefined): Intent[] | null {
|
|
|
382
371
|
return null;
|
|
383
372
|
}
|
|
384
373
|
|
|
374
|
+
/** Ungated view toggles in the diff: right folds a file to its band, left unfolds it, s flips
|
|
375
|
+
* unified/split (split lays out only when the Changes pane is zoomed). */
|
|
376
|
+
function viewToggleIntent(action: string | undefined): Intent[] | null {
|
|
377
|
+
if (action === "collapse_file") return [{ type: "foldFile" }];
|
|
378
|
+
if (action === "expand_file") return [{ type: "unfoldFile" }];
|
|
379
|
+
if (action === "split_diff") return [{ type: "toggleDiffView" }];
|
|
380
|
+
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
385
384
|
/** Annotation navigation, deletion, and submit: one path for plan and diff. */
|
|
386
385
|
function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
|
|
387
386
|
if (action === "next_annotation" || action === "prev_annotation") {
|
package/src/mark-runs.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mark geometry for an annotated line of text: which stretches are marked
|
|
3
|
+
* (a discussion span, a held selection) or carry the caret cell, the greedy
|
|
4
|
+
* word wrap into visual lines, and the printable-key test that starts a
|
|
5
|
+
* comment. Renderer-free, shared by the plan thread view and the diff sheet.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { KeyEvent } from "@opentui/core";
|
|
9
|
+
import { wordRanges } from "./thread-selection";
|
|
10
|
+
|
|
11
|
+
export interface MarkRange {
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
/** The bare caret's single cell - a cursor, not yet a mark. */
|
|
15
|
+
caretOnly?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Run {
|
|
19
|
+
text: string;
|
|
20
|
+
marked: boolean;
|
|
21
|
+
caretOnly: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Split a block's text into plain/marked runs from the union of ranges. */
|
|
25
|
+
export function runsFor(text: string, ranges: MarkRange[]): Run[] {
|
|
26
|
+
const cuts = new Set<number>([0, text.length]);
|
|
27
|
+
|
|
28
|
+
for (const range of ranges) {
|
|
29
|
+
cuts.add(Math.max(0, range.start));
|
|
30
|
+
cuts.add(Math.min(text.length, range.end));
|
|
31
|
+
}
|
|
32
|
+
const edges = [...cuts].toSorted((left, right) => left - right);
|
|
33
|
+
const runs: Run[] = [];
|
|
34
|
+
|
|
35
|
+
for (let index = 0; index < edges.length - 1; index++) {
|
|
36
|
+
const start = edges[index]!;
|
|
37
|
+
const end = edges[index + 1]!;
|
|
38
|
+
|
|
39
|
+
if (end <= start) continue;
|
|
40
|
+
const covering = ranges.filter((range) => range.start <= start && end <= range.end);
|
|
41
|
+
// the caret cell shows through a mark, so the head of a selection is visible
|
|
42
|
+
runs.push({
|
|
43
|
+
text: text.slice(start, end),
|
|
44
|
+
marked: covering.some((range) => range.caretOnly !== true),
|
|
45
|
+
caretOnly: covering.some((range) => range.caretOnly === true),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return runs.length > 0 ? runs : [{ text, marked: false, caretOnly: false }];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A visual line of a block: the char range of the block's text it shows. */
|
|
53
|
+
export interface VisualLine {
|
|
54
|
+
start: number;
|
|
55
|
+
end: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Greedy word wrap into visual-line char ranges, so a comment card can slot
|
|
60
|
+
* in directly below the line its mark ends on (mid-paragraph when needed).
|
|
61
|
+
*/
|
|
62
|
+
export function wrapLines(text: string, width: number): VisualLine[] {
|
|
63
|
+
const lines: VisualLine[] = [];
|
|
64
|
+
let segmentStart = 0;
|
|
65
|
+
|
|
66
|
+
// hard breaks first: every visual line must be exactly one terminal row,
|
|
67
|
+
// or hit-testing and the mark geometry drift from what is painted
|
|
68
|
+
for (const segment of text.split("\n")) {
|
|
69
|
+
const segmentEnd = segmentStart + segment.length;
|
|
70
|
+
|
|
71
|
+
if (width <= 0 || segment.length <= width) {
|
|
72
|
+
lines.push({ start: segmentStart, end: segmentEnd });
|
|
73
|
+
} else {
|
|
74
|
+
// greedy word wrap inside the segment; the first line keeps its
|
|
75
|
+
// leading indentation, continuation lines start at a word
|
|
76
|
+
let lineStart = segmentStart;
|
|
77
|
+
let lineEnd = segmentStart;
|
|
78
|
+
|
|
79
|
+
for (const word of wordRanges(segment)) {
|
|
80
|
+
const wordStart = segmentStart + word.start;
|
|
81
|
+
const wordEnd = segmentStart + word.end;
|
|
82
|
+
|
|
83
|
+
if (wordEnd - lineStart > width && lineEnd > lineStart) {
|
|
84
|
+
lines.push({ start: lineStart, end: lineEnd });
|
|
85
|
+
lineStart = wordStart;
|
|
86
|
+
}
|
|
87
|
+
// a token wider than the line (a long identifier, a URL) breaks at the cell edge, so
|
|
88
|
+
// every visual line stays one terminal row and nothing is clipped away
|
|
89
|
+
while (wordEnd - lineStart > width) {
|
|
90
|
+
lines.push({ start: lineStart, end: lineStart + width });
|
|
91
|
+
lineStart += width;
|
|
92
|
+
}
|
|
93
|
+
lineEnd = wordEnd;
|
|
94
|
+
}
|
|
95
|
+
lines.push({ start: lineStart, end: Math.max(lineEnd, lineStart) });
|
|
96
|
+
}
|
|
97
|
+
segmentStart = segmentEnd + 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return lines;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The block's mark ranges clipped to one visual line, in that line's coordinates. */
|
|
104
|
+
export function lineMarkRanges(ranges: MarkRange[], line: VisualLine): MarkRange[] {
|
|
105
|
+
return ranges
|
|
106
|
+
.map((range) => ({
|
|
107
|
+
start: Math.max(range.start, line.start) - line.start,
|
|
108
|
+
end: Math.min(range.end, line.end) - line.start,
|
|
109
|
+
caretOnly: range.caretOnly,
|
|
110
|
+
}))
|
|
111
|
+
.filter((range) => range.end > range.start);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A printable character: single-width input that should reach a composer. */
|
|
115
|
+
export function printableSequence(key: KeyEvent): string | null {
|
|
116
|
+
const printable =
|
|
117
|
+
key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta && key.sequence >= " ";
|
|
118
|
+
|
|
119
|
+
return printable ? key.sequence : null;
|
|
120
|
+
}
|
package/src/pty.ts
CHANGED
|
@@ -13,8 +13,10 @@ import { join } from "node:path";
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_COLS = 80;
|
|
15
15
|
const DEFAULT_ROWS = 24;
|
|
16
|
-
/**
|
|
16
|
+
/** Shortest read-loop sleep, used while the child is actively streaming - keeps output snappy. */
|
|
17
17
|
const READ_IDLE_MS = 8;
|
|
18
|
+
/** Longest read-loop sleep, reached after the child sits quiet - stops the loop burning a core at idle. */
|
|
19
|
+
const READ_IDLE_MAX_MS = 100;
|
|
18
20
|
const READ_BUFFER_BYTES = 4096;
|
|
19
21
|
/** cueloop_pty_read's sentinel: the child has exited and its output is drained. */
|
|
20
22
|
const CHILD_EXITED = -2;
|
|
@@ -200,11 +202,15 @@ class Pty implements IPty {
|
|
|
200
202
|
if (this.reading) return;
|
|
201
203
|
this.reading = true;
|
|
202
204
|
const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES);
|
|
205
|
+
// consecutive empty reads; the idle sleep backs off exponentially so a quiet agent prompt
|
|
206
|
+
// ramps from an 8ms hot poll up to a 100ms idle poll instead of spinning a core forever
|
|
207
|
+
let idlePolls = 0;
|
|
203
208
|
|
|
204
209
|
while (!this.closing) {
|
|
205
210
|
const count = this.lib.cueloop_pty_read(this.handle, ptr(buffer), buffer.length);
|
|
206
211
|
|
|
207
212
|
if (count > 0) {
|
|
213
|
+
idlePolls = 0;
|
|
208
214
|
// Stream mode buffers a multibyte char split across reads (box-drawing etc.).
|
|
209
215
|
const text = this.decoder.decode(buffer.subarray(0, count), { stream: true });
|
|
210
216
|
|
|
@@ -221,7 +227,9 @@ class Pty implements IPty {
|
|
|
221
227
|
this.closing = true;
|
|
222
228
|
this.exitEvent.fire({ exitCode });
|
|
223
229
|
} else {
|
|
224
|
-
|
|
230
|
+
const delay = Math.min(READ_IDLE_MAX_MS, READ_IDLE_MS * 2 ** Math.min(idlePolls, 5));
|
|
231
|
+
idlePolls += 1;
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
225
233
|
}
|
|
226
234
|
}
|
|
227
235
|
}
|
package/src/run.ts
CHANGED
|
@@ -21,18 +21,28 @@ export async function runClient(options: RunClientOptions): Promise<number> {
|
|
|
21
21
|
(await renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
|
|
22
22
|
|
|
23
23
|
return new Promise<number>((resolve) => {
|
|
24
|
+
let exited = false;
|
|
25
|
+
const shutdown = (code: number): void => {
|
|
26
|
+
if (exited) return;
|
|
27
|
+
exited = true;
|
|
28
|
+
renderer.destroy();
|
|
29
|
+
resolve(code);
|
|
30
|
+
// one microtask between destroy and exit lets the renderer flush
|
|
31
|
+
// its terminal-restore sequences before the process dies
|
|
32
|
+
queueMicrotask(() => process.exit(code));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// a closed pane/terminal hangs up (or the parent kills us); exit instead of
|
|
36
|
+
// lingering as an orphan whose pty read loop keeps burning CPU
|
|
37
|
+
process.once("SIGHUP", () => shutdown(0));
|
|
38
|
+
process.once("SIGTERM", () => shutdown(0));
|
|
39
|
+
|
|
24
40
|
createRoot(renderer).render(
|
|
25
41
|
React.createElement(App, {
|
|
26
42
|
home: options.home,
|
|
27
43
|
sessionId: options.sessionId,
|
|
28
44
|
appearance,
|
|
29
|
-
onExit:
|
|
30
|
-
renderer.destroy();
|
|
31
|
-
resolve(code);
|
|
32
|
-
// one microtask between destroy and exit lets the renderer flush
|
|
33
|
-
// its terminal-restore sequences before the process dies
|
|
34
|
-
queueMicrotask(() => process.exit(code));
|
|
35
|
-
},
|
|
45
|
+
onExit: shutdown,
|
|
36
46
|
}),
|
|
37
47
|
);
|
|
38
48
|
});
|
package/src/serve.test.ts
CHANGED
|
@@ -84,18 +84,14 @@ describe("cueloop serve", () => {
|
|
|
84
84
|
expect(existsSync(join(home, "ssh", "host_key"))).toBe(true);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
test("an anonymous ssh client gets the
|
|
87
|
+
test("an anonymous ssh client gets the read-only TUI for the session", async () => {
|
|
88
88
|
// Act
|
|
89
|
-
const bytes = await sshCapture(
|
|
90
|
-
handle.port,
|
|
91
|
-
(frame) => frame.includes("Rollout Plan") && frame.includes("observer"),
|
|
92
|
-
);
|
|
89
|
+
const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan"));
|
|
93
90
|
|
|
94
|
-
// Assert
|
|
91
|
+
// Assert - the viewer sees the session; read-only is enforced behaviorally
|
|
92
|
+
// (covered by the observer-primitives tests), not by a header badge
|
|
95
93
|
expect(bytes).toContain("cueloop");
|
|
96
94
|
expect(bytes).toContain("Rollout Plan");
|
|
97
|
-
// observer chrome: the header badge marks the read-only session
|
|
98
|
-
expect(bytes).toContain("observer");
|
|
99
95
|
});
|
|
100
96
|
|
|
101
97
|
test("two observers can watch at once", async () => {
|
|
@@ -114,6 +114,7 @@ function fakeClient(initial: ReviewSession, sink: WorkingCopySink): SessionClien
|
|
|
114
114
|
sessionLabel: unimplemented("sessionLabel"),
|
|
115
115
|
sessionFork: unimplemented("sessionFork"),
|
|
116
116
|
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
117
|
+
sessionSetTitle: unimplemented("sessionSetTitle"),
|
|
117
118
|
sessionSetShareId: unimplemented("sessionSetShareId"),
|
|
118
119
|
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
119
120
|
sessionDelete: unimplemented("sessionDelete"),
|
|
@@ -360,3 +361,77 @@ describe("plan cut removals", () => {
|
|
|
360
361
|
expect(sink.workingCopy).toBe("# Title\n");
|
|
361
362
|
});
|
|
362
363
|
});
|
|
364
|
+
|
|
365
|
+
// a file whose hunk touches line 2 only, so lines 1/3/4 are unchanged tail/context
|
|
366
|
+
const TAIL_PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
367
|
+
--- a/src/store.ts
|
|
368
|
+
+++ b/src/store.ts
|
|
369
|
+
@@ -2,1 +2,1 @@
|
|
370
|
+
- private items = [];
|
|
371
|
+
+ private items = new Map();
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
function tailSession(): ReviewSession {
|
|
375
|
+
return {
|
|
376
|
+
...diffSession(),
|
|
377
|
+
artifact: {
|
|
378
|
+
type: "diff",
|
|
379
|
+
content: TAIL_PATCH,
|
|
380
|
+
meta: {},
|
|
381
|
+
files: [
|
|
382
|
+
{
|
|
383
|
+
path: "src/store.ts",
|
|
384
|
+
oldContents: "export class Store {\n private items = [];\n}\nexport const x = 1;\n",
|
|
385
|
+
newContents:
|
|
386
|
+
"export class Store {\n private items = new Map();\n}\nexport const x = 1;\n",
|
|
387
|
+
status: "modified",
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
describe("diff file fold", () => {
|
|
395
|
+
test("collapse leaves only the file band; expand restores the body", async () => {
|
|
396
|
+
// Arrange
|
|
397
|
+
const { controller } = await connected(tailSession());
|
|
398
|
+
const bodyRows = () =>
|
|
399
|
+
controller.rows().filter((row) => row.file === "src/store.ts" && row.kind !== "file").length;
|
|
400
|
+
|
|
401
|
+
// Assert - starts unfolded with a body
|
|
402
|
+
expect(bodyRows()).toBeGreaterThan(0);
|
|
403
|
+
|
|
404
|
+
// Act / Assert - collapse hides the body
|
|
405
|
+
controller.setFileCollapsed("src/store.ts", true);
|
|
406
|
+
expect(bodyRows()).toBe(0);
|
|
407
|
+
expect(controller.isFileCollapsed("src/store.ts")).toBe(true);
|
|
408
|
+
|
|
409
|
+
// Act / Assert - unfold brings it back
|
|
410
|
+
controller.setFileCollapsed("src/store.ts", false);
|
|
411
|
+
expect(bodyRows()).toBeGreaterThan(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("expand weaves the file's unchanged tail line the hunk never showed", async () => {
|
|
415
|
+
// Arrange
|
|
416
|
+
const { controller } = await connected(tailSession());
|
|
417
|
+
const hasTail = () => controller.rows().some((row) => row.text.includes("export const x = 1"));
|
|
418
|
+
|
|
419
|
+
// Assert - the hunk view does not show the tail line
|
|
420
|
+
expect(controller.canExpandFile("src/store.ts")).toBe(true);
|
|
421
|
+
expect(hasTail()).toBe(false);
|
|
422
|
+
|
|
423
|
+
// Act - weave the full file
|
|
424
|
+
controller.setFileExpanded("src/store.ts", true);
|
|
425
|
+
|
|
426
|
+
// Assert
|
|
427
|
+
expect(hasTail()).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("a diff without full contents cannot be expanded", async () => {
|
|
431
|
+
// Arrange - no artifact.files
|
|
432
|
+
const { controller } = await connected(diffSession(undefined));
|
|
433
|
+
|
|
434
|
+
// Assert
|
|
435
|
+
expect(controller.canExpandFile("src/store.ts")).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
@@ -78,6 +78,7 @@ function fakeClient(session: ReviewSession): FakeSessionClient {
|
|
|
78
78
|
sessionLabel: unimplemented("sessionLabel"),
|
|
79
79
|
sessionFork: unimplemented("sessionFork"),
|
|
80
80
|
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
81
|
+
sessionSetTitle: unimplemented("sessionSetTitle"),
|
|
81
82
|
sessionSetShareId: mock(
|
|
82
83
|
async (_id: string, shareId: string) => ((session.shareId = shareId), session),
|
|
83
84
|
),
|
|
@@ -91,6 +91,7 @@ function fakeClient(session: ReviewSession) {
|
|
|
91
91
|
parentSessionId: id,
|
|
92
92
|
})),
|
|
93
93
|
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
94
|
+
sessionSetTitle: unimplemented("sessionSetTitle"),
|
|
94
95
|
sessionSetShareId: mock(async (_id: string, _shareId: string) => session),
|
|
95
96
|
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
96
97
|
sessionDelete: unimplemented("sessionDelete"),
|
|
@@ -116,6 +117,31 @@ async function connected(session = sessionFixture()) {
|
|
|
116
117
|
return { controller, client, session };
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
describe("read-only guards", () => {
|
|
121
|
+
test("an observer's rename and delete never reach the daemon", async () => {
|
|
122
|
+
// Arrange - a read-only controller over a client whose mutations would throw
|
|
123
|
+
const session = sessionFixture();
|
|
124
|
+
const client = fakeClient(session);
|
|
125
|
+
const controller = createReviewController({
|
|
126
|
+
sessionId: session.id,
|
|
127
|
+
openClient: async () => client,
|
|
128
|
+
readOnly: true,
|
|
129
|
+
shareTransport,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
controller.connect();
|
|
133
|
+
await tick();
|
|
134
|
+
|
|
135
|
+
// Act - the sidebar's rename and delete, invoked directly
|
|
136
|
+
controller.renameSession(session.id, "hijacked");
|
|
137
|
+
controller.deleteSession(session.id);
|
|
138
|
+
await tick();
|
|
139
|
+
|
|
140
|
+
// Assert - both answer read-only and the throwing client stubs were never called
|
|
141
|
+
expect(controller.getSnapshot().status).toBe("observer - read-only");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
119
145
|
describe("tree primitives", () => {
|
|
120
146
|
test("a move back shows the earlier path at once and asks the daemon for the same move", async () => {
|
|
121
147
|
// Arrange
|