@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.91
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1406 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +787 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- 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 +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +29 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +78 -0
- package/src/launch-layout.ts +62 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Annotation } from "@cueloop/schema";
|
|
3
|
+
import {
|
|
4
|
+
commentCountsByFile,
|
|
5
|
+
diffRowText,
|
|
6
|
+
fileChangeCounts,
|
|
7
|
+
marksByRows,
|
|
8
|
+
type DiffRow,
|
|
9
|
+
} from "../view-diff";
|
|
10
|
+
import { fixtureDiffSession } from "./story-fixtures";
|
|
11
|
+
import type { IntralineRun } from "../diff-intraline";
|
|
12
|
+
import type { SyntaxSpan } from "../diff-syntax";
|
|
13
|
+
import { DARK } from "../theme";
|
|
14
|
+
import { coloredRowSpans } from "./diff-content-view-layout";
|
|
15
|
+
|
|
16
|
+
function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
|
|
17
|
+
return { kind, text, file: "a.ts", ...extra };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function annotation(id: string, anchor: Annotation["anchor"]): Annotation {
|
|
21
|
+
return { id, kind: "comment", body: "note", anchor, createdAt: "2026-01-01T00:00:00Z" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("diffRowText", () => {
|
|
25
|
+
test("strips the trailing newline the patch carries", () => {
|
|
26
|
+
// Arrange / Act / Assert
|
|
27
|
+
expect(diffRowText(row("add", "const x = 1;\n"))).toBe("const x = 1;");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("fileChangeCounts", () => {
|
|
32
|
+
test("tallies added and removed lines per file, ignoring context and headers", () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
const rows = [
|
|
35
|
+
row("file", "a.ts"),
|
|
36
|
+
row("hunk", "@@"),
|
|
37
|
+
row("ctx", "unchanged"),
|
|
38
|
+
row("del", "gone"),
|
|
39
|
+
row("add", "one"),
|
|
40
|
+
row("add", "two"),
|
|
41
|
+
row("file", "b.ts", { file: "b.ts" }),
|
|
42
|
+
row("add", "only add", { file: "b.ts" }),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// Act
|
|
46
|
+
const counts = fileChangeCounts(rows);
|
|
47
|
+
|
|
48
|
+
// Assert
|
|
49
|
+
expect(counts.get("a.ts")).toEqual({ additions: 2, deletions: 1 });
|
|
50
|
+
expect(counts.get("b.ts")).toEqual({ additions: 1, deletions: 0 });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("marksByRows", () => {
|
|
55
|
+
const rows = [
|
|
56
|
+
row("file", "a.ts"),
|
|
57
|
+
row("hunk", "@@"),
|
|
58
|
+
row("ctx", "keep\n"),
|
|
59
|
+
row("add", "const items = new Map();\n"),
|
|
60
|
+
row("add", "return items;\n"),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
test("a whole-row quote (the legacy row anchor) marks that row end to end", () => {
|
|
64
|
+
// Act
|
|
65
|
+
const marks = marksByRows(
|
|
66
|
+
[annotation("a1", { quote: "return items;", prefix: "", suffix: "" })],
|
|
67
|
+
rows,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
71
|
+
expect([...marks.keys()]).toEqual([4]);
|
|
72
|
+
expect(marks.get(4)![0]).toMatchObject({ start: 0, end: 13, annotationId: "a1" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a word quote marks only its characters and carries the span", () => {
|
|
76
|
+
// Act
|
|
77
|
+
const marks = marksByRows(
|
|
78
|
+
[annotation("a1", { quote: "new Map()", prefix: "const items = ", suffix: ";" })],
|
|
79
|
+
rows,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
const mark = marks.get(3)![0]!;
|
|
84
|
+
|
|
85
|
+
expect(mark.start).toBe(14);
|
|
86
|
+
expect(mark.end).toBe(23);
|
|
87
|
+
expect(mark.span).toEqual({
|
|
88
|
+
start: { blockIndex: 3, char: 14 },
|
|
89
|
+
end: { blockIndex: 3, char: 23 },
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a quote across two rows paints a mark on each row with one shared span", () => {
|
|
94
|
+
// Arrange - the anchor a two-row drag produces: rows joined by the block separator
|
|
95
|
+
const marks = marksByRows(
|
|
96
|
+
[
|
|
97
|
+
annotation("a1", {
|
|
98
|
+
quote: "Map();\n\nreturn",
|
|
99
|
+
prefix: "const items = new ",
|
|
100
|
+
suffix: " items;",
|
|
101
|
+
blockIndex: 3,
|
|
102
|
+
start: 18,
|
|
103
|
+
end: 6,
|
|
104
|
+
endBlockIndex: 4,
|
|
105
|
+
}),
|
|
106
|
+
],
|
|
107
|
+
rows,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Assert
|
|
111
|
+
expect([...marks.keys()].toSorted()).toEqual([3, 4]);
|
|
112
|
+
expect(marks.get(3)![0]!.span).toEqual(marks.get(4)![0]!.span);
|
|
113
|
+
expect(marks.get(3)![0]).toMatchObject({ start: 18, end: 24 });
|
|
114
|
+
expect(marks.get(4)![0]).toMatchObject({ start: 0, end: 6 });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("the focused annotation wears the focus role", () => {
|
|
118
|
+
// Act
|
|
119
|
+
const marks = marksByRows(
|
|
120
|
+
[annotation("a1", { quote: "keep", prefix: "", suffix: "" })],
|
|
121
|
+
rows,
|
|
122
|
+
"a1",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
expect(marks.get(2)![0]!.role).toBe("mark-focus");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("commentCountsByFile", () => {
|
|
131
|
+
const rows = [
|
|
132
|
+
row("file", "a.ts"),
|
|
133
|
+
row("add", "const items = new Map();\n"),
|
|
134
|
+
row("file", "b.ts", { file: "b.ts" }),
|
|
135
|
+
row("add", "return other;\n", { file: "b.ts" }),
|
|
136
|
+
];
|
|
137
|
+
const annotation = (id: string, quote: string, replyTo?: string): Annotation => {
|
|
138
|
+
const comment: Annotation = {
|
|
139
|
+
id,
|
|
140
|
+
kind: "comment",
|
|
141
|
+
body: id,
|
|
142
|
+
anchor: { quote, prefix: "", suffix: "" },
|
|
143
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (replyTo !== undefined) comment.replyTo = replyTo;
|
|
147
|
+
|
|
148
|
+
return comment;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
test("counts a discussion's comments (root plus replies) toward its file", () => {
|
|
152
|
+
// Arrange - one thread on a.ts with a reply, one on b.ts
|
|
153
|
+
const session = fixtureDiffSession({
|
|
154
|
+
annotations: [
|
|
155
|
+
annotation("a1", "const items = new Map();"),
|
|
156
|
+
annotation("a1r", "const items = new Map();", "a1"),
|
|
157
|
+
annotation("b1", "return other;"),
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
const counts = commentCountsByFile(session, rows);
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
expect(counts.get("a.ts")).toBe(2);
|
|
166
|
+
expect(counts.get("b.ts")).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("coloredRowSpans", () => {
|
|
171
|
+
test("a changed word keeps the diff color while unchanged text dims", () => {
|
|
172
|
+
// Arrange - "old" is the change, " tail" unchanged, on a modified line
|
|
173
|
+
const runs: IntralineRun[] = [
|
|
174
|
+
{ text: "old", changed: true },
|
|
175
|
+
{ text: " tail", changed: false },
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
// Act
|
|
179
|
+
const spans = coloredRowSpans("old tail", runs, undefined, DARK.deletedForeground, DARK);
|
|
180
|
+
|
|
181
|
+
// Assert
|
|
182
|
+
expect(spans).toEqual([
|
|
183
|
+
{ text: "old", foreground: DARK.deletedForeground },
|
|
184
|
+
{ text: " tail", foreground: DARK.textDim },
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("syntax color applies to unchanged text when there is no intra-line run", () => {
|
|
189
|
+
// Arrange - a keyword span over the whole word, no intra-line runs
|
|
190
|
+
const syntax: SyntaxSpan[] = [{ start: 0, end: 6, group: "keyword" }];
|
|
191
|
+
|
|
192
|
+
// Act
|
|
193
|
+
const spans = coloredRowSpans("return", undefined, syntax, DARK.textMuted, DARK);
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
expect(spans).toEqual([{ text: "return", foreground: DARK.accent }]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure color logic for the diff review sheet, split out so it can be tested
|
|
3
|
+
* without rendering: a row's colored spans (syntax under the intra-line change
|
|
4
|
+
* color). DiffContentView.tsx paints marks and the caret over these per character.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Theme } from "../theme";
|
|
8
|
+
import type { IntralineRun } from "../diff-intraline";
|
|
9
|
+
import type { SyntaxSpan } from "../diff-syntax";
|
|
10
|
+
import { colorForSyntaxGroup } from "./syntax-highlight";
|
|
11
|
+
|
|
12
|
+
/** One rendered span of a row: its text and its resolved foreground color. */
|
|
13
|
+
export interface ColoredSpan {
|
|
14
|
+
text: string;
|
|
15
|
+
foreground: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Colored spans for one row, resolved per character: an intra-line changed word
|
|
20
|
+
* keeps the diff color, otherwise the syntax color applies, falling back to the
|
|
21
|
+
* dim token on the unchanged part of a modified line or the row's base color.
|
|
22
|
+
* Adjacent same-color characters coalesce so a row stays a few spans.
|
|
23
|
+
*/
|
|
24
|
+
export function coloredRowSpans(
|
|
25
|
+
text: string,
|
|
26
|
+
intralineRuns: IntralineRun[] | undefined,
|
|
27
|
+
syntaxSpans: SyntaxSpan[] | undefined,
|
|
28
|
+
baseColor: string,
|
|
29
|
+
tokens: Theme,
|
|
30
|
+
): ColoredSpan[] {
|
|
31
|
+
const changed = changedColumns(text.length, intralineRuns);
|
|
32
|
+
const syntaxColorByColumn = syntaxColorColumns(text.length, syntaxSpans, tokens);
|
|
33
|
+
const isModifiedLine = intralineRuns !== undefined;
|
|
34
|
+
|
|
35
|
+
const spans: ColoredSpan[] = [];
|
|
36
|
+
|
|
37
|
+
for (let column = 0; column < text.length; column++) {
|
|
38
|
+
const color = changed[column]
|
|
39
|
+
? baseColor
|
|
40
|
+
: (syntaxColorByColumn[column] ?? (isModifiedLine ? tokens.textDim : baseColor));
|
|
41
|
+
const previous = spans[spans.length - 1];
|
|
42
|
+
|
|
43
|
+
if (previous && previous.foreground === color) previous.text += text[column];
|
|
44
|
+
else spans.push({ text: text[column]!, foreground: color });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return spans;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Which columns are the intra-line change on a modified line. */
|
|
51
|
+
function changedColumns(length: number, intralineRuns: IntralineRun[] | undefined): boolean[] {
|
|
52
|
+
const changed = Array.from({ length }, () => false);
|
|
53
|
+
|
|
54
|
+
if (!intralineRuns) return changed;
|
|
55
|
+
let offset = 0;
|
|
56
|
+
|
|
57
|
+
for (const run of intralineRuns) {
|
|
58
|
+
if (run.changed)
|
|
59
|
+
for (let index = 0; index < run.text.length; index++) changed[offset + index] = true;
|
|
60
|
+
offset += run.text.length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return changed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The syntax color per column, or undefined where a column has no token. */
|
|
67
|
+
function syntaxColorColumns(
|
|
68
|
+
length: number,
|
|
69
|
+
syntaxSpans: SyntaxSpan[] | undefined,
|
|
70
|
+
tokens: Theme,
|
|
71
|
+
): Array<string | undefined> {
|
|
72
|
+
const colors: Array<string | undefined> = Array.from({ length }, () => undefined);
|
|
73
|
+
|
|
74
|
+
for (const span of syntaxSpans ?? []) {
|
|
75
|
+
const color = colorForSyntaxGroup(span.group, tokens);
|
|
76
|
+
|
|
77
|
+
if (!color) continue;
|
|
78
|
+
for (let column = span.start; column < span.end && column < length; column++) {
|
|
79
|
+
colors[column] = color;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return colors;
|
|
84
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
addTab,
|
|
4
|
+
changesTab,
|
|
5
|
+
closeTab,
|
|
6
|
+
containsGroup,
|
|
7
|
+
fileTab,
|
|
8
|
+
firstGroupId,
|
|
9
|
+
makeGroup,
|
|
10
|
+
splitGroup,
|
|
11
|
+
type EditorBranch,
|
|
12
|
+
type EditorGroup,
|
|
13
|
+
type EditorNode,
|
|
14
|
+
} from "./editor-grid";
|
|
15
|
+
|
|
16
|
+
function asBranch(node: EditorNode | null): EditorBranch {
|
|
17
|
+
if (node === null || node.type !== "branch") throw new Error("expected a branch node");
|
|
18
|
+
return node;
|
|
19
|
+
}
|
|
20
|
+
function asGroup(node: EditorNode | null): EditorGroup {
|
|
21
|
+
if (node === null || node.type !== "group") throw new Error("expected a group node");
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("editor grid", () => {
|
|
26
|
+
test("a fresh group holds its tabs with the first active", () => {
|
|
27
|
+
const tab = changesTab();
|
|
28
|
+
const group = makeGroup([tab]);
|
|
29
|
+
expect(group.type).toBe("group");
|
|
30
|
+
expect(group.activeTabId).toBe(tab.id);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("adding a tab focuses it", () => {
|
|
34
|
+
const group = makeGroup([changesTab()]);
|
|
35
|
+
const file = fileTab("App.tsx", "src/App.tsx", "diff");
|
|
36
|
+
const next = asGroup(addTab(group, group.id, file));
|
|
37
|
+
expect(next.tabs).toHaveLength(2);
|
|
38
|
+
expect(next.activeTabId).toBe(file.id);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("splitting right makes a horizontal branch and focuses the new group", () => {
|
|
42
|
+
const group = makeGroup([fileTab("App.tsx", "src/App.tsx", "diff")]);
|
|
43
|
+
const { tree, focusGroupId } = splitGroup(group, group.id, "right");
|
|
44
|
+
expect(tree.type).toBe("branch");
|
|
45
|
+
const branch = asBranch(tree);
|
|
46
|
+
expect(branch.orientation).toBe("horizontal");
|
|
47
|
+
expect(branch.children).toHaveLength(2);
|
|
48
|
+
// the new group is the second child (right edge) and takes focus
|
|
49
|
+
expect(branch.children[1]!.id).toBe(focusGroupId);
|
|
50
|
+
expect(firstGroupId(tree)).toBe(group.id);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("splitting up makes a vertical branch with the new group first", () => {
|
|
54
|
+
const group = makeGroup([fileTab("a.ts", "a.ts", "contents")]);
|
|
55
|
+
const { tree, focusGroupId } = splitGroup(group, group.id, "up");
|
|
56
|
+
const branch = asBranch(tree);
|
|
57
|
+
expect(branch.orientation).toBe("vertical");
|
|
58
|
+
expect(branch.children[0]!.id).toBe(focusGroupId);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("closing the last tab of a split collapses the branch back to one group", () => {
|
|
62
|
+
const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
|
|
63
|
+
const { tree } = splitGroup(group, group.id, "right");
|
|
64
|
+
const branch = asBranch(tree);
|
|
65
|
+
const rightGroup = asGroup(branch.children[1]!);
|
|
66
|
+
const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
|
|
67
|
+
expect(collapsed?.type).toBe("group");
|
|
68
|
+
expect(asGroup(collapsed).id).toBe(group.id);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("closing the only tab of the whole grid returns null", () => {
|
|
72
|
+
const tab = changesTab();
|
|
73
|
+
const group = makeGroup([tab]);
|
|
74
|
+
expect(closeTab(group, group.id, tab.id)).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("containsGroup finds a live group and misses a pruned one", () => {
|
|
78
|
+
const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
|
|
79
|
+
const { tree } = splitGroup(group, group.id, "right");
|
|
80
|
+
const branch = asBranch(tree);
|
|
81
|
+
const rightGroup = asGroup(branch.children[1]!);
|
|
82
|
+
expect(containsGroup(tree, group.id)).toBe(true);
|
|
83
|
+
const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
|
|
84
|
+
expect(containsGroup(asGroup(collapsed), rightGroup.id)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The Changes pane's editor layout: a tree of nodes where a leaf is an editor group (a tab strip plus
|
|
2
|
+
// its one active tab) and a branch splits child nodes along an orientation. Splitting an editor group
|
|
3
|
+
// moves its active tab into a new group; a group is pruned when its last tab closes, collapsing the
|
|
4
|
+
// branch that held it.
|
|
5
|
+
|
|
6
|
+
/** One editor in a group's tab strip: the Changes tab (the whole diff), a per-file tab, or Welcome. */
|
|
7
|
+
export interface EditorTab {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: "changes" | "file" | "welcome";
|
|
10
|
+
label: string;
|
|
11
|
+
/** Repo-relative path for a file tab; absent for the Changes and Welcome tabs. */
|
|
12
|
+
path?: string;
|
|
13
|
+
/** A file tab shows either a single-file diff or the file's plain contents. */
|
|
14
|
+
fileView?: "diff" | "contents";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A leaf of the grid: one editor group holding tabs, with the active tab id. */
|
|
18
|
+
export interface EditorGroup {
|
|
19
|
+
type: "group";
|
|
20
|
+
id: string;
|
|
21
|
+
tabs: EditorTab[];
|
|
22
|
+
activeTabId: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A branch of the grid: children laid out along one orientation (horizontal = side by side). */
|
|
26
|
+
export interface EditorBranch {
|
|
27
|
+
type: "branch";
|
|
28
|
+
id: string;
|
|
29
|
+
orientation: "horizontal" | "vertical";
|
|
30
|
+
children: EditorNode[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type EditorNode = EditorGroup | EditorBranch;
|
|
34
|
+
|
|
35
|
+
/** Which edge a split places the new group on; Left/Right split horizontally, Up/Down vertically. */
|
|
36
|
+
export type SplitDirection = "left" | "right" | "up" | "down";
|
|
37
|
+
|
|
38
|
+
/** A split's outcome: the new grid and the id of the group that should take focus. */
|
|
39
|
+
export interface SplitResult {
|
|
40
|
+
tree: EditorNode;
|
|
41
|
+
focusGroupId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const freshId = (): string => crypto.randomUUID();
|
|
45
|
+
|
|
46
|
+
/** A new editor group holding the given tabs, with the first tab active. */
|
|
47
|
+
export function makeGroup(tabs: EditorTab[]): EditorGroup {
|
|
48
|
+
return { type: "group", id: freshId(), tabs, activeTabId: tabs[0]?.id ?? null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The Changes tab: the whole diff in one scroll, the disposable default of the Changes pane. */
|
|
52
|
+
export function changesTab(): EditorTab {
|
|
53
|
+
return { id: freshId(), kind: "changes", label: "Changes" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The Welcome tab: the getting-started surface, a disposable tab shown before any thread is opened. */
|
|
57
|
+
export function welcomeTab(): EditorTab {
|
|
58
|
+
return { id: freshId(), kind: "welcome", label: "Welcome" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A file tab, opened from a tree; a diff view for a changed file, plain contents otherwise. */
|
|
62
|
+
export function fileTab(label: string, path: string, fileView: "diff" | "contents"): EditorTab {
|
|
63
|
+
return { id: freshId(), kind: "file", label, path, fileView };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The id of the first editor group in reading order; the fallback focus target. */
|
|
67
|
+
export function firstGroupId(node: EditorNode): string {
|
|
68
|
+
return node.type === "group" ? node.id : firstGroupId(node.children[0]!);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether a group id still exists in the grid; used to re-home focus after a prune. */
|
|
72
|
+
export function containsGroup(node: EditorNode, groupId: string): boolean {
|
|
73
|
+
if (node.type === "group") return node.id === groupId;
|
|
74
|
+
return node.children.some((child) => containsGroup(child, groupId));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function replaceGroup(
|
|
78
|
+
node: EditorNode,
|
|
79
|
+
id: string,
|
|
80
|
+
fn: (group: EditorGroup) => EditorNode,
|
|
81
|
+
): EditorNode {
|
|
82
|
+
if (node.type === "group") return node.id === id ? fn(node) : node;
|
|
83
|
+
return { ...node, children: node.children.map((child) => replaceGroup(child, id, fn)) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Make a tab the active one in its group. */
|
|
87
|
+
export function activateTab(node: EditorNode, groupId: string, tabId: string): EditorNode {
|
|
88
|
+
return replaceGroup(node, groupId, (group) => ({ ...group, activeTabId: tabId }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Append a tab to a group and focus it. */
|
|
92
|
+
export function addTab(node: EditorNode, groupId: string, tab: EditorTab): EditorNode {
|
|
93
|
+
return replaceGroup(node, groupId, (group) => ({
|
|
94
|
+
...group,
|
|
95
|
+
tabs: [...group.tabs, tab],
|
|
96
|
+
activeTabId: tab.id,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drop empty groups and collapse a branch that ends up with one child; null when nothing remains. */
|
|
101
|
+
export function pruneEmpty(node: EditorNode): EditorNode | null {
|
|
102
|
+
if (node.type === "group") return node.tabs.length > 0 ? node : null;
|
|
103
|
+
const kept = node.children.map(pruneEmpty).filter((child): child is EditorNode => child !== null);
|
|
104
|
+
if (kept.length === 0) return null;
|
|
105
|
+
if (kept.length === 1) return kept[0]!;
|
|
106
|
+
return { ...node, children: kept };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Close a tab; returns the pruned tree, or null when the last tab of the whole grid is closed. */
|
|
110
|
+
export function closeTab(node: EditorNode, groupId: string, tabId: string): EditorNode | null {
|
|
111
|
+
const next = replaceGroup(node, groupId, (group) => {
|
|
112
|
+
const tabs = group.tabs.filter((tab) => tab.id !== tabId);
|
|
113
|
+
const activeTabId = group.activeTabId === tabId ? (tabs[0]?.id ?? null) : group.activeTabId;
|
|
114
|
+
return { ...group, tabs, activeTabId };
|
|
115
|
+
});
|
|
116
|
+
return pruneEmpty(next);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Split a group's active tab into a new group on the given edge; returns the tree and the new focus. */
|
|
120
|
+
export function splitGroup(
|
|
121
|
+
node: EditorNode,
|
|
122
|
+
groupId: string,
|
|
123
|
+
direction: SplitDirection,
|
|
124
|
+
): SplitResult {
|
|
125
|
+
let focusGroupId = groupId;
|
|
126
|
+
const tree = replaceGroup(node, groupId, (group) => {
|
|
127
|
+
const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
|
|
128
|
+
if (active === undefined) return group;
|
|
129
|
+
const movedTab: EditorTab = { ...active, id: freshId() };
|
|
130
|
+
const newGroup = makeGroup([movedTab]);
|
|
131
|
+
focusGroupId = newGroup.id;
|
|
132
|
+
const orientation = direction === "left" || direction === "right" ? "horizontal" : "vertical";
|
|
133
|
+
const newFirst = direction === "left" || direction === "up";
|
|
134
|
+
const children = newFirst ? [newGroup, group] : [group, newGroup];
|
|
135
|
+
return { type: "branch", id: freshId(), orientation, children };
|
|
136
|
+
});
|
|
137
|
+
return { tree, focusGroupId };
|
|
138
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { DiffFileContents } from "@cueloop/schema";
|
|
3
|
+
import { buildFileTree } from "./file-tree";
|
|
4
|
+
|
|
5
|
+
function file(path: string, status: DiffFileContents["status"] = "modified"): DiffFileContents {
|
|
6
|
+
return { path, oldContents: "a", newContents: "b", status };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("buildFileTree", () => {
|
|
10
|
+
test("nests files under their directories and keeps the full path as the leaf id", () => {
|
|
11
|
+
// Arrange
|
|
12
|
+
const files = [file("src/app/main.ts"), file("src/app/util.ts"), file("README.md")];
|
|
13
|
+
|
|
14
|
+
// Act
|
|
15
|
+
const tree = buildFileTree(files);
|
|
16
|
+
|
|
17
|
+
// Assert - one src folder over one app folder over two leaves, plus a root file
|
|
18
|
+
expect(tree.map((node) => node.label)).toEqual(["src", "README.md"]);
|
|
19
|
+
const app = tree[0]!.children![0]!;
|
|
20
|
+
|
|
21
|
+
expect(app.label).toBe("app");
|
|
22
|
+
expect(app.children!.map((leaf) => leaf.id)).toEqual(["src/app/main.ts", "src/app/util.ts"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("carries the git status onto each leaf so the tree can tint it", () => {
|
|
26
|
+
// Act
|
|
27
|
+
const tree = buildFileTree([file("a.ts", "added"), file("b.ts", "deleted")]);
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
30
|
+
expect(tree.map((leaf) => leaf.status)).toEqual(["added", "deleted"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("merges files that share a directory into one folder node", () => {
|
|
34
|
+
// Act
|
|
35
|
+
const tree = buildFileTree([file("pkg/one.ts"), file("pkg/two.ts")]);
|
|
36
|
+
|
|
37
|
+
// Assert
|
|
38
|
+
expect(tree).toHaveLength(1);
|
|
39
|
+
expect(tree[0]!.children).toHaveLength(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns an empty tree for no changes", () => {
|
|
43
|
+
expect(buildFileTree([])).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("badges a leaf with its comment count, and leaves an uncommented file bare", () => {
|
|
47
|
+
// Act
|
|
48
|
+
const tree = buildFileTree(
|
|
49
|
+
[file("a.ts"), file("b.ts")],
|
|
50
|
+
new Map([
|
|
51
|
+
["a.ts", 3],
|
|
52
|
+
["b.ts", 0],
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Assert - a.ts shows the dot-and-count, b.ts (0) shows nothing
|
|
57
|
+
expect(tree[0]!.badge).toBe("● 3");
|
|
58
|
+
expect(tree[1]!.badge).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a nested file tree from a diff's changed files, so the Changes column
|
|
3
|
+
* can browse them by directory. Each leaf keeps its repo-relative path as the id
|
|
4
|
+
* (what the diff rows are keyed by) and carries the git status for its tint.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DiffFileContents, DiffFileStatus } from "@cueloop/schema";
|
|
8
|
+
import type { GitStatus, TreeNode } from "./primitives/tree-model";
|
|
9
|
+
|
|
10
|
+
/** The diff file statuses are a subset of the tree's git statuses. */
|
|
11
|
+
function gitStatusOf(status: DiffFileStatus): GitStatus {
|
|
12
|
+
return status;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface MutableTreeNode {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
status?: GitStatus;
|
|
19
|
+
badge?: string;
|
|
20
|
+
children?: Map<string, MutableTreeNode>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function freeze(node: MutableTreeNode): TreeNode {
|
|
24
|
+
if (node.children === undefined) {
|
|
25
|
+
const leaf: TreeNode = { id: node.id, label: node.label, status: node.status };
|
|
26
|
+
|
|
27
|
+
if (node.badge !== undefined) leaf.badge = node.badge;
|
|
28
|
+
|
|
29
|
+
return leaf;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
id: node.id,
|
|
34
|
+
label: node.label,
|
|
35
|
+
children: [...node.children.values()].map(freeze),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A file's comment count as a leaf badge, or undefined when it carries none. */
|
|
40
|
+
function commentBadge(
|
|
41
|
+
counts: ReadonlyMap<string, number> | undefined,
|
|
42
|
+
path: string,
|
|
43
|
+
): string | undefined {
|
|
44
|
+
const count = counts?.get(path);
|
|
45
|
+
|
|
46
|
+
return count ? `● ${count}` : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Group changed files into a directory tree; leaves are keyed by their full path. A leaf whose
|
|
51
|
+
* file carries comments shows a dot-and-count badge, so feedback is visible even with its tab closed.
|
|
52
|
+
*/
|
|
53
|
+
export function buildFileTree(
|
|
54
|
+
files: readonly DiffFileContents[],
|
|
55
|
+
commentCounts?: ReadonlyMap<string, number>,
|
|
56
|
+
): TreeNode[] {
|
|
57
|
+
const roots = new Map<string, MutableTreeNode>();
|
|
58
|
+
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
const segments = file.path.split("/").filter((segment) => segment.length > 0);
|
|
61
|
+
let level = roots;
|
|
62
|
+
let prefix = "";
|
|
63
|
+
|
|
64
|
+
segments.forEach((segment, index) => {
|
|
65
|
+
prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment;
|
|
66
|
+
const isLeaf = index === segments.length - 1;
|
|
67
|
+
let node = level.get(segment);
|
|
68
|
+
|
|
69
|
+
if (node === undefined) {
|
|
70
|
+
node = isLeaf
|
|
71
|
+
? {
|
|
72
|
+
id: file.path,
|
|
73
|
+
label: segment,
|
|
74
|
+
status: gitStatusOf(file.status),
|
|
75
|
+
badge: commentBadge(commentCounts, file.path),
|
|
76
|
+
}
|
|
77
|
+
: { id: prefix, label: segment, children: new Map() };
|
|
78
|
+
level.set(segment, node);
|
|
79
|
+
}
|
|
80
|
+
if (!isLeaf) level = node.children!;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return [...roots.values()].map(freeze);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Group plain repo-relative paths (git ls-files) into a directory tree; leaves keep their full path. */
|
|
88
|
+
export function buildPathTree(paths: readonly string[]): TreeNode[] {
|
|
89
|
+
const roots = new Map<string, MutableTreeNode>();
|
|
90
|
+
|
|
91
|
+
for (const path of paths) {
|
|
92
|
+
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
93
|
+
let level = roots;
|
|
94
|
+
let prefix = "";
|
|
95
|
+
|
|
96
|
+
segments.forEach((segment, index) => {
|
|
97
|
+
prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment;
|
|
98
|
+
const isLeaf = index === segments.length - 1;
|
|
99
|
+
let node = level.get(segment);
|
|
100
|
+
|
|
101
|
+
if (node === undefined) {
|
|
102
|
+
node = isLeaf
|
|
103
|
+
? { id: path, label: segment }
|
|
104
|
+
: { id: prefix, label: segment, children: new Map() };
|
|
105
|
+
level.set(segment, node);
|
|
106
|
+
}
|
|
107
|
+
if (!isLeaf) level = node.children!;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [...roots.values()].map(freeze);
|
|
112
|
+
}
|