@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.68
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/ghostty-terminal.test.ts +2 -1
- package/src/ghostty-terminal.ts +27 -4
- 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 +15 -3
- package/src/test-support.ts +66 -15
- 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
|
+
}
|
|
@@ -21,10 +21,11 @@ describe("ghostty-terminal FFI", () => {
|
|
|
21
21
|
term.write(new TextEncoder().encode("hi\x1b[1;32mGO\x1b[0m"));
|
|
22
22
|
const cells = [0, 1, 2, 3].map((x) => term.readCell(x, 0));
|
|
23
23
|
|
|
24
|
-
// Assert - glyphs decode, and the green run is bold on ANSI palette index 2
|
|
24
|
+
// Assert - glyphs decode, the row reads as text, and the green run is bold on ANSI palette index 2
|
|
25
25
|
expect(
|
|
26
26
|
cells.map((c) => (c?.codepoint ? String.fromCodePoint(c.codepoint) : " ")).join(""),
|
|
27
27
|
).toBe("hiGO");
|
|
28
|
+
expect(term.rowText(0, 80)).toBe("hiGO");
|
|
28
29
|
expect(cells[2]!.bold).toBe(true);
|
|
29
30
|
expect(cells[2]!.fg).toEqual({ kind: "palette", index: 2 });
|
|
30
31
|
term.free();
|
package/src/ghostty-terminal.ts
CHANGED
|
@@ -60,25 +60,31 @@ function nativeLibraryPath(): string | null {
|
|
|
60
60
|
return existsSync(path) ? path : null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** One shared load of the VT library; null when no dylib ships or it failed to load. */
|
|
64
|
+
let ghosttyFactory: GhosttyTerminalFactory | null | undefined;
|
|
65
|
+
|
|
63
66
|
/**
|
|
64
67
|
* Open the Ghostty VT library for this platform, or return null when the prebuilt
|
|
65
68
|
* dylib is missing (the caller degrades to a herdr split instead of embedding).
|
|
69
|
+
* The library is opened once per process; later calls return the same factory.
|
|
66
70
|
*/
|
|
67
71
|
export function loadGhosttyTerminals(): GhosttyTerminalFactory | null {
|
|
72
|
+
if (ghosttyFactory !== undefined) return ghosttyFactory;
|
|
68
73
|
const path = nativeLibraryPath();
|
|
69
74
|
|
|
70
|
-
if (!path) return null;
|
|
75
|
+
if (!path) return (ghosttyFactory = null);
|
|
71
76
|
try {
|
|
72
77
|
const lib = dlopen(path, CVT_SYMBOLS);
|
|
73
78
|
|
|
74
|
-
|
|
79
|
+
ghosttyFactory = new GhosttyTerminalFactory(lib.symbols);
|
|
75
80
|
} catch (error) {
|
|
76
81
|
// The file exists but failed to load (bad arch, missing symbol) - a real
|
|
77
82
|
// fault, not the expected no-prebuilt case; surface it, then degrade.
|
|
78
83
|
console.error(`cueloop: failed to load ${path}:`, error);
|
|
79
|
-
|
|
80
|
-
return null;
|
|
84
|
+
ghosttyFactory = null;
|
|
81
85
|
}
|
|
86
|
+
|
|
87
|
+
return ghosttyFactory;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/** Creates `GhosttyTerminal`s that share one loaded copy of the VT library. */
|
|
@@ -146,6 +152,23 @@ export class GhosttyTerminal {
|
|
|
146
152
|
};
|
|
147
153
|
}
|
|
148
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Row `y` as text: blank cells read as spaces, the trailing half of a wide
|
|
157
|
+
* glyph is skipped, and trailing spaces are trimmed.
|
|
158
|
+
*/
|
|
159
|
+
rowText(y: number, cols: number): string {
|
|
160
|
+
let line = "";
|
|
161
|
+
|
|
162
|
+
for (let x = 0; x < cols; x++) {
|
|
163
|
+
const cell = this.readCell(x, y);
|
|
164
|
+
|
|
165
|
+
if (cell === null || cell.width === 2) continue;
|
|
166
|
+
line += cell.codepoint === 0 ? " " : String.fromCodePoint(cell.codepoint);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return line.trimEnd();
|
|
170
|
+
}
|
|
171
|
+
|
|
149
172
|
/** The cursor's current viewport position and visibility. */
|
|
150
173
|
readCursor(): GhosttyCursor {
|
|
151
174
|
if (this.freed) return { x: 0, y: 0, visible: false };
|
|
@@ -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
|