@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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Per-file collapse and full-file weave over the flat diff rows. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { DiffRow } from "./view-diff";
|
|
5
|
+
import { applyFold, weaveFullFileRows } from "./diff-fold";
|
|
6
|
+
|
|
7
|
+
function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
|
|
8
|
+
return { kind, text, file: "a.ts", ...extra };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// a two-line change in a four-line file: line 2 swapped, lines 1/3/4 unchanged
|
|
12
|
+
const base: DiffRow[] = [
|
|
13
|
+
row("file", "a.ts"),
|
|
14
|
+
row("hunk", "@@ -1,3 +1,3 @@"),
|
|
15
|
+
row("ctx", "line one", { oldLine: 1, newLine: 1 }),
|
|
16
|
+
row("del", "old two", { oldLine: 2 }),
|
|
17
|
+
row("add", "new two", { newLine: 2 }),
|
|
18
|
+
row("ctx", "line three", { oldLine: 3, newLine: 3 }),
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const contents = {
|
|
22
|
+
path: "a.ts",
|
|
23
|
+
oldContents: "line one\nold two\nline three\nline four\n",
|
|
24
|
+
newContents: "line one\nnew two\nline three\nline four\n",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("applyFold", () => {
|
|
28
|
+
test("with no fold state it returns the base rows unchanged", () => {
|
|
29
|
+
expect(applyFold(base, new Set(), new Set(), [contents])).toBe(base);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a collapsed file keeps only its file band", () => {
|
|
33
|
+
// Act
|
|
34
|
+
const folded = applyFold(base, new Set(["a.ts"]), new Set(), [contents]);
|
|
35
|
+
|
|
36
|
+
// Assert
|
|
37
|
+
expect(folded).toHaveLength(1);
|
|
38
|
+
expect(folded[0]).toEqual(row("file", "a.ts"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("an expanded file weaves the unchanged tail line the hunk never showed", () => {
|
|
42
|
+
// Act
|
|
43
|
+
const folded = applyFold(base, new Set(), new Set(["a.ts"]), [contents]);
|
|
44
|
+
|
|
45
|
+
// Assert - the hunk header is gone and "line four" (outside the hunk) now shows
|
|
46
|
+
expect(folded.some((r) => r.kind === "hunk")).toBe(false);
|
|
47
|
+
const four = folded.find((r) => r.text === "line four");
|
|
48
|
+
|
|
49
|
+
expect(four).toEqual(row("ctx", "line four", { oldLine: 4, newLine: 4 }));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("expand is a no-op without the file's full contents", () => {
|
|
53
|
+
// Act - no contents entry, so the file keeps its hunks
|
|
54
|
+
const folded = applyFold(base, new Set(), new Set(["a.ts"]), undefined);
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
|
+
expect(folded).toEqual(base);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("weaveFullFileRows", () => {
|
|
62
|
+
test("keeps the change rows and fills every unchanged line as context in order", () => {
|
|
63
|
+
// Arrange - the file's code rows (no headers)
|
|
64
|
+
const codeRows = base.slice(2);
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
const woven = weaveFullFileRows("a.ts", codeRows, contents.newContents);
|
|
68
|
+
|
|
69
|
+
// Assert - full file: ctx1, del, add, ctx3, ctx4
|
|
70
|
+
expect(woven.map((r) => `${r.kind}:${r.text}`)).toEqual([
|
|
71
|
+
"ctx:line one",
|
|
72
|
+
"del:old two",
|
|
73
|
+
"add:new two",
|
|
74
|
+
"ctx:line three",
|
|
75
|
+
"ctx:line four",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/diff-fold.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-file fold state for the diff sheet, applied over the flat diff rows so the
|
|
3
|
+
* cursor, curation, and rendering all read one array. A collapsed file shows only
|
|
4
|
+
* its file band; an expanded file shows its whole contents with the diff woven in
|
|
5
|
+
* (unchanged lines become context rows), matching VSCode's "expand all lines".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DiffRow } from "./view-diff";
|
|
9
|
+
|
|
10
|
+
/** The full old/new contents a diff carries per file (curatable diffs do). */
|
|
11
|
+
export interface FileContents {
|
|
12
|
+
path: string;
|
|
13
|
+
newContents: string;
|
|
14
|
+
oldContents: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Split file contents into lines, dropping the single trailing newline the file carries. */
|
|
18
|
+
function contentLines(contents: string): string[] {
|
|
19
|
+
if (contents === "") return [];
|
|
20
|
+
|
|
21
|
+
return contents.replace(/\n$/, "").split("\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Weave a file's hunk rows into its full contents: unchanged lines between and
|
|
26
|
+
* around the hunks become context rows, so the whole file renders with the diff
|
|
27
|
+
* inline. `codeRows` are the file's ctx/add/del rows in order (no headers).
|
|
28
|
+
*/
|
|
29
|
+
export function weaveFullFileRows(
|
|
30
|
+
fileName: string,
|
|
31
|
+
codeRows: DiffRow[],
|
|
32
|
+
newContents: string,
|
|
33
|
+
): DiffRow[] {
|
|
34
|
+
const newLines = contentLines(newContents);
|
|
35
|
+
const woven: DiffRow[] = [];
|
|
36
|
+
// the next unchanged old/new line numbers to emit while filling gaps between hunks
|
|
37
|
+
let newCursor = 1;
|
|
38
|
+
let oldCursor = 1;
|
|
39
|
+
const fillTo = (newLine: number): void => {
|
|
40
|
+
while (newCursor < newLine) {
|
|
41
|
+
woven.push({
|
|
42
|
+
kind: "ctx",
|
|
43
|
+
text: newLines[newCursor - 1] ?? "",
|
|
44
|
+
file: fileName,
|
|
45
|
+
oldLine: oldCursor,
|
|
46
|
+
newLine: newCursor,
|
|
47
|
+
});
|
|
48
|
+
newCursor += 1;
|
|
49
|
+
oldCursor += 1;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (const row of codeRows) {
|
|
54
|
+
if (row.newLine !== undefined) {
|
|
55
|
+
fillTo(row.newLine);
|
|
56
|
+
woven.push(row);
|
|
57
|
+
newCursor = row.newLine + 1;
|
|
58
|
+
if (row.kind === "ctx") oldCursor = (row.oldLine ?? oldCursor) + 1;
|
|
59
|
+
} else {
|
|
60
|
+
// a deletion consumes an old line only; it keeps its place before the additions
|
|
61
|
+
woven.push(row);
|
|
62
|
+
oldCursor = (row.oldLine ?? oldCursor) + 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// the unchanged tail after the last hunk
|
|
66
|
+
while (newCursor <= newLines.length) {
|
|
67
|
+
woven.push({
|
|
68
|
+
kind: "ctx",
|
|
69
|
+
text: newLines[newCursor - 1] ?? "",
|
|
70
|
+
file: fileName,
|
|
71
|
+
oldLine: oldCursor,
|
|
72
|
+
newLine: newCursor,
|
|
73
|
+
});
|
|
74
|
+
newCursor += 1;
|
|
75
|
+
oldCursor += 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return woven;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rebuild the diff rows with per-file fold state. A collapsed file keeps only its
|
|
83
|
+
* file band; an expanded file with known contents shows its whole file woven; every
|
|
84
|
+
* other file keeps its hunks. Row order and the file bands themselves are preserved.
|
|
85
|
+
*/
|
|
86
|
+
export function applyFold(
|
|
87
|
+
base: DiffRow[],
|
|
88
|
+
collapsed: ReadonlySet<string>,
|
|
89
|
+
expanded: ReadonlySet<string>,
|
|
90
|
+
files: readonly FileContents[] | undefined,
|
|
91
|
+
): DiffRow[] {
|
|
92
|
+
if (collapsed.size === 0 && expanded.size === 0) return base;
|
|
93
|
+
const contentsByPath = new Map((files ?? []).map((file) => [file.path, file]));
|
|
94
|
+
const out: DiffRow[] = [];
|
|
95
|
+
let index = 0;
|
|
96
|
+
|
|
97
|
+
while (index < base.length) {
|
|
98
|
+
const row = base[index]!;
|
|
99
|
+
|
|
100
|
+
if (row.kind !== "file") {
|
|
101
|
+
out.push(row);
|
|
102
|
+
index += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
out.push(row);
|
|
106
|
+
const file = row.file;
|
|
107
|
+
let end = index + 1;
|
|
108
|
+
|
|
109
|
+
while (end < base.length && base[end]!.kind !== "file") end += 1;
|
|
110
|
+
const body = base.slice(index + 1, end);
|
|
111
|
+
const contents = contentsByPath.get(file);
|
|
112
|
+
|
|
113
|
+
if (collapsed.has(file)) {
|
|
114
|
+
// only the file band; the body is hidden
|
|
115
|
+
} else if (expanded.has(file) && contents) {
|
|
116
|
+
const codeRows = body.filter((bodyRow) => bodyRow.kind !== "hunk");
|
|
117
|
+
|
|
118
|
+
out.push(...weaveFullFileRows(file, codeRows, contents.newContents));
|
|
119
|
+
} else {
|
|
120
|
+
out.push(...body);
|
|
121
|
+
}
|
|
122
|
+
index = end;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return out;
|
|
126
|
+
}
|
package/src/diff-syntax.test.ts
CHANGED
|
@@ -41,6 +41,22 @@ describe("spansByLine", () => {
|
|
|
41
41
|
expect(lineSpans[1]).toEqual([{ start: 0, end: 2, group: "comment" }]);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test("a capture ending past the source clamps to its end instead of walking forever", () => {
|
|
45
|
+
// Arrange - offsets past the string, as byte offsets over a multi-byte line report
|
|
46
|
+
const source = "a│b\ncc";
|
|
47
|
+
const highlights: SimpleHighlight[] = [
|
|
48
|
+
[0, 9, "string"],
|
|
49
|
+
[12, 20, "comment"],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// Act
|
|
53
|
+
const lineSpans = spansByLine(source, highlights);
|
|
54
|
+
|
|
55
|
+
// Assert - the covered text is colored to the end and the second capture paints nothing
|
|
56
|
+
expect(lineSpans[0]).toEqual([{ start: 0, end: 3, group: "string" }]);
|
|
57
|
+
expect(lineSpans[1]).toEqual([{ start: 0, end: 2, group: "string" }]);
|
|
58
|
+
});
|
|
59
|
+
|
|
44
60
|
test("resolves overlapping captures with later winning", () => {
|
|
45
61
|
// Arrange
|
|
46
62
|
const source = "foo";
|
package/src/diff-syntax.ts
CHANGED
|
@@ -111,10 +111,13 @@ export function spansByLine(source: string, highlights: SimpleHighlight[]): Synt
|
|
|
111
111
|
return Array.from({ length: Math.max(0, end - start) }, () => undefined);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
for (const [start,
|
|
114
|
+
for (const [start, rawEnd, group] of highlights) {
|
|
115
115
|
// A capture can span newlines (block comments, template literals); color the
|
|
116
|
-
// covered columns on every line it crosses, not just the starting line.
|
|
117
|
-
|
|
116
|
+
// covered columns on every line it crosses, not just the starting line. Offsets
|
|
117
|
+
// past the source (byte offsets from a multi-byte line) clamp to its end, or the
|
|
118
|
+
// walk below would sit on the last line forever.
|
|
119
|
+
const end = Math.min(rawEnd, source.length);
|
|
120
|
+
let cursor = Math.max(0, start);
|
|
118
121
|
|
|
119
122
|
while (cursor < end) {
|
|
120
123
|
const line = lineOf(cursor);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discussions: the conversations an annotated surface hosts inline. A root
|
|
3
|
+
* annotation, its replies, and the span they mark, grouped so two roots on the
|
|
4
|
+
* very same text read as one conversation. Pure over the resolved marks, so the
|
|
5
|
+
* plan thread view and the diff sheet group the same way.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Annotation, ReviewSession } from "@cueloop/schema";
|
|
9
|
+
import type { Mark } from "./view-plan";
|
|
10
|
+
import { comparePositions, type TextSpan } from "./thread-selection";
|
|
11
|
+
|
|
12
|
+
/** One conversation: a root annotation, its replies, and the span they mark. */
|
|
13
|
+
export interface Discussion {
|
|
14
|
+
key: string;
|
|
15
|
+
/** The annotation replies attach to. */
|
|
16
|
+
rootId: string;
|
|
17
|
+
/** The block the card renders under: where the span ends. */
|
|
18
|
+
blockIndex: number;
|
|
19
|
+
span: TextSpan;
|
|
20
|
+
annotations: Annotation[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function spanKey(span: TextSpan): string {
|
|
24
|
+
return `${span.start.blockIndex}:${span.start.char}:${span.end.blockIndex}:${span.end.char}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Roots group by the span they resolved to (two roots on the very same text
|
|
29
|
+
* read as one conversation); replies join the discussion their replyTo names, or
|
|
30
|
+
* stand as roots when that root is gone.
|
|
31
|
+
*/
|
|
32
|
+
export function discussionsFrom(session: ReviewSession, marks: Map<number, Mark[]>): Discussion[] {
|
|
33
|
+
const byId = new Map(session.annotations.map((annotation) => [annotation.id, annotation]));
|
|
34
|
+
const spanOf = new Map<string, TextSpan>();
|
|
35
|
+
|
|
36
|
+
for (const [displayIndex, blockMarks] of marks) {
|
|
37
|
+
for (const mark of blockMarks) {
|
|
38
|
+
if (!mark.annotationId || spanOf.has(mark.annotationId)) continue;
|
|
39
|
+
spanOf.set(
|
|
40
|
+
mark.annotationId,
|
|
41
|
+
mark.span ?? {
|
|
42
|
+
start: { blockIndex: displayIndex, char: mark.start },
|
|
43
|
+
end: { blockIndex: displayIndex, char: mark.end },
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const resolved = [...spanOf.keys()].map((id) => byId.get(id)!).filter(Boolean);
|
|
49
|
+
const isRoot = (annotation: Annotation): boolean =>
|
|
50
|
+
annotation.replyTo === undefined || !spanOf.has(annotation.replyTo);
|
|
51
|
+
const grouped = new Map<string, Discussion>();
|
|
52
|
+
const discussionOfRoot = new Map<string, Discussion>();
|
|
53
|
+
|
|
54
|
+
for (const annotation of resolved.filter(isRoot)) {
|
|
55
|
+
const span = spanOf.get(annotation.id)!;
|
|
56
|
+
const key = spanKey(span);
|
|
57
|
+
const discussion = grouped.get(key) ?? {
|
|
58
|
+
key,
|
|
59
|
+
rootId: annotation.id,
|
|
60
|
+
blockIndex: span.end.blockIndex,
|
|
61
|
+
span,
|
|
62
|
+
annotations: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
discussion.annotations.push(annotation);
|
|
66
|
+
grouped.set(key, discussion);
|
|
67
|
+
discussionOfRoot.set(annotation.id, discussion);
|
|
68
|
+
}
|
|
69
|
+
for (const annotation of resolved.filter((candidate) => !isRoot(candidate))) {
|
|
70
|
+
discussionOfRoot.get(annotation.replyTo!)?.annotations.push(annotation);
|
|
71
|
+
}
|
|
72
|
+
for (const discussion of grouped.values()) {
|
|
73
|
+
discussion.annotations.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [...grouped.values()].toSorted((left, right) =>
|
|
77
|
+
comparePositions(left.span.start, right.span.start),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -71,10 +71,23 @@ function baseController(): ReviewController {
|
|
|
71
71
|
dismissToast: mock(),
|
|
72
72
|
display: mock(() => []),
|
|
73
73
|
rows: mock(() => []),
|
|
74
|
+
setFileCollapsed: mock(),
|
|
75
|
+
isFileCollapsed: mock(() => false),
|
|
76
|
+
setFileExpanded: mock(),
|
|
77
|
+
isFileExpanded: mock(() => false),
|
|
78
|
+
canExpandFile: mock(() => false),
|
|
79
|
+
copyFilePath: mock(),
|
|
80
|
+
fileStats: mock(() => new Map()),
|
|
74
81
|
files: mock(() => []),
|
|
75
82
|
working: mock(() => ""),
|
|
83
|
+
projectFiles: mock(() => Promise.resolve<string[]>([])),
|
|
84
|
+
readFile: mock(() => Promise.resolve<string | null>(null)),
|
|
85
|
+
repoFiles: mock(() => Promise.resolve<string[]>([])),
|
|
86
|
+
repoReadFile: mock(() => Promise.resolve<string | null>(null)),
|
|
87
|
+
repoChanges: mock(() => Promise.resolve([])),
|
|
76
88
|
open: mock(),
|
|
77
89
|
deleteSession: mock(),
|
|
90
|
+
renameSession: mock(),
|
|
78
91
|
setSelfName: mock(),
|
|
79
92
|
cut: mock(),
|
|
80
93
|
toggleRejectHunk: mock(),
|
|
@@ -106,7 +119,6 @@ function baseController(): ReviewController {
|
|
|
106
119
|
finishReview: mock(),
|
|
107
120
|
dismissCompletion: mock(),
|
|
108
121
|
optInAutoClose: mock(),
|
|
109
|
-
saveReviewPanel: mock(),
|
|
110
122
|
};
|
|
111
123
|
}
|
|
112
124
|
|
|
@@ -123,9 +135,6 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
123
135
|
inboxCursor: 0,
|
|
124
136
|
mode: { type: "normal" },
|
|
125
137
|
session: null,
|
|
126
|
-
reviewMode: "expanded",
|
|
127
|
-
reviewWidth: 34,
|
|
128
|
-
terminalWidth: 120,
|
|
129
138
|
focusedAnnotationId: undefined,
|
|
130
139
|
selectedCurationId: undefined,
|
|
131
140
|
railTab: "review",
|
|
@@ -133,13 +142,11 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
133
142
|
authorNames: {},
|
|
134
143
|
quickActions: [],
|
|
135
144
|
renameAuthor: mock(),
|
|
145
|
+
renameThread: mock(),
|
|
136
146
|
liveInput: { current: "" },
|
|
137
|
-
reviewWidthRef: { current: 34 },
|
|
138
147
|
setCursor: mock(),
|
|
139
148
|
setInboxCursor: mock(),
|
|
140
149
|
setMode: mock(),
|
|
141
|
-
setReviewMode: mock(),
|
|
142
|
-
setReviewWidth: mock(),
|
|
143
150
|
setRailTab: mock(),
|
|
144
151
|
setSelectedEntryId: mock(),
|
|
145
152
|
setFocusedAnnotationId: mock(),
|
|
@@ -148,6 +155,7 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
148
155
|
selectCardFromDocument: mock(),
|
|
149
156
|
runEditorHandOff: mock(),
|
|
150
157
|
openCardEdit: mock(),
|
|
158
|
+
toggleDiffView: mock(),
|
|
151
159
|
...overrides,
|
|
152
160
|
};
|
|
153
161
|
}
|
|
@@ -349,17 +357,15 @@ describe("marker-actions popover", () => {
|
|
|
349
357
|
});
|
|
350
358
|
|
|
351
359
|
describe("openSubmit", () => {
|
|
352
|
-
test("
|
|
360
|
+
test("opens the submit confirm with the default verdict", () => {
|
|
353
361
|
// Arrange
|
|
354
|
-
const deps = makeDeps({ session: sessionWith([])
|
|
362
|
+
const deps = makeDeps({ session: sessionWith([]) });
|
|
355
363
|
const dispatch = createIntentDispatch(deps);
|
|
356
364
|
|
|
357
365
|
// Act
|
|
358
366
|
dispatch({ type: "openSubmit" });
|
|
359
367
|
|
|
360
368
|
// Assert
|
|
361
|
-
expect(deps.setReviewMode).toHaveBeenCalledWith("expanded");
|
|
362
|
-
expect(deps.setRailTab).toHaveBeenCalledWith("review");
|
|
363
369
|
expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "approve", summary: "" });
|
|
364
370
|
});
|
|
365
371
|
|
|
@@ -436,6 +442,20 @@ describe("restoreCuration", () => {
|
|
|
436
442
|
});
|
|
437
443
|
});
|
|
438
444
|
|
|
445
|
+
describe("toggleDiffView", () => {
|
|
446
|
+
test("delegates to the App-owned toggle", () => {
|
|
447
|
+
// Arrange
|
|
448
|
+
const deps = makeDeps();
|
|
449
|
+
const dispatch = createIntentDispatch(deps);
|
|
450
|
+
|
|
451
|
+
// Act
|
|
452
|
+
dispatch({ type: "toggleDiffView" });
|
|
453
|
+
|
|
454
|
+
// Assert
|
|
455
|
+
expect(deps.toggleDiffView).toHaveBeenCalled();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
439
459
|
describe("exit", () => {
|
|
440
460
|
test("calls the exit hook with 0", () => {
|
|
441
461
|
// Arrange
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -14,15 +14,11 @@ import type { DiffRow } from "./view-diff";
|
|
|
14
14
|
import type { ReviewController } from "./session-controller";
|
|
15
15
|
import type { Intent } from "./keymap";
|
|
16
16
|
import type { TreeRow } from "./tree-view";
|
|
17
|
-
import type { RailTab } from "./components/ReviewRail";
|
|
18
17
|
import { quickActionBody, type QuickAction } from "./config";
|
|
19
18
|
import { VERDICTS } from "./components/ConfirmCard";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
resolveReviewWidth,
|
|
24
|
-
type ReviewPanelMode,
|
|
25
|
-
} from "./review-panel";
|
|
19
|
+
|
|
20
|
+
/** Which pane of the session tree / review the keyboard grammar is aimed at. */
|
|
21
|
+
export type RailTab = "review" | "agent" | "tree";
|
|
26
22
|
|
|
27
23
|
/** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
|
|
28
24
|
export type Mode =
|
|
@@ -41,6 +37,7 @@ export type Mode =
|
|
|
41
37
|
| { type: "submit"; verdict: VerdictKind; summary: string }
|
|
42
38
|
| { type: "confirmDelete"; sessionId: string; title: string }
|
|
43
39
|
| { type: "rename"; authorId: string; text: string }
|
|
40
|
+
| { type: "renameThread"; sessionId: string; text: string }
|
|
44
41
|
| { type: "nameSelf"; text: string }
|
|
45
42
|
| { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
|
|
46
43
|
|
|
@@ -87,14 +84,11 @@ export interface IntentDispatchDeps {
|
|
|
87
84
|
inboxCursor: number;
|
|
88
85
|
mode: Mode;
|
|
89
86
|
session: ReviewSession | null;
|
|
90
|
-
reviewMode: ReviewPanelMode;
|
|
91
|
-
reviewWidth: number;
|
|
92
|
-
terminalWidth: number;
|
|
93
87
|
focusedAnnotationId: string | undefined;
|
|
94
|
-
/** The curation item selected
|
|
88
|
+
/** The curation item selected for undo, if any. */
|
|
95
89
|
selectedCurationId: string | undefined;
|
|
96
90
|
railTab: RailTab;
|
|
97
|
-
/** The tree row selected in the
|
|
91
|
+
/** The tree row selected in the session tree; the target of go. */
|
|
98
92
|
selectedEntryId: string | undefined;
|
|
99
93
|
/** Planner-local author renames, for seeding the rename prompt. */
|
|
100
94
|
authorNames: Record<string, string>;
|
|
@@ -102,15 +96,14 @@ export interface IntentDispatchDeps {
|
|
|
102
96
|
quickActions: QuickAction[];
|
|
103
97
|
/** Persist an author rename and update the live overrides (App-owned). */
|
|
104
98
|
renameAuthor: (id: string, name: string) => void;
|
|
99
|
+
/** Rename a thread's title through the daemon. */
|
|
100
|
+
renameThread: (id: string, title: string) => void;
|
|
105
101
|
|
|
106
102
|
liveInput: MutableRefObject<string>;
|
|
107
|
-
reviewWidthRef: MutableRefObject<number>;
|
|
108
103
|
|
|
109
104
|
setCursor: Dispatch<SetStateAction<number>>;
|
|
110
105
|
setInboxCursor: Dispatch<SetStateAction<number>>;
|
|
111
106
|
setMode: Dispatch<SetStateAction<Mode>>;
|
|
112
|
-
setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
|
|
113
|
-
setReviewWidth: Dispatch<SetStateAction<number>>;
|
|
114
107
|
setRailTab: Dispatch<SetStateAction<RailTab>>;
|
|
115
108
|
setSelectedEntryId: Dispatch<SetStateAction<string | undefined>>;
|
|
116
109
|
setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
|
|
@@ -120,6 +113,8 @@ export interface IntentDispatchDeps {
|
|
|
120
113
|
selectCardFromDocument: (annotationId: string) => void;
|
|
121
114
|
runEditorHandOff: () => void;
|
|
122
115
|
openCardEdit: (annotationId: string) => void;
|
|
116
|
+
/** Flip unified/split diff and persist it (App-owned); split lays out only when zoomed. */
|
|
117
|
+
toggleDiffView: () => void;
|
|
123
118
|
}
|
|
124
119
|
|
|
125
120
|
type IntentOfType<Kind extends Intent["type"]> = Extract<Intent, { type: Kind }>;
|
|
@@ -132,8 +127,55 @@ function handleStatus(intent: IntentOfType<"status">, deps: IntentDispatchDeps):
|
|
|
132
127
|
deps.controller.setStatus(intent.message);
|
|
133
128
|
}
|
|
134
129
|
|
|
130
|
+
/** A diff cursor rests on code lines only; file and hunk headers are structural dividers. */
|
|
131
|
+
function isCodeRow(row: DiffRow | undefined): boolean {
|
|
132
|
+
return row?.kind === "ctx" || row?.kind === "add" || row?.kind === "del";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A landable diff row: any code line, plus a collapsed file's band (a file row with
|
|
137
|
+
* no body before the next file or the end) so the cursor can reach it to expand it.
|
|
138
|
+
*/
|
|
139
|
+
function isLandableRow(rows: DiffRow[], index: number): boolean {
|
|
140
|
+
const row = rows[index];
|
|
141
|
+
|
|
142
|
+
if (isCodeRow(row)) return true;
|
|
143
|
+
if (row?.kind !== "file") return false;
|
|
144
|
+
const next = rows[index + 1];
|
|
145
|
+
|
|
146
|
+
return next === undefined || next.kind === "file";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The next landable index in the move direction, skipping expanded headers; stays put at an edge. */
|
|
150
|
+
function nextCodeRowIndex(rows: DiffRow[], from: number, to: IntentOfType<"move">["to"]): number {
|
|
151
|
+
if (to === "top") {
|
|
152
|
+
for (let index = 0; index < rows.length; index++) if (isLandableRow(rows, index)) return index;
|
|
153
|
+
|
|
154
|
+
return from;
|
|
155
|
+
}
|
|
156
|
+
if (to === "bottom") {
|
|
157
|
+
for (let index = rows.length - 1; index >= 0; index--) {
|
|
158
|
+
if (isLandableRow(rows, index)) return index;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return from;
|
|
162
|
+
}
|
|
163
|
+
const step = to === "down" ? 1 : -1;
|
|
164
|
+
|
|
165
|
+
for (let index = from + step; index >= 0 && index < rows.length; index += step) {
|
|
166
|
+
if (isLandableRow(rows, index)) return index;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return from;
|
|
170
|
+
}
|
|
171
|
+
|
|
135
172
|
function handleMove(intent: IntentOfType<"move">, deps: IntentDispatchDeps): void {
|
|
136
|
-
|
|
173
|
+
if (deps.isDiff) {
|
|
174
|
+
deps.setCursor((current) => nextCodeRowIndex(deps.rows, current, intent.to));
|
|
175
|
+
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const navigableCount = deps.display.length;
|
|
137
179
|
|
|
138
180
|
if (intent.to === "down") deps.setCursor((current) => Math.min(navigableCount - 1, current + 1));
|
|
139
181
|
else if (intent.to === "up") deps.setCursor((current) => Math.max(0, current - 1));
|
|
@@ -194,6 +236,7 @@ function handleConfirmDialog(
|
|
|
194
236
|
|
|
195
237
|
if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
|
|
196
238
|
else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
|
|
239
|
+
else if (mode.type === "renameThread") deps.renameThread(mode.sessionId, mode.text.trim());
|
|
197
240
|
else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
|
|
198
241
|
else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
|
|
199
242
|
deps.setMode({ type: "normal" });
|
|
@@ -335,11 +378,6 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
|
|
|
335
378
|
|
|
336
379
|
if (!session) return;
|
|
337
380
|
deps.liveInput.current = "";
|
|
338
|
-
// the confirm card lives in the expanded review rail; a compact or hidden
|
|
339
|
-
// panel would swallow the whole submit flow, so force the rail open (live
|
|
340
|
-
// only - the saved panel preference is left untouched)
|
|
341
|
-
deps.setReviewMode("expanded");
|
|
342
|
-
deps.setRailTab("review");
|
|
343
381
|
deps.setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
344
382
|
}
|
|
345
383
|
|
|
@@ -359,6 +397,37 @@ function handleRejectChange(_intent: IntentOfType<"rejectChange">, deps: IntentD
|
|
|
359
397
|
deps.controller.toggleRejectChange(deps.cursor);
|
|
360
398
|
}
|
|
361
399
|
|
|
400
|
+
function handleFoldFile(_intent: IntentOfType<"foldFile">, deps: IntentDispatchDeps): void {
|
|
401
|
+
const file = deps.rows[deps.cursor]?.file;
|
|
402
|
+
|
|
403
|
+
if (!file || deps.controller.isFileCollapsed(file)) return;
|
|
404
|
+
deps.controller.setFileCollapsed(file, true);
|
|
405
|
+
// the file band is unchanged in index by its own collapse, so land the cursor on it
|
|
406
|
+
const headerIndex = deps.controller
|
|
407
|
+
.rows()
|
|
408
|
+
.findIndex((row) => row.kind === "file" && row.file === file);
|
|
409
|
+
|
|
410
|
+
if (headerIndex >= 0) deps.setCursor(headerIndex);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function handleUnfoldFile(_intent: IntentOfType<"unfoldFile">, deps: IntentDispatchDeps): void {
|
|
414
|
+
const file = deps.rows[deps.cursor]?.file;
|
|
415
|
+
|
|
416
|
+
if (!file || !deps.controller.isFileCollapsed(file)) return;
|
|
417
|
+
deps.controller.setFileCollapsed(file, false);
|
|
418
|
+
// drop the cursor onto the file's first code line now that its body is back
|
|
419
|
+
const firstCode = deps.controller.rows().findIndex((row) => row.file === file && isCodeRow(row));
|
|
420
|
+
|
|
421
|
+
if (firstCode >= 0) deps.setCursor(firstCode);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function handleToggleDiffView(
|
|
425
|
+
_intent: IntentOfType<"toggleDiffView">,
|
|
426
|
+
deps: IntentDispatchDeps,
|
|
427
|
+
): void {
|
|
428
|
+
deps.toggleDiffView();
|
|
429
|
+
}
|
|
430
|
+
|
|
362
431
|
function handleRestoreCuration(
|
|
363
432
|
_intent: IntentOfType<"restoreCuration">,
|
|
364
433
|
deps: IntentDispatchDeps,
|
|
@@ -502,31 +571,6 @@ function handleDismissCompletion(
|
|
|
502
571
|
deps.controller.dismissCompletion();
|
|
503
572
|
}
|
|
504
573
|
|
|
505
|
-
function handleCycleReviewPanel(
|
|
506
|
-
_intent: IntentOfType<"cycleReviewPanel">,
|
|
507
|
-
deps: IntentDispatchDeps,
|
|
508
|
-
): void {
|
|
509
|
-
const next = cycleReviewPanelMode(deps.reviewMode);
|
|
510
|
-
|
|
511
|
-
deps.setReviewMode(next);
|
|
512
|
-
deps.controller.saveReviewPanel({ mode: next });
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function handleResizeReviewPanel(
|
|
516
|
-
intent: IntentOfType<"resizeReviewPanel">,
|
|
517
|
-
deps: IntentDispatchDeps,
|
|
518
|
-
): void {
|
|
519
|
-
if (deps.reviewMode !== "expanded") return;
|
|
520
|
-
const next = resolveReviewWidth(
|
|
521
|
-
deps.reviewWidth + intent.direction * REVIEW_RESIZE_STEP,
|
|
522
|
-
deps.terminalWidth,
|
|
523
|
-
);
|
|
524
|
-
|
|
525
|
-
deps.reviewWidthRef.current = next;
|
|
526
|
-
deps.setReviewWidth(next);
|
|
527
|
-
deps.controller.saveReviewPanel({ width: next });
|
|
528
|
-
}
|
|
529
|
-
|
|
530
574
|
function confirmTreePrompt(
|
|
531
575
|
mode: Extract<Mode, { type: "treePrompt" }>,
|
|
532
576
|
deps: IntentDispatchDeps,
|
|
@@ -613,6 +657,9 @@ const intentHandlers: IntentHandlers = {
|
|
|
613
657
|
cut: handleCut,
|
|
614
658
|
rejectHunk: handleRejectHunk,
|
|
615
659
|
rejectChange: handleRejectChange,
|
|
660
|
+
foldFile: handleFoldFile,
|
|
661
|
+
unfoldFile: handleUnfoldFile,
|
|
662
|
+
toggleDiffView: handleToggleDiffView,
|
|
616
663
|
restoreCuration: handleRestoreCuration,
|
|
617
664
|
edit: handleEdit,
|
|
618
665
|
editCard: handleEditCard,
|
|
@@ -631,8 +678,6 @@ const intentHandlers: IntentHandlers = {
|
|
|
631
678
|
finishReview: handleFinishReview,
|
|
632
679
|
optInAutoClose: handleOptInAutoClose,
|
|
633
680
|
dismissCompletion: handleDismissCompletion,
|
|
634
|
-
cycleReviewPanel: handleCycleReviewPanel,
|
|
635
|
-
resizeReviewPanel: handleResizeReviewPanel,
|
|
636
681
|
toggleTree: handleToggleTree,
|
|
637
682
|
treeMove: handleTreeMove,
|
|
638
683
|
treeGo: handleTreeGo,
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -23,7 +23,7 @@ describe("grammar-layer resolution", () => {
|
|
|
23
23
|
["g", true, "bottom"],
|
|
24
24
|
["v", false, "span"],
|
|
25
25
|
["c", false, "comment"],
|
|
26
|
-
["s", false,
|
|
26
|
+
["s", false, "split_diff"],
|
|
27
27
|
["x", false, "cut"],
|
|
28
28
|
["e", false, "edit"],
|
|
29
29
|
["n", false, "next_annotation"],
|