@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70
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 +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1039 -799
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +461 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -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 +126 -0
- 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 +89 -0
- package/src/components/AppShell.tsx +286 -0
- package/src/components/AppShell.zoom.test.tsx +94 -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 +39 -0
- package/src/components/ChangesColumn.tsx +171 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -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 +859 -0
- 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 +118 -0
- package/src/components/EditorGrid.tsx +368 -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 +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -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 +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -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 +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -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 +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -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/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -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 +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -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/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 +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -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 +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -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/truncate-text.ts +7 -0
- package/src/config.test.ts +461 -31
- package/src/config.ts +389 -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 +165 -0
- package/src/diff-intraline.ts +146 -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/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -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 +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -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 +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -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 +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- 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 -312
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { intralineRunsByRow, wordLevelChanges, type IntralineRun } from "./diff-intraline";
|
|
3
|
+
import type { DiffRow } from "./view-diff";
|
|
4
|
+
|
|
5
|
+
function line(kind: DiffRow["kind"], text: string): DiffRow {
|
|
6
|
+
return { kind, text, file: "a.ts" };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function joinText(runs: IntralineRun[]): string {
|
|
10
|
+
return runs.map((run) => run.text).join("");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function changedText(runs: IntralineRun[]): string {
|
|
14
|
+
return runs
|
|
15
|
+
.filter((run) => run.changed)
|
|
16
|
+
.map((run) => run.text)
|
|
17
|
+
.join("");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("wordLevelChanges", () => {
|
|
21
|
+
test("marks only the changed word and stays whitespace-lossless", () => {
|
|
22
|
+
// Arrange
|
|
23
|
+
const oldText = "const foo = 1";
|
|
24
|
+
const newText = "const foo = 2";
|
|
25
|
+
|
|
26
|
+
// Act
|
|
27
|
+
const changes = wordLevelChanges(oldText, newText);
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
30
|
+
expect(
|
|
31
|
+
changes
|
|
32
|
+
.filter((change) => change.kind === "common")
|
|
33
|
+
.map((change) => change.text)
|
|
34
|
+
.join(""),
|
|
35
|
+
).toBe("const foo = ");
|
|
36
|
+
expect(
|
|
37
|
+
changes
|
|
38
|
+
.filter((change) => change.kind === "removed")
|
|
39
|
+
.map((change) => change.text)
|
|
40
|
+
.join(""),
|
|
41
|
+
).toBe("1");
|
|
42
|
+
expect(
|
|
43
|
+
changes
|
|
44
|
+
.filter((change) => change.kind === "added")
|
|
45
|
+
.map((change) => change.text)
|
|
46
|
+
.join(""),
|
|
47
|
+
).toBe("2");
|
|
48
|
+
// old text reconstructs from common + removed, new text from common + added
|
|
49
|
+
expect(
|
|
50
|
+
changes
|
|
51
|
+
.filter((change) => change.kind !== "added")
|
|
52
|
+
.map((change) => change.text)
|
|
53
|
+
.join(""),
|
|
54
|
+
).toBe(oldText);
|
|
55
|
+
expect(
|
|
56
|
+
changes
|
|
57
|
+
.filter((change) => change.kind !== "removed")
|
|
58
|
+
.map((change) => change.text)
|
|
59
|
+
.join(""),
|
|
60
|
+
).toBe(newText);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("intralineRunsByRow", () => {
|
|
65
|
+
test("pairs a deletion with its addition and highlights the changed word on each side", () => {
|
|
66
|
+
// Arrange
|
|
67
|
+
const rows = [
|
|
68
|
+
line("ctx", "unchanged"),
|
|
69
|
+
line("del", "const foo = 1"),
|
|
70
|
+
line("add", "const foo = 2"),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Act
|
|
74
|
+
const runsByRow = intralineRunsByRow(rows);
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
77
|
+
const deletionRuns = runsByRow.get(1)!;
|
|
78
|
+
const additionRuns = runsByRow.get(2)!;
|
|
79
|
+
|
|
80
|
+
expect(joinText(deletionRuns)).toBe("const foo = 1");
|
|
81
|
+
expect(joinText(additionRuns)).toBe("const foo = 2");
|
|
82
|
+
expect(changedText(deletionRuns)).toBe("1");
|
|
83
|
+
expect(changedText(additionRuns)).toBe("2");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("omits context rows and rows with no counterpart", () => {
|
|
87
|
+
// Arrange
|
|
88
|
+
const rows = [line("ctx", "keep me"), line("add", "brand new line")];
|
|
89
|
+
|
|
90
|
+
// Act
|
|
91
|
+
const runsByRow = intralineRunsByRow(rows);
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
94
|
+
expect(runsByRow.size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("pairs only min(deletions, additions); the surplus stays a plain row", () => {
|
|
98
|
+
// Arrange - two deletions, one addition
|
|
99
|
+
const rows = [line("del", "alpha one"), line("del", "beta two"), line("add", "alpha ONE")];
|
|
100
|
+
|
|
101
|
+
// Act
|
|
102
|
+
const runsByRow = intralineRunsByRow(rows);
|
|
103
|
+
|
|
104
|
+
// Assert
|
|
105
|
+
expect(runsByRow.has(0)).toBe(true); // first deletion pairs with the addition
|
|
106
|
+
expect(runsByRow.has(1)).toBe(false); // second deletion has no counterpart
|
|
107
|
+
expect(runsByRow.has(2)).toBe(true);
|
|
108
|
+
expect(changedText(runsByRow.get(0)!)).toBe("one");
|
|
109
|
+
expect(changedText(runsByRow.get(2)!)).toBe("ONE");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a fully rewritten single line marks the whole line changed on both sides", () => {
|
|
113
|
+
// Arrange - a lone deletion opposite a lone addition is unambiguously one edit
|
|
114
|
+
const rows = [line("del", "aaa"), line("add", "zzz")];
|
|
115
|
+
|
|
116
|
+
// Act
|
|
117
|
+
const runsByRow = intralineRunsByRow(rows);
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
expect(changedText(runsByRow.get(0)!)).toBe("aaa");
|
|
121
|
+
expect(changedText(runsByRow.get(1)!)).toBe("zzz");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("in a shifted block, alignment matches the real counterpart, not by position", () => {
|
|
125
|
+
// Arrange - "beta gamma" is modified and an unrelated line sits at each end;
|
|
126
|
+
// positional pairing would word-diff "alpha" against "beta gamma delta"
|
|
127
|
+
const rows = [
|
|
128
|
+
line("del", "alpha"),
|
|
129
|
+
line("del", "beta gamma"),
|
|
130
|
+
line("add", "beta gamma delta"),
|
|
131
|
+
line("add", "zulu"),
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
// Act
|
|
135
|
+
const runsByRow = intralineRunsByRow(rows);
|
|
136
|
+
|
|
137
|
+
// Assert
|
|
138
|
+
// the modified pair is found and word-diffed
|
|
139
|
+
expect(runsByRow.has(1)).toBe(true); // "beta gamma"
|
|
140
|
+
expect(runsByRow.has(2)).toBe(true); // "beta gamma delta"
|
|
141
|
+
expect(changedText(runsByRow.get(2)!).trim()).toBe("delta");
|
|
142
|
+
// the genuinely unrelated lines stay whole-line
|
|
143
|
+
expect(runsByRow.has(0)).toBe(false); // "alpha"
|
|
144
|
+
expect(runsByRow.has(3)).toBe(false); // "zulu"
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("in a multi-line block, related positional pairs still highlight the changed word", () => {
|
|
148
|
+
// Arrange - a same-shape 2x2 edit: each row lines up with its counterpart
|
|
149
|
+
const rows = [
|
|
150
|
+
line("del", "one two"),
|
|
151
|
+
line("del", "three four"),
|
|
152
|
+
line("add", "one TWO"),
|
|
153
|
+
line("add", "three FOUR"),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
// Act
|
|
157
|
+
const runsByRow = intralineRunsByRow(rows);
|
|
158
|
+
|
|
159
|
+
// Assert
|
|
160
|
+
expect(changedText(runsByRow.get(0)!)).toBe("two");
|
|
161
|
+
expect(changedText(runsByRow.get(1)!)).toBe("four");
|
|
162
|
+
expect(changedText(runsByRow.get(2)!)).toBe("TWO");
|
|
163
|
+
expect(changedText(runsByRow.get(3)!)).toBe("FOUR");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intra-line word diff: the char-precise "what changed within this line" engine
|
|
3
|
+
* shared by the plan tracked-changes view and the diff review sheet. Backed by
|
|
4
|
+
* jsdiff diffWordsWithSpace, so it is punctuation-aware and whitespace-lossless:
|
|
5
|
+
* the run values always concatenate back to the exact inputs, which keeps quote
|
|
6
|
+
* anchors intact. Supersedes the coarse LCS wordDiff.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { diffWordsWithSpace } from "diff";
|
|
10
|
+
import { lcsDiff } from "@cueloop/schema";
|
|
11
|
+
import type { DiffRow } from "./view-diff";
|
|
12
|
+
|
|
13
|
+
export type WordChangeKind = "common" | "added" | "removed";
|
|
14
|
+
|
|
15
|
+
/** One word-level change between two strings; text is verbatim (lossless). */
|
|
16
|
+
export interface WordChange {
|
|
17
|
+
text: string;
|
|
18
|
+
kind: WordChangeKind;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Word-level changes between two strings, whitespace-lossless and ordered. */
|
|
22
|
+
export function wordLevelChanges(oldText: string, newText: string): WordChange[] {
|
|
23
|
+
return diffWordsWithSpace(oldText, newText).map((change) => ({
|
|
24
|
+
text: change.value,
|
|
25
|
+
kind: change.added ? "added" : change.removed ? "removed" : "common",
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One rendered segment of a diff row: changed marks the intra-line change. */
|
|
30
|
+
export interface IntralineRun {
|
|
31
|
+
text: string;
|
|
32
|
+
changed: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripTrailingNewline(text: string): string {
|
|
36
|
+
return text.replace(/\n$/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Char-precise intra-line runs for modified diff rows. Within a change block - a
|
|
41
|
+
* run of deletions immediately followed by additions - lines are aligned to their
|
|
42
|
+
* real counterpart (see alignedPairs) and each matched pair is word-diffed so the
|
|
43
|
+
* removed/added words are marked changed. Rows with no counterpart (pure add, pure
|
|
44
|
+
* delete, or an unmatched line in a shifted block) are omitted, so the caller
|
|
45
|
+
* renders those as a single run. Each row's runs concatenate back to that row's text.
|
|
46
|
+
*/
|
|
47
|
+
export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]> {
|
|
48
|
+
const runsByRow = new Map<number, IntralineRun[]>();
|
|
49
|
+
let index = 0;
|
|
50
|
+
|
|
51
|
+
while (index < rows.length) {
|
|
52
|
+
if (rows[index]!.kind !== "del") {
|
|
53
|
+
index++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const deletionStart = index;
|
|
57
|
+
|
|
58
|
+
while (index < rows.length && rows[index]!.kind === "del") index++;
|
|
59
|
+
const additionStart = index;
|
|
60
|
+
|
|
61
|
+
while (index < rows.length && rows[index]!.kind === "add") index++;
|
|
62
|
+
const deletionTexts = rows
|
|
63
|
+
.slice(deletionStart, additionStart)
|
|
64
|
+
.map((row) => stripTrailingNewline(row.text));
|
|
65
|
+
const additionTexts = rows
|
|
66
|
+
.slice(additionStart, index)
|
|
67
|
+
.map((row) => stripTrailingNewline(row.text));
|
|
68
|
+
|
|
69
|
+
for (const [deletionOffset, additionOffset] of alignedPairs(deletionTexts, additionTexts)) {
|
|
70
|
+
const changes = wordLevelChanges(
|
|
71
|
+
deletionTexts[deletionOffset]!,
|
|
72
|
+
additionTexts[additionOffset]!,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
runsByRow.set(deletionStart + deletionOffset, sideRuns(changes, "removed"));
|
|
76
|
+
runsByRow.set(additionStart + additionOffset, sideRuns(changes, "added"));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return runsByRow;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Lines this similar (word-set overlap) are treated as the same line edited. */
|
|
84
|
+
const SIMILAR_MIN_WORD_OVERLAP = 0.3;
|
|
85
|
+
|
|
86
|
+
/** Whether two lines are the same line edited, by case-insensitive word-set
|
|
87
|
+
* overlap - the matching gate, distinct from the case-sensitive word diff. */
|
|
88
|
+
function linesSimilar(oldText: string, newText: string): boolean {
|
|
89
|
+
const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
|
|
90
|
+
const oldWords = words(oldText);
|
|
91
|
+
const newWords = words(newText);
|
|
92
|
+
|
|
93
|
+
if (oldWords.size === 0 && newWords.size === 0) return true;
|
|
94
|
+
let intersection = 0;
|
|
95
|
+
|
|
96
|
+
for (const word of oldWords) if (newWords.has(word)) intersection++;
|
|
97
|
+
const union = oldWords.size + newWords.size - intersection;
|
|
98
|
+
|
|
99
|
+
return union === 0 ? true : intersection / union >= SIMILAR_MIN_WORD_OVERLAP;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pair each modified line with the addition it became. A lone deletion opposite
|
|
104
|
+
* a lone addition is one edit (always paired). Otherwise align the lines by
|
|
105
|
+
* similarity, order-preserving (LCS over `linesSimilar`), so an inserted, removed,
|
|
106
|
+
* or shifted line finds its real counterpart instead of mispairing by position -
|
|
107
|
+
* unmatched lines get no pair and render whole-line. Returns [deletionOffset,
|
|
108
|
+
* additionOffset] pairs into the block.
|
|
109
|
+
*/
|
|
110
|
+
function alignedPairs(deletionTexts: string[], additionTexts: string[]): Array<[number, number]> {
|
|
111
|
+
if (deletionTexts.length === 1 && additionTexts.length === 1) return [[0, 0]];
|
|
112
|
+
const pairs: Array<[number, number]> = [];
|
|
113
|
+
let deletionOffset = 0;
|
|
114
|
+
let additionOffset = 0;
|
|
115
|
+
|
|
116
|
+
for (const op of lcsDiff(deletionTexts, additionTexts, linesSimilar)) {
|
|
117
|
+
if (op.kind === "ctx") {
|
|
118
|
+
pairs.push([deletionOffset, additionOffset]);
|
|
119
|
+
deletionOffset++;
|
|
120
|
+
additionOffset++;
|
|
121
|
+
} else if (op.kind === "del") {
|
|
122
|
+
deletionOffset++;
|
|
123
|
+
} else {
|
|
124
|
+
additionOffset++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return pairs;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Runs for one side of a word diff: common plus this side's changes, adjacent
|
|
132
|
+
* runs of equal changed-ness coalesced so highlighting stays as few spans. */
|
|
133
|
+
function sideRuns(changes: WordChange[], side: "added" | "removed"): IntralineRun[] {
|
|
134
|
+
const runs: IntralineRun[] = [];
|
|
135
|
+
|
|
136
|
+
for (const change of changes) {
|
|
137
|
+
if (change.kind !== "common" && change.kind !== side) continue;
|
|
138
|
+
const changed = change.kind === side;
|
|
139
|
+
const last = runs[runs.length - 1];
|
|
140
|
+
|
|
141
|
+
if (last && last.changed === changed) last.text += change.text;
|
|
142
|
+
else runs.push({ text: change.text, changed });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return runs;
|
|
146
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { highlightJobs, spansByLine } from "./diff-syntax";
|
|
3
|
+
import type { DiffRow } from "./view-diff";
|
|
4
|
+
import type { SimpleHighlight } from "@opentui/core";
|
|
5
|
+
|
|
6
|
+
function row(kind: DiffRow["kind"], text: string, file = "src/x.ts"): DiffRow {
|
|
7
|
+
return { kind, text, file };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("spansByLine", () => {
|
|
11
|
+
test("maps absolute offsets to line-relative spans", () => {
|
|
12
|
+
// Arrange
|
|
13
|
+
const source = "const x = 1;\nfoo";
|
|
14
|
+
const highlights: SimpleHighlight[] = [
|
|
15
|
+
[0, 5, "keyword"],
|
|
16
|
+
[6, 7, "variable"],
|
|
17
|
+
[13, 16, "variable"],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Act
|
|
21
|
+
const lineSpans = spansByLine(source, highlights);
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(lineSpans[0]).toEqual([
|
|
25
|
+
{ start: 0, end: 5, group: "keyword" },
|
|
26
|
+
{ start: 6, end: 7, group: "variable" },
|
|
27
|
+
]);
|
|
28
|
+
expect(lineSpans[1]).toEqual([{ start: 0, end: 3, group: "variable" }]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("spreads a capture that crosses newlines onto every line it covers", () => {
|
|
32
|
+
// Arrange - one capture spanning both lines, as a block comment would
|
|
33
|
+
const source = "aa\nbb";
|
|
34
|
+
const highlights: SimpleHighlight[] = [[0, 5, "comment"]];
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
const lineSpans = spansByLine(source, highlights);
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
40
|
+
expect(lineSpans[0]).toEqual([{ start: 0, end: 2, group: "comment" }]);
|
|
41
|
+
expect(lineSpans[1]).toEqual([{ start: 0, end: 2, group: "comment" }]);
|
|
42
|
+
});
|
|
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
|
+
|
|
60
|
+
test("resolves overlapping captures with later winning", () => {
|
|
61
|
+
// Arrange
|
|
62
|
+
const source = "foo";
|
|
63
|
+
const highlights: SimpleHighlight[] = [
|
|
64
|
+
[0, 3, "variable"],
|
|
65
|
+
[0, 3, "type"],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Act
|
|
69
|
+
const lineSpans = spansByLine(source, highlights);
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect(lineSpans[0]).toEqual([{ start: 0, end: 3, group: "type" }]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("highlightJobs", () => {
|
|
77
|
+
test("reconstructs old and new sides of a hunk with row indices", () => {
|
|
78
|
+
// Arrange
|
|
79
|
+
const rows = [
|
|
80
|
+
row("file", "src/x.ts", "src/x.ts"),
|
|
81
|
+
row("hunk", "@@ -1,2 +1,2 @@"),
|
|
82
|
+
row("ctx", "const a = 1;"),
|
|
83
|
+
row("del", "const b = 2;"),
|
|
84
|
+
row("add", "const b = 3;"),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// Act
|
|
88
|
+
const jobs = highlightJobs(rows);
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
// old side = context + deletion; new side = context + addition
|
|
92
|
+
expect(jobs).toEqual([
|
|
93
|
+
{ filetype: "typescript", source: "const a = 1;\nconst b = 2;", rowIndexByLine: [2, 3] },
|
|
94
|
+
{ filetype: "typescript", source: "const a = 1;\nconst b = 3;", rowIndexByLine: [2, 4] },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("skips files with no known filetype", () => {
|
|
99
|
+
// Arrange
|
|
100
|
+
const rows = [
|
|
101
|
+
row("file", "notes/thing.unknownext", "notes/thing.unknownext"),
|
|
102
|
+
row("hunk", "@@"),
|
|
103
|
+
row("add", "whatever"),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
// Act
|
|
107
|
+
const jobs = highlightJobs(rows);
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
110
|
+
expect(jobs).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax highlighting for diff rows: tree-sitter token colors mapped onto the
|
|
3
|
+
* diff sheet's hand-rolled rows, so annotation cards still interleave. A hunk is
|
|
4
|
+
* highlighted as a contiguous fragment (its context+addition lines as the new
|
|
5
|
+
* side, context+deletion lines as the old side) - never per single line, which
|
|
6
|
+
* would lose the multi-line grammar context - and the resulting token spans are
|
|
7
|
+
* mapped back to each row in line-relative columns. Async: the shared tree-sitter
|
|
8
|
+
* client resolves off the render path, so callers draw unstyled first.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getTreeSitterClient, type SimpleHighlight } from "@opentui/core";
|
|
12
|
+
import type { DiffRow } from "./view-diff";
|
|
13
|
+
import { filetypeForPath } from "./components/syntax-highlight";
|
|
14
|
+
|
|
15
|
+
/** A syntax-colored run within one row, in line-relative columns [start, end). */
|
|
16
|
+
export interface SyntaxSpan {
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
group: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stripTrailingNewline(text: string): string {
|
|
23
|
+
return text.replace(/\n$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface HighlightJob {
|
|
27
|
+
filetype: string;
|
|
28
|
+
source: string;
|
|
29
|
+
/** Row index for each line of `source`, in order. */
|
|
30
|
+
rowIndexByLine: number[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Split diff rows into per-hunk highlight jobs, one per side. A hunk's rows are
|
|
35
|
+
* contiguous file lines, so each side reconstructs to valid code; file/hunk
|
|
36
|
+
* header rows and files without a known filetype are skipped.
|
|
37
|
+
*/
|
|
38
|
+
export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
|
|
39
|
+
const jobs: HighlightJob[] = [];
|
|
40
|
+
let oldLines: string[] = [];
|
|
41
|
+
let newLines: string[] = [];
|
|
42
|
+
let oldRowIndexByLine: number[] = [];
|
|
43
|
+
let newRowIndexByLine: number[] = [];
|
|
44
|
+
let filetype: string | undefined;
|
|
45
|
+
|
|
46
|
+
const flush = (): void => {
|
|
47
|
+
if (filetype) {
|
|
48
|
+
if (oldLines.length)
|
|
49
|
+
jobs.push({ filetype, source: oldLines.join("\n"), rowIndexByLine: oldRowIndexByLine });
|
|
50
|
+
if (newLines.length)
|
|
51
|
+
jobs.push({ filetype, source: newLines.join("\n"), rowIndexByLine: newRowIndexByLine });
|
|
52
|
+
}
|
|
53
|
+
oldLines = [];
|
|
54
|
+
newLines = [];
|
|
55
|
+
oldRowIndexByLine = [];
|
|
56
|
+
newRowIndexByLine = [];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
60
|
+
const row = rows[rowIndex]!;
|
|
61
|
+
|
|
62
|
+
if (row.kind === "file" || row.kind === "hunk") {
|
|
63
|
+
flush();
|
|
64
|
+
filetype = row.kind === "file" ? filetypeForPath(row.file) : filetype;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const text = stripTrailingNewline(row.text);
|
|
68
|
+
|
|
69
|
+
if (row.kind === "ctx" || row.kind === "del") {
|
|
70
|
+
oldLines.push(text);
|
|
71
|
+
oldRowIndexByLine.push(rowIndex);
|
|
72
|
+
}
|
|
73
|
+
if (row.kind === "ctx" || row.kind === "add") {
|
|
74
|
+
newLines.push(text);
|
|
75
|
+
newRowIndexByLine.push(rowIndex);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
|
|
80
|
+
return jobs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve one source's flat, possibly-overlapping token highlights into
|
|
85
|
+
* per-line spans. Offsets are absolute into `source`; later highlights win on
|
|
86
|
+
* overlap, and a highlight is clamped to the line its start falls on.
|
|
87
|
+
*/
|
|
88
|
+
export function spansByLine(source: string, highlights: SimpleHighlight[]): SyntaxSpan[][] {
|
|
89
|
+
const lineStarts: number[] = [0];
|
|
90
|
+
|
|
91
|
+
for (let index = 0; index < source.length; index++) {
|
|
92
|
+
if (source[index] === "\n") lineStarts.push(index + 1);
|
|
93
|
+
}
|
|
94
|
+
const lineOf = (offset: number): number => {
|
|
95
|
+
let low = 0;
|
|
96
|
+
let high = lineStarts.length - 1;
|
|
97
|
+
|
|
98
|
+
while (low < high) {
|
|
99
|
+
const mid = (low + high + 1) >> 1;
|
|
100
|
+
|
|
101
|
+
if (lineStarts[mid]! <= offset) low = mid;
|
|
102
|
+
else high = mid - 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return low;
|
|
106
|
+
};
|
|
107
|
+
// Per-line char->group, later highlights overriding, then coalesce into spans.
|
|
108
|
+
const groupsByLine: Array<Array<string | undefined>> = lineStarts.map((start, line) => {
|
|
109
|
+
const end = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : source.length;
|
|
110
|
+
|
|
111
|
+
return Array.from({ length: Math.max(0, end - start) }, () => undefined);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
for (const [start, rawEnd, group] of highlights) {
|
|
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. 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);
|
|
121
|
+
|
|
122
|
+
while (cursor < end) {
|
|
123
|
+
const line = lineOf(cursor);
|
|
124
|
+
const lineStart = lineStarts[line]!;
|
|
125
|
+
const lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : source.length;
|
|
126
|
+
const chars = groupsByLine[line]!;
|
|
127
|
+
const to = Math.min(end, lineEnd) - lineStart;
|
|
128
|
+
|
|
129
|
+
for (let column = cursor - lineStart; column < to; column++) chars[column] = group;
|
|
130
|
+
cursor = lineEnd + 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return groupsByLine.map((chars) => {
|
|
135
|
+
const spans: SyntaxSpan[] = [];
|
|
136
|
+
|
|
137
|
+
for (let column = 0; column < chars.length; column++) {
|
|
138
|
+
const group = chars[column];
|
|
139
|
+
|
|
140
|
+
if (group === undefined) continue;
|
|
141
|
+
const previous = spans[spans.length - 1];
|
|
142
|
+
|
|
143
|
+
if (previous && previous.group === group && previous.end === column)
|
|
144
|
+
previous.end = column + 1;
|
|
145
|
+
else spans.push({ start: column, end: column + 1, group });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return spans;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Highlight the diff's rows and return the token spans per row index. Rows in a
|
|
154
|
+
* file with no known filetype, and any row while highlighting is still resolving,
|
|
155
|
+
* are absent - the caller renders those unstyled.
|
|
156
|
+
*/
|
|
157
|
+
export async function highlightDiffRows(rows: DiffRow[]): Promise<Map<number, SyntaxSpan[]>> {
|
|
158
|
+
const spansByRow = new Map<number, SyntaxSpan[]>();
|
|
159
|
+
const jobs = highlightJobs(rows);
|
|
160
|
+
|
|
161
|
+
if (!jobs.length) return spansByRow;
|
|
162
|
+
const client = getTreeSitterClient();
|
|
163
|
+
|
|
164
|
+
await client.initialize();
|
|
165
|
+
for (const job of jobs) {
|
|
166
|
+
const result = await client.highlightOnce(job.source, job.filetype);
|
|
167
|
+
|
|
168
|
+
if (!result.highlights) continue;
|
|
169
|
+
const lineSpans = spansByLine(job.source, result.highlights);
|
|
170
|
+
|
|
171
|
+
job.rowIndexByLine.forEach((rowIndex, line) => {
|
|
172
|
+
const spans = lineSpans[line];
|
|
173
|
+
|
|
174
|
+
if (spans && spans.length) spansByRow.set(rowIndex, spans);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return spansByRow;
|
|
179
|
+
}
|
|
@@ -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, Thread } 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: Thread, 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
|
+
}
|