@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,134 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Thread, WorkspaceKey } from "@cueloop/schema";
|
|
3
|
+
import { groupInbox, projectName } from "./session-tree";
|
|
4
|
+
|
|
5
|
+
function session(id: string, title: string, rootCommit?: string, remote?: string): Thread {
|
|
6
|
+
const workspace: WorkspaceKey = { repoRoot: `/home/dev/${id}-checkout`, branch: "main" };
|
|
7
|
+
if (rootCommit !== undefined) workspace.rootCommit = rootCommit;
|
|
8
|
+
if (remote !== undefined) workspace.remote = remote;
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
schemaVersion: "1",
|
|
12
|
+
id,
|
|
13
|
+
workspace,
|
|
14
|
+
artifact: { type: "plan", content: `# ${title}\n`, meta: { title } },
|
|
15
|
+
revisions: [],
|
|
16
|
+
annotations: [],
|
|
17
|
+
verdict: null,
|
|
18
|
+
status: "pending",
|
|
19
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("projectName", () => {
|
|
24
|
+
test("prefers the remote basename and strips the .git suffix", () => {
|
|
25
|
+
// Arrange
|
|
26
|
+
const workspace: WorkspaceKey = {
|
|
27
|
+
repoRoot: "/tmp/whatever",
|
|
28
|
+
branch: "main",
|
|
29
|
+
remote: "git@github.com:acme/widget.git",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
const name = projectName(workspace);
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(name).toBe("widget");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("falls back to the working-tree folder name when there is no remote", () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const workspace: WorkspaceKey = { repoRoot: "/home/dev/cueloop", branch: "main" };
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
const name = projectName(workspace);
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(name).toBe("cueloop");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("groupInbox", () => {
|
|
52
|
+
test("splits repo-bound threads into Projects and the rest into Threads", () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
const sessions = [
|
|
55
|
+
session("a", "Read the repo", "root-1", "git@github.com:acme/widget.git"),
|
|
56
|
+
session("b", "A loose idea"),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
const { rows, ordered } = groupInbox(sessions);
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
const labels = rows.map((row) => `${row.kind}:${"label" in row ? row.label : row.id}`);
|
|
64
|
+
expect(labels).toEqual([
|
|
65
|
+
"section:Projects",
|
|
66
|
+
"project:widget",
|
|
67
|
+
"thread:a",
|
|
68
|
+
"section:Threads",
|
|
69
|
+
"thread:b",
|
|
70
|
+
]);
|
|
71
|
+
expect(ordered.map((s) => s.id)).toEqual(["a", "b"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("collapses threads sharing a root commit into one project", () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
const sessions = [
|
|
77
|
+
session("a", "First", "root-1", "git@github.com:acme/widget.git"),
|
|
78
|
+
session("b", "Second", "root-1", "git@github.com:acme/widget.git"),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Act
|
|
82
|
+
const { rows, ordered } = groupInbox(sessions);
|
|
83
|
+
|
|
84
|
+
// Assert
|
|
85
|
+
const projectRows = rows.filter((row) => row.kind === "project");
|
|
86
|
+
expect(projectRows).toHaveLength(1);
|
|
87
|
+
expect(ordered).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("selectionIndex on each thread matches its position in ordered", () => {
|
|
91
|
+
// Arrange
|
|
92
|
+
const sessions = [session("a", "One", "root-1"), session("b", "Two")];
|
|
93
|
+
|
|
94
|
+
// Act
|
|
95
|
+
const { rows, ordered } = groupInbox(sessions);
|
|
96
|
+
|
|
97
|
+
// Assert
|
|
98
|
+
for (const row of rows) {
|
|
99
|
+
if (row.kind === "thread") expect(ordered[row.selectionIndex]!.id).toBe(row.id);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("omits the Projects section when nothing is repo-bound", () => {
|
|
104
|
+
// Arrange
|
|
105
|
+
const sessions = [session("a", "Loose one"), session("b", "Loose two")];
|
|
106
|
+
|
|
107
|
+
// Act
|
|
108
|
+
const { rows } = groupInbox(sessions);
|
|
109
|
+
|
|
110
|
+
// Assert
|
|
111
|
+
expect(rows.some((row) => row.kind === "section" && row.label === "Projects")).toBe(false);
|
|
112
|
+
expect(rows.some((row) => row.kind === "section" && row.label === "Threads")).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a pinned thread lifts into a Pinned section at the top, out of its normal group", () => {
|
|
116
|
+
// Arrange - one repo-bound thread, one standalone; pin the repo-bound one
|
|
117
|
+
const sessions = [session("a", "Pinned one", "root-1"), session("b", "Loose two")];
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
const { rows, ordered } = groupInbox(sessions, new Set(["a"]));
|
|
121
|
+
|
|
122
|
+
// Assert - Pinned is the first section and holds thread a; a is gone from Projects
|
|
123
|
+
const sections = rows
|
|
124
|
+
.filter((row) => row.kind === "section")
|
|
125
|
+
.map((row) => (row.kind === "section" ? row.label : ""));
|
|
126
|
+
|
|
127
|
+
expect(sections[0]).toBe("Pinned");
|
|
128
|
+
expect(sections).not.toContain("Projects"); // its only member was pinned away
|
|
129
|
+
expect(ordered[0]!.id).toBe("a"); // the cursor walks pinned threads first
|
|
130
|
+
const pinnedThreadIds = rows.filter((row) => row.kind === "thread").map((row) => row.id);
|
|
131
|
+
|
|
132
|
+
expect(pinnedThreadIds).toEqual(["a", "b"]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Groups the pending inbox into Projects and Threads for the sidebar. Threads
|
|
3
|
+
* whose workspace carries a rootCommit gather under their project (keyed by that
|
|
4
|
+
* commit, so moving or re-cloning the repo never forks it); the rest are
|
|
5
|
+
* standalone Threads. The flat `ordered` sequence is what the inbox cursor
|
|
6
|
+
* walks, so the keyboard grammar keeps a single index across the two groups.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Thread, WorkspaceKey } from "@cueloop/schema";
|
|
10
|
+
|
|
11
|
+
/** A repo's display name: the remote basename when present, else the working-tree folder name. */
|
|
12
|
+
export function projectName(workspace: WorkspaceKey): string {
|
|
13
|
+
const { remote, repoRoot } = workspace;
|
|
14
|
+
|
|
15
|
+
if (remote !== undefined) {
|
|
16
|
+
const cleaned = remote.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
17
|
+
const base = cleaned
|
|
18
|
+
.split(/[/:]/)
|
|
19
|
+
.filter((part) => part.length > 0)
|
|
20
|
+
.at(-1);
|
|
21
|
+
if (base !== undefined) return base;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const dir = repoRoot
|
|
25
|
+
.replace(/\/+$/, "")
|
|
26
|
+
.split("/")
|
|
27
|
+
.filter((part) => part.length > 0)
|
|
28
|
+
.at(-1);
|
|
29
|
+
|
|
30
|
+
return dir ?? repoRoot;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type InboxRow =
|
|
34
|
+
| { kind: "section"; id: string; label: string }
|
|
35
|
+
| { kind: "project"; id: string; label: string }
|
|
36
|
+
| { kind: "thread"; id: string; session: Thread; selectionIndex: number };
|
|
37
|
+
|
|
38
|
+
export interface GroupedInbox {
|
|
39
|
+
rows: InboxRow[];
|
|
40
|
+
/** Threads in display order; the inbox cursor indexes this. */
|
|
41
|
+
ordered: Thread[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function threadTitle(session: Thread): string {
|
|
45
|
+
return session.artifact.meta.title ?? session.id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ProjectGroup {
|
|
49
|
+
name: string;
|
|
50
|
+
sessions: Thread[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Group pending sessions into Pinned, then Projects (by root commit), then standalone Threads. */
|
|
54
|
+
export function groupInbox(
|
|
55
|
+
sessions: readonly Thread[],
|
|
56
|
+
pinnedIds?: ReadonlySet<string>,
|
|
57
|
+
): GroupedInbox {
|
|
58
|
+
const pinned: Thread[] = [];
|
|
59
|
+
const projects = new Map<string, ProjectGroup>();
|
|
60
|
+
const standalone: Thread[] = [];
|
|
61
|
+
|
|
62
|
+
for (const session of sessions) {
|
|
63
|
+
if (pinnedIds?.has(session.id) === true) {
|
|
64
|
+
pinned.push(session);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const key = session.workspace.rootCommit;
|
|
69
|
+
if (key === undefined) {
|
|
70
|
+
standalone.push(session);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const existing = projects.get(key);
|
|
75
|
+
if (existing !== undefined) existing.sessions.push(session);
|
|
76
|
+
else projects.set(key, { name: projectName(session.workspace), sessions: [session] });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const rows: InboxRow[] = [];
|
|
80
|
+
const ordered: Thread[] = [];
|
|
81
|
+
const pushThread = (session: Thread): void => {
|
|
82
|
+
rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length });
|
|
83
|
+
ordered.push(session);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (pinned.length > 0) {
|
|
87
|
+
rows.push({ kind: "section", id: "section:pinned", label: "Pinned" });
|
|
88
|
+
for (const session of pinned) pushThread(session);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
|
|
92
|
+
if (projectEntries.length > 0) {
|
|
93
|
+
rows.push({ kind: "section", id: "section:projects", label: "Projects" });
|
|
94
|
+
for (const [key, project] of projectEntries) {
|
|
95
|
+
rows.push({ kind: "project", id: `project:${key}`, label: project.name });
|
|
96
|
+
for (const session of project.sessions) pushThread(session);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (standalone.length > 0) {
|
|
101
|
+
rows.push({ kind: "section", id: "section:threads", label: "Threads" });
|
|
102
|
+
for (const session of standalone) pushThread(session);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { rows, ordered };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { threadTitle };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive stories catalog (`bun run stories`): a nested tree of
|
|
3
|
+
* component modules on the left (j/k to move, h/l to fold or open) and the
|
|
4
|
+
* selected story rendered live in the preview pane. The same story objects
|
|
5
|
+
* back the snapshot harness - what this TUI shows is what the tests assert.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { createCliRenderer, type ScrollBoxRenderable } from "@opentui/core";
|
|
10
|
+
import { createRoot, useKeyboard } from "@opentui/react";
|
|
11
|
+
import { DARK } from "../theme";
|
|
12
|
+
import { ThemeProvider } from "./theme-context";
|
|
13
|
+
import { buildStoryTree, loadStories, type LoadedStory } from "./story";
|
|
14
|
+
import { PaneGrid } from "./PaneGrid";
|
|
15
|
+
import { ShellHeader } from "./ShellHeader";
|
|
16
|
+
import { Tree } from "./primitives/Tree";
|
|
17
|
+
import { NERD } from "./primitives/icons";
|
|
18
|
+
import { allFolderIds, flattenTree } from "./primitives/tree-model";
|
|
19
|
+
|
|
20
|
+
interface StoriesAppProps {
|
|
21
|
+
stories: LoadedStory[];
|
|
22
|
+
onExit: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
|
|
26
|
+
const treeNodes = useMemo(() => buildStoryTree(stories), [stories]);
|
|
27
|
+
const storiesById = useMemo(
|
|
28
|
+
() => new Map(stories.map((story) => [`${story.moduleTitle}/${story.storyName}`, story])),
|
|
29
|
+
[stories],
|
|
30
|
+
);
|
|
31
|
+
const firstStoryId = `${stories[0]!.moduleTitle}/${stories[0]!.storyName}`;
|
|
32
|
+
|
|
33
|
+
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(() =>
|
|
34
|
+
allFolderIds(treeNodes),
|
|
35
|
+
);
|
|
36
|
+
const [selectedId, setSelectedId] = useState(firstStoryId);
|
|
37
|
+
const [openedId, setOpenedId] = useState(firstStoryId);
|
|
38
|
+
const [treeOpen, setTreeOpen] = useState(true);
|
|
39
|
+
|
|
40
|
+
const rows = flattenTree(treeNodes, { expandedIds });
|
|
41
|
+
const selectedRow = rows.find((row) => row.id === selectedId) ?? rows[0];
|
|
42
|
+
const opened = storiesById.get(openedId);
|
|
43
|
+
const sidebarScroll = useRef<ScrollBoxRenderable | null>(null);
|
|
44
|
+
|
|
45
|
+
// keep the moving selection inside the scrolled viewport
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
try {
|
|
48
|
+
sidebarScroll.current?.scrollChildIntoView(`tree-row-${selectedId}`);
|
|
49
|
+
} catch {
|
|
50
|
+
// reveal is best-effort; the selection state is already correct
|
|
51
|
+
}
|
|
52
|
+
}, [selectedId]);
|
|
53
|
+
|
|
54
|
+
const openStory = (id: string): void => {
|
|
55
|
+
if (storiesById.has(id)) setOpenedId(id);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const toggleFolder = (id: string): void => {
|
|
59
|
+
setExpandedIds((current) => {
|
|
60
|
+
const next = new Set(current);
|
|
61
|
+
if (next.has(id)) next.delete(id);
|
|
62
|
+
else next.add(id);
|
|
63
|
+
|
|
64
|
+
return next;
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const expandFolder = (id: string): void => {
|
|
69
|
+
setExpandedIds((current) => new Set(current).add(id));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const collapseFolder = (id: string): void => {
|
|
73
|
+
setExpandedIds((current) => {
|
|
74
|
+
const next = new Set(current);
|
|
75
|
+
next.delete(id);
|
|
76
|
+
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const moveSelection = (delta: number): void => {
|
|
82
|
+
const index = rows.findIndex((row) => row.id === selectedId);
|
|
83
|
+
const nextIndex = Math.max(0, Math.min(rows.length - 1, index + delta));
|
|
84
|
+
const nextRow = rows[nextIndex];
|
|
85
|
+
if (nextRow !== undefined) setSelectedId(nextRow.id);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
useKeyboard((key) => {
|
|
89
|
+
if (key.name === "q") onExit();
|
|
90
|
+
else if (key.name === "j" || key.name === "down") moveSelection(1);
|
|
91
|
+
else if (key.name === "k" || key.name === "up") moveSelection(-1);
|
|
92
|
+
else if (selectedRow === undefined) return;
|
|
93
|
+
else if (key.name === "l" || key.name === "right") {
|
|
94
|
+
if (selectedRow.isFolder) expandFolder(selectedRow.id);
|
|
95
|
+
else openStory(selectedRow.id);
|
|
96
|
+
} else if (key.name === "h" || key.name === "left") {
|
|
97
|
+
if (selectedRow.isFolder) collapseFolder(selectedRow.id);
|
|
98
|
+
} else if (key.name === "return" || key.name === "space") {
|
|
99
|
+
if (selectedRow.isFolder) toggleFolder(selectedRow.id);
|
|
100
|
+
else openStory(selectedRow.id);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<ThemeProvider theme={DARK}>
|
|
106
|
+
<PaneGrid
|
|
107
|
+
theme={DARK}
|
|
108
|
+
mainHeader={
|
|
109
|
+
<ShellHeader
|
|
110
|
+
theme={DARK}
|
|
111
|
+
leftIcons={[{ glyph: NERD.settings }]}
|
|
112
|
+
leftLabel="cueloop storybook"
|
|
113
|
+
inspectorOpen={treeOpen}
|
|
114
|
+
onToggleInspector={treeOpen ? undefined : () => setTreeOpen(true)}
|
|
115
|
+
/>
|
|
116
|
+
}
|
|
117
|
+
inspectorHeader={
|
|
118
|
+
<ShellHeader
|
|
119
|
+
theme={DARK}
|
|
120
|
+
inspectorOpen={treeOpen}
|
|
121
|
+
onToggleInspector={() => setTreeOpen(false)}
|
|
122
|
+
/>
|
|
123
|
+
}
|
|
124
|
+
main={
|
|
125
|
+
opened !== undefined ? (
|
|
126
|
+
<scrollbox style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }} focused={false}>
|
|
127
|
+
{/* remount per story so component-local state never leaks across */}
|
|
128
|
+
<box key={openedId} style={{ flexDirection: "column" }}>
|
|
129
|
+
{opened.story.render()}
|
|
130
|
+
</box>
|
|
131
|
+
</scrollbox>
|
|
132
|
+
) : (
|
|
133
|
+
<box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
|
|
134
|
+
<text fg={DARK.textDim}>Select a story</text>
|
|
135
|
+
</box>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
inspectorWidth={34}
|
|
139
|
+
inspector={
|
|
140
|
+
treeOpen ? (
|
|
141
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
142
|
+
<scrollbox ref={sidebarScroll} style={{ flexGrow: 1 }} focused={false}>
|
|
143
|
+
<Tree
|
|
144
|
+
nodes={treeNodes}
|
|
145
|
+
expandedIds={expandedIds}
|
|
146
|
+
selectedId={selectedId}
|
|
147
|
+
theme={DARK}
|
|
148
|
+
onSelect={(id) => {
|
|
149
|
+
setSelectedId(id);
|
|
150
|
+
openStory(id);
|
|
151
|
+
}}
|
|
152
|
+
onToggle={(id) => {
|
|
153
|
+
setSelectedId(id);
|
|
154
|
+
toggleFolder(id);
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
</scrollbox>
|
|
158
|
+
</box>
|
|
159
|
+
) : undefined
|
|
160
|
+
}
|
|
161
|
+
footer={
|
|
162
|
+
<box style={{ height: 1, paddingLeft: 1 }}>
|
|
163
|
+
<text fg={DARK.textDim}>j/k move · h/l fold or open · enter open · q quit</text>
|
|
164
|
+
</box>
|
|
165
|
+
}
|
|
166
|
+
/>
|
|
167
|
+
</ThemeProvider>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (import.meta.main) {
|
|
172
|
+
const stories = await loadStories();
|
|
173
|
+
const renderer = await createCliRenderer();
|
|
174
|
+
|
|
175
|
+
createRoot(renderer).render(
|
|
176
|
+
<StoriesApp
|
|
177
|
+
stories={stories}
|
|
178
|
+
onExit={() => {
|
|
179
|
+
renderer.destroy();
|
|
180
|
+
queueMicrotask(() => process.exit(0));
|
|
181
|
+
}}
|
|
182
|
+
/>,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog IS the regression suite: every exported story renders in
|
|
3
|
+
* a virtual terminal, its char frame snapshots, and its declared colors must
|
|
4
|
+
* appear among the styled spans. A component file without a colocated
|
|
5
|
+
* stories file fails the harness, so the safety net cannot silently thin.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
10
|
+
import { RGBA } from "@opentui/core";
|
|
11
|
+
import { componentFilesMissingStories, loadStories } from "./story";
|
|
12
|
+
import { allowEventLoopUpdates } from "../test-support";
|
|
13
|
+
|
|
14
|
+
const stories = await loadStories();
|
|
15
|
+
|
|
16
|
+
/** Hex of a painted color; undefined for unpainted (transparent) spans. */
|
|
17
|
+
function hexOf(color: RGBA | null | undefined): string | undefined {
|
|
18
|
+
if (!color) return undefined;
|
|
19
|
+
const [red, green, blue] = color.toInts();
|
|
20
|
+
|
|
21
|
+
return "#" + [red, green, blue].map((part) => part!.toString(16).padStart(2, "0")).join("");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("stories catalog", () => {
|
|
25
|
+
test("every component file ships stories", () => {
|
|
26
|
+
expect(componentFilesMissingStories()).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the catalog is not empty", () => {
|
|
30
|
+
expect(stories.length).toBeGreaterThan(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const { moduleTitle, storyName, story } of stories) {
|
|
34
|
+
test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
|
|
35
|
+
// Arrange
|
|
36
|
+
const size = story.size ?? { width: 80, height: 24 };
|
|
37
|
+
|
|
38
|
+
// Act
|
|
39
|
+
const setup = await testRender(story.render(), size);
|
|
40
|
+
|
|
41
|
+
allowEventLoopUpdates();
|
|
42
|
+
await setup.waitForVisualIdle();
|
|
43
|
+
const frame = setup.captureCharFrame();
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
46
|
+
expect(frame).toMatchSnapshot(`${moduleTitle}/${storyName}`);
|
|
47
|
+
if (story.expectedColors?.length) {
|
|
48
|
+
const seenColors = new Set<string>();
|
|
49
|
+
|
|
50
|
+
for (const line of setup.captureSpans().lines) {
|
|
51
|
+
for (const span of line.spans) {
|
|
52
|
+
const foregroundHex = hexOf(span.fg);
|
|
53
|
+
const backgroundHex = hexOf(span.bg);
|
|
54
|
+
|
|
55
|
+
if (foregroundHex) seenColors.add(foregroundHex);
|
|
56
|
+
if (backgroundHex) seenColors.add(backgroundHex);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const expectedColor of story.expectedColors) {
|
|
60
|
+
expect(seenColors).toContain(expectedColor.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
setup.renderer.destroy();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Shared fixture data for the stories catalog: one plan session, one diff. */
|
|
2
|
+
|
|
3
|
+
import type { Annotation, Thread } from "@cueloop/schema";
|
|
4
|
+
import { buildDisplay, marksByDisplay } from "../view-plan";
|
|
5
|
+
import { diffRows } from "../view-diff";
|
|
6
|
+
|
|
7
|
+
export const FIXTURE_PLAN = `# Migration Plan
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The daemon persists sessions to disk atomically.
|
|
12
|
+
|
|
13
|
+
## Steps
|
|
14
|
+
|
|
15
|
+
- move the store
|
|
16
|
+
- add recovery
|
|
17
|
+
|
|
18
|
+
\`\`\`ts
|
|
19
|
+
export function persist(session: Session): void {
|
|
20
|
+
// one JSON document per session
|
|
21
|
+
writeAtomically(pathFor(session.id), serialize(session));
|
|
22
|
+
}
|
|
23
|
+
\`\`\`
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
export const FIXTURE_PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
27
|
+
index 111..222 100644
|
|
28
|
+
--- a/src/store.ts
|
|
29
|
+
+++ b/src/store.ts
|
|
30
|
+
@@ -1,4 +1,4 @@
|
|
31
|
+
export class Store {
|
|
32
|
+
- private items = [];
|
|
33
|
+
+ private items = new Map();
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export const FIXTURE_ANNOTATIONS: Annotation[] = [
|
|
38
|
+
{
|
|
39
|
+
id: "a_story_1",
|
|
40
|
+
kind: "comment",
|
|
41
|
+
anchor: { quote: "persists sessions", prefix: "The daemon ", suffix: " to disk" },
|
|
42
|
+
body: "Which daemon owns this?",
|
|
43
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "a_story_2",
|
|
47
|
+
kind: "comment",
|
|
48
|
+
anchor: { quote: "move the store", prefix: "", suffix: "" },
|
|
49
|
+
body: "move the store behind one interface",
|
|
50
|
+
createdAt: "2026-01-01T00:00:01Z",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export function fixturePlanSession(overrides: Partial<Thread> = {}): Thread {
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: "1",
|
|
57
|
+
id: "s_story_plan",
|
|
58
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
59
|
+
artifact: {
|
|
60
|
+
type: "plan",
|
|
61
|
+
content: FIXTURE_PLAN,
|
|
62
|
+
meta: { title: "Migration Plan", agent: "agent/worker-3" },
|
|
63
|
+
},
|
|
64
|
+
revisions: [{ revision: 1, content: FIXTURE_PLAN, submittedAt: "2026-01-01T00:00:00Z" }],
|
|
65
|
+
annotations: FIXTURE_ANNOTATIONS,
|
|
66
|
+
verdict: null,
|
|
67
|
+
status: "pending",
|
|
68
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function fixtureDiffSession(overrides: Partial<Thread> = {}): Thread {
|
|
74
|
+
return {
|
|
75
|
+
schemaVersion: "1",
|
|
76
|
+
id: "s_story_diff",
|
|
77
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
78
|
+
artifact: {
|
|
79
|
+
type: "diff",
|
|
80
|
+
content: FIXTURE_PATCH,
|
|
81
|
+
meta: { title: "working tree", agent: "agent/worker-3" },
|
|
82
|
+
},
|
|
83
|
+
revisions: [{ revision: 1, content: FIXTURE_PATCH, submittedAt: "2026-01-01T00:00:00Z" }],
|
|
84
|
+
annotations: [],
|
|
85
|
+
verdict: null,
|
|
86
|
+
status: "pending",
|
|
87
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
88
|
+
...overrides,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function fixtureDisplay() {
|
|
93
|
+
return buildDisplay(FIXTURE_PLAN);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function fixtureMarks() {
|
|
97
|
+
return marksByDisplay(FIXTURE_ANNOTATIONS, fixtureDisplay());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function fixtureDiffRows() {
|
|
101
|
+
return diffRows(FIXTURE_PATCH);
|
|
102
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildStoryTree, type LoadedStory } from "./story";
|
|
3
|
+
import type { TreeNode } from "./primitives/tree-model";
|
|
4
|
+
|
|
5
|
+
/** A story object whose render is never invoked in these structural tests. */
|
|
6
|
+
const STUB = { render: () => null };
|
|
7
|
+
|
|
8
|
+
function story(moduleTitle: string, storyName: string): LoadedStory {
|
|
9
|
+
return { moduleTitle, storyName, story: STUB };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function childLabels(node: TreeNode): string[] {
|
|
13
|
+
return (node.children ?? []).map((child) => child.label);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("buildStoryTree", () => {
|
|
17
|
+
test("splits moduleTitle on / into nested folders keyed by their path", () => {
|
|
18
|
+
// Arrange
|
|
19
|
+
const stories = [story("Primitives/Tree", "Default"), story("Layout/AppShell", "Default")];
|
|
20
|
+
|
|
21
|
+
// Act
|
|
22
|
+
const tree = buildStoryTree(stories);
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
25
|
+
expect(tree.map((node) => [node.id, node.label])).toEqual([
|
|
26
|
+
["Primitives", "Primitives"],
|
|
27
|
+
["Layout", "Layout"],
|
|
28
|
+
]);
|
|
29
|
+
expect(tree[0]!.children!.map((node) => node.id)).toEqual(["Primitives/Tree"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("groups sibling components under one shared parent folder", () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
const stories = [story("Primitives/Tree", "Default"), story("Primitives/Card", "Default")];
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
const tree = buildStoryTree(stories);
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
40
|
+
expect(tree).toHaveLength(1);
|
|
41
|
+
expect(childLabels(tree[0]!)).toEqual(["Tree", "Card"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("nests to the full depth of the moduleTitle path", () => {
|
|
45
|
+
// Arrange
|
|
46
|
+
const stories = [story("A/B/C", "Only")];
|
|
47
|
+
|
|
48
|
+
// Act
|
|
49
|
+
const tree = buildStoryTree(stories);
|
|
50
|
+
const a = tree[0]!;
|
|
51
|
+
const b = a.children![0]!;
|
|
52
|
+
|
|
53
|
+
// Assert
|
|
54
|
+
expect([a.id, b.id]).toEqual(["A", "A/B"]);
|
|
55
|
+
expect(b.children![0]!.id).toBe("A/B/C");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("adds each story as a leaf keyed `${moduleTitle}/${storyName}` under its component", () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
const stories = [story("Primitives/Tree", "Empty"), story("Primitives/Tree", "Deep")];
|
|
61
|
+
|
|
62
|
+
// Act
|
|
63
|
+
const tree = buildStoryTree(stories);
|
|
64
|
+
const component = tree[0]!.children![0]!;
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(component.id).toBe("Primitives/Tree");
|
|
68
|
+
expect(component.children!.map((leaf) => [leaf.id, leaf.label])).toEqual([
|
|
69
|
+
["Primitives/Tree/Empty", "Empty"],
|
|
70
|
+
["Primitives/Tree/Deep", "Deep"],
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("hoists a component with a single story of its own name into one leaf row", () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
const stories = [story("Primitives/Card", "Card")];
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
const tree = buildStoryTree(stories);
|
|
80
|
+
const hoisted = tree[0]!.children![0]!;
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
expect(hoisted.children).toBeUndefined();
|
|
84
|
+
expect(hoisted.id).toBe("Primitives/Card/Card");
|
|
85
|
+
expect(hoisted.label).toBe("Card");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("keeps the folder when its single story differs from the component name", () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
const stories = [story("Primitives/Card", "Default")];
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
const tree = buildStoryTree(stories);
|
|
94
|
+
const component = tree[0]!.children![0]!;
|
|
95
|
+
|
|
96
|
+
// Assert
|
|
97
|
+
expect(component.id).toBe("Primitives/Card");
|
|
98
|
+
expect(component.children!.map((leaf) => leaf.id)).toEqual(["Primitives/Card/Default"]);
|
|
99
|
+
});
|
|
100
|
+
});
|