@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.78
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 +5 -5
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +14 -2
- package/src/App.export.test.tsx +1 -2
- package/src/App.plan-review.test.tsx +16 -0
- package/src/App.submit-confirm.test.tsx +34 -5
- package/src/App.test.tsx +2 -2
- package/src/App.tsx +4 -0
- package/src/App.walk.test.tsx +2 -2
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +7 -3
- package/src/app-view-model.ts +3 -0
- package/src/components/AnnotatableFileView.tsx +1 -1
- package/src/components/AnnotationCards.tsx +18 -2
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +4 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/ManageAccessDialog.tsx +13 -11
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/ShareChoiceDialog.tsx +0 -2
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadTree.tsx +11 -6
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -1
- package/src/components/ThreadView.tsx +66 -7
- package/src/components/Toast.tsx +0 -5
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +219 -89
- package/src/components/primitives/Card.tsx +3 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +8 -1
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -1
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/run.ts +31 -19
- package/src/serve.ts +1 -1
- package/src/share.ts +1 -1
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.switch-cache.test.ts +146 -0
- package/src/thread-controller.ts +157 -24
- package/src/use-annotation-surface.tsx +43 -27
|
@@ -33,7 +33,7 @@ export function projectName(workspace: WorkspaceKey): string {
|
|
|
33
33
|
export type InboxRow =
|
|
34
34
|
| { kind: "section"; id: string; label: string }
|
|
35
35
|
| { kind: "project"; id: string; label: string }
|
|
36
|
-
| { kind: "thread"; id: string; session: Thread; selectionIndex: number };
|
|
36
|
+
| { kind: "thread"; id: string; session: Thread; selectionIndex: number; depth: number };
|
|
37
37
|
|
|
38
38
|
export interface GroupedInbox {
|
|
39
39
|
rows: InboxRow[];
|
|
@@ -78,14 +78,14 @@ export function groupInbox(
|
|
|
78
78
|
|
|
79
79
|
const rows: InboxRow[] = [];
|
|
80
80
|
const ordered: Thread[] = [];
|
|
81
|
-
const pushThread = (session: Thread): void => {
|
|
82
|
-
rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length });
|
|
81
|
+
const pushThread = (session: Thread, depth: number): void => {
|
|
82
|
+
rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length, depth });
|
|
83
83
|
ordered.push(session);
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
if (pinned.length > 0) {
|
|
87
87
|
rows.push({ kind: "section", id: "section:pinned", label: "Starred" });
|
|
88
|
-
for (const session of pinned) pushThread(session);
|
|
88
|
+
for (const session of pinned) pushThread(session, 1);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
|
|
@@ -93,13 +93,14 @@ export function groupInbox(
|
|
|
93
93
|
rows.push({ kind: "section", id: "section:projects", label: "Projects" });
|
|
94
94
|
for (const [key, project] of projectEntries) {
|
|
95
95
|
rows.push({ kind: "project", id: `project:${key}`, label: project.name });
|
|
96
|
-
|
|
96
|
+
// a project's threads cascade one level under its name
|
|
97
|
+
for (const session of project.sessions) pushThread(session, 2);
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
if (standalone.length > 0) {
|
|
101
102
|
rows.push({ kind: "section", id: "section:threads", label: "Threads" });
|
|
102
|
-
for (const session of standalone) pushThread(session);
|
|
103
|
+
for (const session of standalone) pushThread(session, 1);
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
return { rows, ordered };
|
|
@@ -13,9 +13,10 @@ import { ThemeProvider } from "./theme-context";
|
|
|
13
13
|
import { buildStoryTree, loadStories, type LoadedStory } from "./story";
|
|
14
14
|
import { PaneGrid } from "./PaneGrid";
|
|
15
15
|
import { ShellHeader } from "./ShellHeader";
|
|
16
|
+
import { AnsiScreen } from "./AnsiScreen";
|
|
16
17
|
import { Tree } from "./primitives/Tree";
|
|
17
18
|
import { NERD } from "./primitives/icons";
|
|
18
|
-
import {
|
|
19
|
+
import { flattenTree } from "./primitives/tree-model";
|
|
19
20
|
|
|
20
21
|
interface StoriesAppProps {
|
|
21
22
|
stories: LoadedStory[];
|
|
@@ -30,8 +31,8 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
|
|
|
30
31
|
);
|
|
31
32
|
const firstStoryId = `${stories[0]!.moduleTitle}/${stories[0]!.storyName}`;
|
|
32
33
|
|
|
33
|
-
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(
|
|
34
|
-
|
|
34
|
+
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(
|
|
35
|
+
() => new Set(treeNodes.filter((node) => node.children !== undefined).map((node) => node.id)),
|
|
35
36
|
);
|
|
36
37
|
const [selectedId, setSelectedId] = useState(firstStoryId);
|
|
37
38
|
const [openedId, setOpenedId] = useState(firstStoryId);
|
|
@@ -126,7 +127,15 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
|
|
|
126
127
|
<scrollbox style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }} focused={false}>
|
|
127
128
|
{/* remount per story so component-local state never leaks across */}
|
|
128
129
|
<box key={openedId} style={{ flexDirection: "column" }}>
|
|
129
|
-
{opened.story.
|
|
130
|
+
{opened.story.ansi ? (
|
|
131
|
+
<AnsiScreen
|
|
132
|
+
ansi={opened.story.ansi()}
|
|
133
|
+
cols={opened.story.size?.width ?? 80}
|
|
134
|
+
rows={opened.story.size?.height ?? 24}
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
opened.story.render?.()
|
|
138
|
+
)}
|
|
130
139
|
</box>
|
|
131
140
|
</scrollbox>
|
|
132
141
|
) : (
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* stories file fails the harness, so the safety net cannot silently thin.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import React from "react";
|
|
8
9
|
import { describe, expect, test } from "bun:test";
|
|
9
10
|
import { testRender } from "@opentui/react/test-utils";
|
|
10
11
|
import { RGBA } from "@opentui/core";
|
|
11
12
|
import { componentFilesMissingStories, loadStories } from "./story";
|
|
13
|
+
import { AnsiScreen } from "./AnsiScreen";
|
|
12
14
|
import { allowEventLoopUpdates } from "../test-support";
|
|
13
15
|
|
|
14
16
|
const stories = await loadStories();
|
|
@@ -34,9 +36,14 @@ describe("stories catalog", () => {
|
|
|
34
36
|
test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
|
|
35
37
|
// Arrange
|
|
36
38
|
const size = story.size ?? { width: 80, height: 24 };
|
|
39
|
+
const node = story.ansi ? (
|
|
40
|
+
<AnsiScreen ansi={story.ansi()} cols={size.width} rows={size.height} />
|
|
41
|
+
) : (
|
|
42
|
+
story.render!()
|
|
43
|
+
);
|
|
37
44
|
|
|
38
45
|
// Act
|
|
39
|
-
const setup = await testRender(
|
|
46
|
+
const setup = await testRender(node, size);
|
|
40
47
|
|
|
41
48
|
allowEventLoopUpdates();
|
|
42
49
|
await setup.waitForVisualIdle();
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { buildStoryTree, type LoadedStory } from "./story";
|
|
2
|
+
import { buildStoryTree, type LoadedStory, type StorySection } from "./story";
|
|
3
3
|
import type { TreeNode } from "./primitives/tree-model";
|
|
4
4
|
|
|
5
5
|
/** A story object whose render is never invoked in these structural tests. */
|
|
6
6
|
const STUB = { render: () => null };
|
|
7
7
|
|
|
8
|
-
function story(moduleTitle: string, storyName: string): LoadedStory {
|
|
9
|
-
return { moduleTitle, storyName, story: STUB };
|
|
8
|
+
function story(moduleTitle: string, storyName: string, section: StorySection = "App"): LoadedStory {
|
|
9
|
+
return { section, moduleTitle, storyName, story: STUB };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function childLabels(node: TreeNode): string[] {
|
|
@@ -14,19 +14,31 @@ function childLabels(node: TreeNode): string[] {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
describe("buildStoryTree", () => {
|
|
17
|
-
test("
|
|
17
|
+
test("groups the top level by section", () => {
|
|
18
|
+
const tree = buildStoryTree([
|
|
19
|
+
story("Primitives/Tree", "Default"),
|
|
20
|
+
story("Collaborator Join", "JoinSplash", "SSH"),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
expect(tree.map((node) => [node.id, node.label])).toEqual([
|
|
24
|
+
["App", "App"],
|
|
25
|
+
["SSH", "SSH"],
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("splits moduleTitle on / into nested folders under the section", () => {
|
|
18
30
|
// Arrange
|
|
19
31
|
const stories = [story("Primitives/Tree", "Default"), story("Layout/AppShell", "Default")];
|
|
20
32
|
|
|
21
33
|
// Act
|
|
22
|
-
const
|
|
34
|
+
const app = buildStoryTree(stories)[0]!;
|
|
23
35
|
|
|
24
36
|
// Assert
|
|
25
|
-
expect(
|
|
26
|
-
["Primitives", "Primitives"],
|
|
27
|
-
["Layout", "Layout"],
|
|
37
|
+
expect(app.children!.map((node) => [node.id, node.label])).toEqual([
|
|
38
|
+
["App/Primitives", "Primitives"],
|
|
39
|
+
["App/Layout", "Layout"],
|
|
28
40
|
]);
|
|
29
|
-
expect(
|
|
41
|
+
expect(app.children![0]!.children!.map((node) => node.id)).toEqual(["App/Primitives/Tree"]);
|
|
30
42
|
});
|
|
31
43
|
|
|
32
44
|
test("groups sibling components under one shared parent folder", () => {
|
|
@@ -34,11 +46,11 @@ describe("buildStoryTree", () => {
|
|
|
34
46
|
const stories = [story("Primitives/Tree", "Default"), story("Primitives/Card", "Default")];
|
|
35
47
|
|
|
36
48
|
// Act
|
|
37
|
-
const
|
|
49
|
+
const app = buildStoryTree(stories)[0]!;
|
|
38
50
|
|
|
39
51
|
// Assert
|
|
40
|
-
expect(
|
|
41
|
-
expect(childLabels(
|
|
52
|
+
expect(app.children).toHaveLength(1);
|
|
53
|
+
expect(childLabels(app.children![0]!)).toEqual(["Tree", "Card"]);
|
|
42
54
|
});
|
|
43
55
|
|
|
44
56
|
test("nests to the full depth of the moduleTitle path", () => {
|
|
@@ -46,13 +58,12 @@ describe("buildStoryTree", () => {
|
|
|
46
58
|
const stories = [story("A/B/C", "Only")];
|
|
47
59
|
|
|
48
60
|
// Act
|
|
49
|
-
const
|
|
50
|
-
const a = tree[0]!;
|
|
61
|
+
const a = buildStoryTree(stories)[0]!.children![0]!;
|
|
51
62
|
const b = a.children![0]!;
|
|
52
63
|
|
|
53
64
|
// Assert
|
|
54
|
-
expect([a.id, b.id]).toEqual(["A", "A/B"]);
|
|
55
|
-
expect(b.children![0]!.id).toBe("A/B/C");
|
|
65
|
+
expect([a.id, b.id]).toEqual(["App/A", "App/A/B"]);
|
|
66
|
+
expect(b.children![0]!.id).toBe("App/A/B/C");
|
|
56
67
|
});
|
|
57
68
|
|
|
58
69
|
test("adds each story as a leaf keyed `${moduleTitle}/${storyName}` under its component", () => {
|
|
@@ -60,11 +71,10 @@ describe("buildStoryTree", () => {
|
|
|
60
71
|
const stories = [story("Primitives/Tree", "Empty"), story("Primitives/Tree", "Deep")];
|
|
61
72
|
|
|
62
73
|
// Act
|
|
63
|
-
const
|
|
64
|
-
const component = tree[0]!.children![0]!;
|
|
74
|
+
const component = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
|
|
65
75
|
|
|
66
76
|
// Assert
|
|
67
|
-
expect(component.id).toBe("Primitives/Tree");
|
|
77
|
+
expect(component.id).toBe("App/Primitives/Tree");
|
|
68
78
|
expect(component.children!.map((leaf) => [leaf.id, leaf.label])).toEqual([
|
|
69
79
|
["Primitives/Tree/Empty", "Empty"],
|
|
70
80
|
["Primitives/Tree/Deep", "Deep"],
|
|
@@ -76,8 +86,7 @@ describe("buildStoryTree", () => {
|
|
|
76
86
|
const stories = [story("Primitives/Card", "Card")];
|
|
77
87
|
|
|
78
88
|
// Act
|
|
79
|
-
const
|
|
80
|
-
const hoisted = tree[0]!.children![0]!;
|
|
89
|
+
const hoisted = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
|
|
81
90
|
|
|
82
91
|
// Assert
|
|
83
92
|
expect(hoisted.children).toBeUndefined();
|
|
@@ -90,11 +99,10 @@ describe("buildStoryTree", () => {
|
|
|
90
99
|
const stories = [story("Primitives/Card", "Default")];
|
|
91
100
|
|
|
92
101
|
// Act
|
|
93
|
-
const
|
|
94
|
-
const component = tree[0]!.children![0]!;
|
|
102
|
+
const component = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
|
|
95
103
|
|
|
96
104
|
// Assert
|
|
97
|
-
expect(component.id).toBe("Primitives/Card");
|
|
105
|
+
expect(component.id).toBe("App/Primitives/Card");
|
|
98
106
|
expect(component.children!.map((leaf) => leaf.id)).toEqual(["Primitives/Card/Default"]);
|
|
99
107
|
});
|
|
100
108
|
});
|
package/src/components/story.ts
CHANGED
|
@@ -15,7 +15,9 @@ export interface StoryMeta {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface Story {
|
|
18
|
-
render
|
|
18
|
+
render?: () => React.ReactNode;
|
|
19
|
+
/** Raw ANSI to paint through AnsiScreen instead of a React tree; exactly one of render or ansi is set. */
|
|
20
|
+
ansi?: () => string;
|
|
19
21
|
/**
|
|
20
22
|
* Colors (hex tokens) that must appear among the rendered frame's styled
|
|
21
23
|
* spans - the color regression net for color-bearing stories.
|
|
@@ -25,18 +27,21 @@ export interface Story {
|
|
|
25
27
|
size?: { width: number; height: number };
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
export type StorySection = "App" | "SSH";
|
|
31
|
+
|
|
28
32
|
export interface LoadedStory {
|
|
33
|
+
section: StorySection;
|
|
29
34
|
moduleTitle: string;
|
|
30
35
|
storyName: string;
|
|
31
36
|
story: Story;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export function isStory(value: unknown): value is Story {
|
|
40
|
+
if (typeof value !== "object" || value === null) return false;
|
|
41
|
+
|
|
35
42
|
return (
|
|
36
|
-
typeof value === "
|
|
37
|
-
value
|
|
38
|
-
"render" in value &&
|
|
39
|
-
typeof value.render === "function"
|
|
43
|
+
("render" in value && typeof value.render === "function") ||
|
|
44
|
+
("ansi" in value && typeof value.ansi === "function")
|
|
40
45
|
);
|
|
41
46
|
}
|
|
42
47
|
|
|
@@ -49,22 +54,34 @@ function isStoryMeta(value: unknown): value is StoryMeta {
|
|
|
49
54
|
);
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
const GATEWAY_SRC_DIR = `${import.meta.dir}/../../../gateway/src`;
|
|
58
|
+
|
|
59
|
+
async function collectStories(
|
|
60
|
+
dir: string,
|
|
61
|
+
pattern: string,
|
|
62
|
+
section: StorySection,
|
|
63
|
+
loaded: LoadedStory[],
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const glob = new Bun.Glob(pattern);
|
|
66
|
+
const files = [...glob.scanSync({ cwd: dir })].sort();
|
|
57
67
|
|
|
58
68
|
for (const file of files) {
|
|
59
|
-
const moduleExports = await import(`${
|
|
69
|
+
const moduleExports = await import(`${dir}/${file}`);
|
|
60
70
|
const meta = moduleExports["meta"];
|
|
61
|
-
const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx
|
|
71
|
+
const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx?$/, "");
|
|
62
72
|
|
|
63
73
|
for (const [exportName, exported] of Object.entries(moduleExports)) {
|
|
64
74
|
if (exportName === "meta" || !isStory(exported)) continue;
|
|
65
|
-
loaded.push({ moduleTitle, storyName: exportName, story: exported });
|
|
75
|
+
loaded.push({ section, moduleTitle, storyName: exportName, story: exported });
|
|
66
76
|
}
|
|
67
77
|
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function loadStories(): Promise<LoadedStory[]> {
|
|
81
|
+
const loaded: LoadedStory[] = [];
|
|
82
|
+
|
|
83
|
+
await collectStories(import.meta.dir, "**/*.stories.tsx", "App", loaded);
|
|
84
|
+
await collectStories(GATEWAY_SRC_DIR, "**/*.stories.ts", "SSH", loaded);
|
|
68
85
|
|
|
69
86
|
return loaded;
|
|
70
87
|
}
|
|
@@ -102,11 +119,11 @@ export function buildStoryTree(stories: LoadedStory[]): TreeNode[] {
|
|
|
102
119
|
const roots: TreeNode[] = [];
|
|
103
120
|
const foldersByPath = new Map<string, StoryTreeFolder>();
|
|
104
121
|
|
|
105
|
-
for (const { moduleTitle, storyName } of stories) {
|
|
122
|
+
for (const { section, moduleTitle, storyName } of stories) {
|
|
106
123
|
let siblings = roots;
|
|
107
124
|
let path = "";
|
|
108
125
|
|
|
109
|
-
for (const segment of moduleTitle.split("/")) {
|
|
126
|
+
for (const segment of [section, ...moduleTitle.split("/")]) {
|
|
110
127
|
path = path === "" ? segment : `${path}/${segment}`;
|
|
111
128
|
let folder = foldersByPath.get(path);
|
|
112
129
|
if (folder === undefined) {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import * as intraline from "./diff-intraline";
|
|
3
|
+
import {
|
|
4
|
+
createIntralineResolver,
|
|
5
|
+
intralineRunsByRow,
|
|
6
|
+
wordLevelChanges,
|
|
7
|
+
type IntralineRun,
|
|
8
|
+
} from "./diff-intraline";
|
|
3
9
|
import type { DiffRow } from "./view-diff";
|
|
4
10
|
|
|
5
11
|
function line(kind: DiffRow["kind"], text: string): DiffRow {
|
|
@@ -163,3 +169,70 @@ describe("intralineRunsByRow", () => {
|
|
|
163
169
|
expect(changedText(runsByRow.get(3)!)).toBe("FOUR");
|
|
164
170
|
});
|
|
165
171
|
});
|
|
172
|
+
|
|
173
|
+
describe("createIntralineResolver", () => {
|
|
174
|
+
test("resolves each row to the same runs the eager pass would, on demand", () => {
|
|
175
|
+
// Arrange - context around two separate change blocks
|
|
176
|
+
const rows = [
|
|
177
|
+
line("ctx", "header"),
|
|
178
|
+
line("del", "one two"),
|
|
179
|
+
line("add", "one TWO"),
|
|
180
|
+
line("ctx", "middle"),
|
|
181
|
+
line("del", "alpha beta"),
|
|
182
|
+
line("add", "alpha GAMMA"),
|
|
183
|
+
line("ctx", "footer"),
|
|
184
|
+
];
|
|
185
|
+
const eager = intralineRunsByRow(rows);
|
|
186
|
+
|
|
187
|
+
// Act
|
|
188
|
+
const resolver = createIntralineResolver(rows);
|
|
189
|
+
|
|
190
|
+
// Assert - lazy row-by-row output matches the eager map, including undefined for context rows
|
|
191
|
+
for (let index = 0; index < rows.length; index++) {
|
|
192
|
+
expect(resolver.runsForRow(index)).toEqual(eager.get(index));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a second call for the same row returns the cached runs", () => {
|
|
197
|
+
const rows = [line("del", "one two"), line("add", "one TWO")];
|
|
198
|
+
const resolver = createIntralineResolver(rows);
|
|
199
|
+
|
|
200
|
+
expect(resolver.runsForRow(0)).toBe(resolver.runsForRow(0));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// The performance guard: a virtualized diff must word-diff only the change blocks whose rows are on
|
|
204
|
+
// screen. This is deterministic (a call count, not a wall-clock), so it fails on any machine if a
|
|
205
|
+
// future change makes the resolver eager again - the regression the nav-latency benchmark would show.
|
|
206
|
+
test("word-diffs only the accessed block, not the whole diff", () => {
|
|
207
|
+
// Arrange - 50 independent change blocks, each a single del/add pair separated by context
|
|
208
|
+
const rows: DiffRow[] = [];
|
|
209
|
+
|
|
210
|
+
for (let block = 0; block < 50; block++) {
|
|
211
|
+
rows.push(
|
|
212
|
+
line("ctx", `ctx ${block}`),
|
|
213
|
+
line("del", `old ${block}`),
|
|
214
|
+
line("add", `new ${block}`),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const spy = spyOn(intraline, "wordLevelChanges");
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// Act - the eager pass word-diffs every block
|
|
221
|
+
spy.mockClear();
|
|
222
|
+
intralineRunsByRow(rows);
|
|
223
|
+
const eagerCalls = spy.mock.calls.length;
|
|
224
|
+
|
|
225
|
+
// Act - the resolver word-diffs only the one block whose row is asked for
|
|
226
|
+
spy.mockClear();
|
|
227
|
+
const resolver = createIntralineResolver(rows);
|
|
228
|
+
resolver.runsForRow(1); // a row in block 0
|
|
229
|
+
const oneBlockCalls = spy.mock.calls.length;
|
|
230
|
+
|
|
231
|
+
// Assert - eager pays for all 50 blocks; lazy pays for one
|
|
232
|
+
expect(eagerCalls).toBe(50);
|
|
233
|
+
expect(oneBlockCalls).toBe(1);
|
|
234
|
+
} finally {
|
|
235
|
+
spy.mockRestore();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
package/src/diff-intraline.ts
CHANGED
|
@@ -36,16 +36,16 @@ function stripTrailingNewline(text: string): string {
|
|
|
36
36
|
return text.replace(/\n$/, "");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
const
|
|
39
|
+
/** A change block: a run of deletion rows immediately followed by addition rows. */
|
|
40
|
+
interface IntralineBlock {
|
|
41
|
+
deletionStart: number;
|
|
42
|
+
additionStart: number;
|
|
43
|
+
end: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The change blocks in row order, found in one cheap pass (no word diff yet). */
|
|
47
|
+
function intralineBlocks(rows: DiffRow[]): IntralineBlock[] {
|
|
48
|
+
const blocks: IntralineBlock[] = [];
|
|
49
49
|
let index = 0;
|
|
50
50
|
|
|
51
51
|
while (index < rows.length) {
|
|
@@ -59,22 +59,91 @@ export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]>
|
|
|
59
59
|
const additionStart = index;
|
|
60
60
|
|
|
61
61
|
while (index < rows.length && rows[index]!.kind === "add") index++;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
62
|
+
blocks.push({ deletionStart, additionStart, end: index });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return blocks;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Word-diff one change block into [rowIndex, runs] pairs; unmatched rows get no entry. */
|
|
69
|
+
function blockRunEntries(rows: DiffRow[], block: IntralineBlock): Array<[number, IntralineRun[]]> {
|
|
70
|
+
const deletionTexts = rows
|
|
71
|
+
.slice(block.deletionStart, block.additionStart)
|
|
72
|
+
.map((row) => stripTrailingNewline(row.text));
|
|
73
|
+
const additionTexts = rows
|
|
74
|
+
.slice(block.additionStart, block.end)
|
|
75
|
+
.map((row) => stripTrailingNewline(row.text));
|
|
76
|
+
const entries: Array<[number, IntralineRun[]]> = [];
|
|
77
|
+
|
|
78
|
+
for (const [deletionOffset, additionOffset] of alignedPairs(deletionTexts, additionTexts)) {
|
|
79
|
+
const changes = wordLevelChanges(
|
|
80
|
+
deletionTexts[deletionOffset]!,
|
|
81
|
+
additionTexts[additionOffset]!,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
entries.push([block.deletionStart + deletionOffset, sideRuns(changes, "removed")]);
|
|
85
|
+
entries.push([block.additionStart + additionOffset, sideRuns(changes, "added")]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The change block whose rows cover `rowIndex`, by binary search over the ordered blocks. */
|
|
92
|
+
function blockContaining(blocks: IntralineBlock[], rowIndex: number): number | undefined {
|
|
93
|
+
let low = 0;
|
|
94
|
+
let high = blocks.length - 1;
|
|
95
|
+
|
|
96
|
+
while (low <= high) {
|
|
97
|
+
const mid = (low + high) >> 1;
|
|
98
|
+
const block = blocks[mid]!;
|
|
99
|
+
|
|
100
|
+
if (rowIndex < block.deletionStart) high = mid - 1;
|
|
101
|
+
else if (rowIndex >= block.end) low = mid + 1;
|
|
102
|
+
else return mid;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A row's intra-line runs, resolved on demand. */
|
|
109
|
+
export interface IntralineResolver {
|
|
110
|
+
runsForRow(rowIndex: number): IntralineRun[] | undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Char-precise intra-line runs for modified diff rows, computed lazily. The change blocks are found
|
|
115
|
+
* up front in one cheap pass; a block is word-diffed only when a row inside it is first asked for,
|
|
116
|
+
* so a virtualized diff pays for the rows on screen, not for the whole file. Within a change block -
|
|
117
|
+
* deletions immediately followed by additions - lines align to their real counterpart (alignedPairs)
|
|
118
|
+
* and each matched pair is word-diffed; unmatched rows get no runs, so the caller renders those whole.
|
|
119
|
+
*/
|
|
120
|
+
export function createIntralineResolver(rows: DiffRow[]): IntralineResolver {
|
|
121
|
+
const blocks = intralineBlocks(rows);
|
|
122
|
+
const computed = new Set<number>();
|
|
123
|
+
const runsByRow = new Map<number, IntralineRun[]>();
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
runsForRow(rowIndex) {
|
|
127
|
+
const blockIndex = blockContaining(blocks, rowIndex);
|
|
128
|
+
|
|
129
|
+
if (blockIndex === undefined) return undefined;
|
|
130
|
+
if (!computed.has(blockIndex)) {
|
|
131
|
+
computed.add(blockIndex);
|
|
132
|
+
for (const [row, runs] of blockRunEntries(rows, blocks[blockIndex]!))
|
|
133
|
+
runsByRow.set(row, runs);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return runsByRow.get(rowIndex);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every modified row's intra-line runs, eagerly. Prefer createIntralineResolver on a hot path. */
|
|
142
|
+
export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]> {
|
|
143
|
+
const runsByRow = new Map<number, IntralineRun[]>();
|
|
144
|
+
|
|
145
|
+
for (const block of intralineBlocks(rows)) {
|
|
146
|
+
for (const [row, runs] of blockRunEntries(rows, block)) runsByRow.set(row, runs);
|
|
78
147
|
}
|
|
79
148
|
|
|
80
149
|
return runsByRow;
|
package/src/keymap.test.ts
CHANGED
|
@@ -59,8 +59,8 @@ describe("submit overlay", () => {
|
|
|
59
59
|
const keyState = state({ overlay: "submit" });
|
|
60
60
|
const table: [string, Intent[]][] = [
|
|
61
61
|
["escape", [{ type: "closeOverlay" }]],
|
|
62
|
-
["return", [
|
|
63
|
-
["enter", [
|
|
62
|
+
["return", []],
|
|
63
|
+
["enter", []],
|
|
64
64
|
["left", [{ type: "cycleVerdict", direction: -1 }]],
|
|
65
65
|
["right", [{ type: "cycleVerdict", direction: 1 }]],
|
|
66
66
|
["j", []],
|
package/src/keymap.ts
CHANGED
|
@@ -193,7 +193,6 @@ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
193
193
|
const name = key.name;
|
|
194
194
|
|
|
195
195
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
196
|
-
if (name === "return" || name === "enter") return [{ type: "submitVerdict" }];
|
|
197
196
|
if (name === "left" || name === "right") {
|
|
198
197
|
return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
|
|
199
198
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { imagePlaceholder, looksLikeBinaryPaste } from "./pasted-image";
|
|
3
|
+
|
|
4
|
+
function bytesOf(...values: number[]): Uint8Array {
|
|
5
|
+
return Uint8Array.from(values);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("looksLikeBinaryPaste", () => {
|
|
9
|
+
test("plain text pastes stay text", () => {
|
|
10
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("just a normal comment"))).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("multi-line text with tabs and newlines stays text", () => {
|
|
14
|
+
expect(
|
|
15
|
+
looksLikeBinaryPaste(new TextEncoder().encode("line one\n\tline two\r\nline three")),
|
|
16
|
+
).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("unicode text stays text", () => {
|
|
20
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("café — naïve — 日本語 — 🎉"))).toBe(
|
|
21
|
+
false,
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a PNG signature is binary", () => {
|
|
26
|
+
expect(looksLikeBinaryPaste(bytesOf(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))).toBe(
|
|
27
|
+
true,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a JPEG signature is binary", () => {
|
|
32
|
+
expect(looksLikeBinaryPaste(bytesOf(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a GIF signature is binary", () => {
|
|
36
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("GIF89a\x01\x02"))).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a WEBP signature is binary", () => {
|
|
40
|
+
expect(
|
|
41
|
+
looksLikeBinaryPaste(bytesOf(0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4, 0x57, 0x45, 0x42, 0x50)),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a NUL byte marks binary", () => {
|
|
46
|
+
expect(looksLikeBinaryPaste(bytesOf(0x68, 0x69, 0x00, 0x74, 0x68, 0x65, 0x72, 0x65))).toBe(
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a burst of control bytes marks binary", () => {
|
|
52
|
+
expect(looksLikeBinaryPaste(bytesOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08))).toBe(
|
|
53
|
+
true,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an empty paste is not binary", () => {
|
|
58
|
+
expect(looksLikeBinaryPaste(new Uint8Array())).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("imagePlaceholder", () => {
|
|
63
|
+
test("numbers each pasted image", () => {
|
|
64
|
+
expect(imagePlaceholder(1)).toBe("[Image #1]");
|
|
65
|
+
expect(imagePlaceholder(2)).toBe("[Image #2]");
|
|
66
|
+
});
|
|
67
|
+
});
|