@cueloop/client 0.1.0-alpha.65 → 0.1.0-alpha.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +79 -112
- package/src/App.plan-review.test.tsx +23 -205
- package/src/App.prototype.test.tsx +1 -8
- package/src/App.readonly.test.tsx +9 -27
- package/src/App.submit-confirm.test.tsx +19 -72
- package/src/App.test.tsx +136 -31
- package/src/App.tsx +491 -322
- package/src/App.walk.test.tsx +15 -16
- package/src/App.workbench.test.tsx +390 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +61 -0
- package/src/app-screens.tsx +197 -77
- package/src/app-view-model.ts +9 -69
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +1 -1
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +239 -0
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +170 -0
- package/src/components/CodeBlock.stories.tsx +1 -1
- package/src/components/CompletionOverlay.stories.tsx +1 -1
- package/src/components/ConfirmCard.stories.tsx +1 -1
- package/src/components/ConfirmCard.tsx +3 -4
- package/src/components/ConfirmDialog.stories.tsx +1 -1
- package/src/components/DiffSheet.scroll.test.tsx +38 -55
- package/src/components/DiffSheet.split.test.tsx +140 -0
- package/src/components/DiffSheet.stories.tsx +56 -54
- package/src/components/DiffSheet.syntax.test.tsx +24 -7
- package/src/components/DiffSheet.tsx +607 -276
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +105 -0
- package/src/components/EditorGrid.tsx +338 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +78 -0
- package/src/components/InboxList.stories.tsx +23 -8
- package/src/components/InboxList.tsx +188 -33
- package/src/components/KeybindsDialog.stories.tsx +1 -1
- package/src/components/MarkerPopover.stories.tsx +1 -1
- package/src/components/MenuBar.stories.tsx +3 -12
- package/src/components/MenuBar.tsx +25 -56
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +82 -0
- package/src/components/PromptDialog.stories.tsx +1 -1
- package/src/components/PromptDialog.tsx +8 -1
- package/src/components/PrototypeSheet.stories.tsx +1 -1
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/SettingsDialog.stories.tsx +7 -1
- package/src/components/SettingsDialog.tsx +114 -67
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SurfaceScrollbar.stories.tsx +34 -0
- package/src/components/SurfaceScrollbar.test.tsx +111 -0
- package/src/components/SurfaceScrollbar.tsx +93 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadView.slash.test.tsx +113 -0
- package/src/components/ThreadView.stories.tsx +1 -1
- package/src/components/ThreadView.tsx +62 -1352
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +70 -0
- package/src/components/Toast.stories.tsx +1 -1
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +1 -1
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/WelcomeSurface.stories.tsx +12 -0
- package/src/components/WelcomeSurface.tsx +52 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +732 -572
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/diff-sheet-layout.test.ts +125 -55
- package/src/components/diff-sheet-layout.ts +3 -103
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +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 +1 -1
- package/src/components/primitives/Card.stories.tsx +1 -1
- package/src/components/primitives/Dialog.stories.tsx +1 -1
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +1 -1
- package/src/components/primitives/Tabs.stories.tsx +1 -1
- package/src/components/primitives/Toolbar.stories.tsx +1 -1
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -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 +1 -1
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +142 -82
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +55 -0
- package/src/components/theme-context.stories.tsx +1 -1
- package/src/config.test.ts +20 -49
- package/src/config.ts +26 -28
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-syntax.test.ts +16 -0
- package/src/diff-syntax.ts +6 -3
- package/src/discussions.ts +79 -0
- package/src/intent-dispatch.test.ts +31 -11
- package/src/intent-dispatch.ts +93 -48
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +3 -25
- package/src/keymap.ts +17 -18
- package/src/mark-runs.ts +120 -0
- package/src/pty.ts +10 -2
- package/src/run.ts +17 -7
- package/src/serve.test.ts +4 -8
- package/src/session-controller.curate.test.ts +75 -0
- package/src/session-controller.share.test.ts +1 -0
- package/src/session-controller.tree.test.ts +26 -0
- package/src/session-controller.ts +160 -31
- package/src/slash-palette.ts +83 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +3 -3
- package/src/test-support.ts +39 -8
- package/src/theme.ts +4 -4
- package/src/thread-chords.test.ts +25 -9
- package/src/thread-chords.ts +33 -8
- package/src/use-annotation-surface.tsx +837 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +23 -24
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/view-diff.ts +114 -0
- package/src/App.review-panel.test.tsx +0 -140
- package/src/App.tree.test.tsx +0 -224
- package/src/components/ReviewPanel.stories.tsx +0 -100
- package/src/components/ReviewPanel.tsx +0 -103
- package/src/components/ReviewRail.stories.tsx +0 -232
- package/src/components/ReviewRail.tsx +0 -327
- package/src/review-panel.test.ts +0 -113
- package/src/review-panel.ts +0 -83
|
@@ -0,0 +1,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 { ReviewSession, 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: ReviewSession; selectionIndex: number };
|
|
37
|
+
|
|
38
|
+
export interface GroupedInbox {
|
|
39
|
+
rows: InboxRow[];
|
|
40
|
+
/** Threads in display order; the inbox cursor indexes this. */
|
|
41
|
+
ordered: ReviewSession[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function threadTitle(session: ReviewSession): string {
|
|
45
|
+
return session.artifact.meta.title ?? session.id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ProjectGroup {
|
|
49
|
+
name: string;
|
|
50
|
+
sessions: ReviewSession[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Group pending sessions into Pinned, then Projects (by root commit), then standalone Threads. */
|
|
54
|
+
export function groupInbox(
|
|
55
|
+
sessions: readonly ReviewSession[],
|
|
56
|
+
pinnedIds?: ReadonlySet<string>,
|
|
57
|
+
): GroupedInbox {
|
|
58
|
+
const pinned: ReviewSession[] = [];
|
|
59
|
+
const projects = new Map<string, ProjectGroup>();
|
|
60
|
+
const standalone: ReviewSession[] = [];
|
|
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: ReviewSession[] = [];
|
|
81
|
+
const pushThread = (session: ReviewSession): 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 };
|
|
@@ -1,109 +1,169 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The interactive stories catalog (`bun run stories`): a
|
|
3
|
-
* component modules (j/k
|
|
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
4
|
* selected story rendered live in the preview pane. The same story objects
|
|
5
5
|
* back the snapshot harness - what this TUI shows is what the tests assert.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useMemo, useState } from "react";
|
|
9
|
-
import { createCliRenderer } from "@opentui/core";
|
|
8
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { createCliRenderer, type ScrollBoxRenderable } from "@opentui/core";
|
|
10
10
|
import { createRoot, useKeyboard } from "@opentui/react";
|
|
11
11
|
import { DARK } from "../theme";
|
|
12
12
|
import { ThemeProvider } from "./theme-context";
|
|
13
|
-
import { loadStories, type LoadedStory } from "./story";
|
|
14
|
-
import {
|
|
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";
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
stories,
|
|
18
|
-
onExit,
|
|
19
|
-
}: {
|
|
20
|
+
interface StoriesAppProps {
|
|
20
21
|
stories: LoadedStory[];
|
|
21
22
|
onExit: () => void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
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])),
|
|
25
29
|
[stories],
|
|
26
30
|
);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
};
|
|
32
87
|
|
|
33
88
|
useKeyboard((key) => {
|
|
34
89
|
if (key.name === "q") onExit();
|
|
35
|
-
else if (key.name === "j" || key.name === "down")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} else if (key.name === "l" || key.name === "right") {
|
|
42
|
-
setStoryIndex((index) => (index + 1) % moduleStories.length);
|
|
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);
|
|
43
96
|
} else if (key.name === "h" || key.name === "left") {
|
|
44
|
-
|
|
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);
|
|
45
101
|
}
|
|
46
102
|
});
|
|
47
103
|
|
|
48
104
|
return (
|
|
49
105
|
<ThemeProvider theme={DARK}>
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
flexDirection: "column"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
{
|
|
78
|
-
<text
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
style={{
|
|
86
|
-
flexGrow: 1
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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>
|
|
100
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>
|
|
101
164
|
</box>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
<text fg={DARK.textDim}>j/k component · h/l story · q quit</text>
|
|
105
|
-
</box>
|
|
106
|
-
</box>
|
|
165
|
+
}
|
|
166
|
+
/>
|
|
107
167
|
</ThemeProvider>
|
|
108
168
|
);
|
|
109
169
|
}
|
|
@@ -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
|
+
});
|
package/src/components/story.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type React from "react";
|
|
10
|
+
import type { TreeNode } from "./primitives/tree-model";
|
|
10
11
|
|
|
11
12
|
export interface StoryMeta {
|
|
12
13
|
/** Catalog path, e.g. "primitives/Card". */
|
|
@@ -68,6 +69,60 @@ export async function loadStories(): Promise<LoadedStory[]> {
|
|
|
68
69
|
return loaded;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/** A folder node keeps its children array live so the builder can append to it. */
|
|
73
|
+
interface StoryTreeFolder extends TreeNode {
|
|
74
|
+
children: TreeNode[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A component folder holding one story of its own name reads as one row, not a folder wrapping a single leaf.
|
|
78
|
+
function hoistSingleStoryFolders(nodes: TreeNode[]): TreeNode[] {
|
|
79
|
+
return nodes.map((node) => {
|
|
80
|
+
if (node.children === undefined) return node;
|
|
81
|
+
const children = hoistSingleStoryFolders(node.children);
|
|
82
|
+
const onlyChild = children.length === 1 ? children[0] : undefined;
|
|
83
|
+
if (
|
|
84
|
+
onlyChild !== undefined &&
|
|
85
|
+
onlyChild.children === undefined &&
|
|
86
|
+
onlyChild.label === node.label
|
|
87
|
+
) {
|
|
88
|
+
return onlyChild;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { ...node, children };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Nest the flat catalog into folder/component groups by splitting each
|
|
97
|
+
* moduleTitle on "/". Each story becomes a leaf keyed `${moduleTitle}/${storyName}`
|
|
98
|
+
* under its component node; a component holding a single story of its own name
|
|
99
|
+
* collapses so its row IS that story leaf.
|
|
100
|
+
*/
|
|
101
|
+
export function buildStoryTree(stories: LoadedStory[]): TreeNode[] {
|
|
102
|
+
const roots: TreeNode[] = [];
|
|
103
|
+
const foldersByPath = new Map<string, StoryTreeFolder>();
|
|
104
|
+
|
|
105
|
+
for (const { moduleTitle, storyName } of stories) {
|
|
106
|
+
let siblings = roots;
|
|
107
|
+
let path = "";
|
|
108
|
+
|
|
109
|
+
for (const segment of moduleTitle.split("/")) {
|
|
110
|
+
path = path === "" ? segment : `${path}/${segment}`;
|
|
111
|
+
let folder = foldersByPath.get(path);
|
|
112
|
+
if (folder === undefined) {
|
|
113
|
+
folder = { id: path, label: segment, children: [] };
|
|
114
|
+
foldersByPath.set(path, folder);
|
|
115
|
+
siblings.push(folder);
|
|
116
|
+
}
|
|
117
|
+
siblings = folder.children;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
siblings.push({ id: `${moduleTitle}/${storyName}`, label: storyName });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return hoistSingleStoryFolders(roots);
|
|
124
|
+
}
|
|
125
|
+
|
|
71
126
|
/** Component files that must carry stories: every .tsx that is not a story/test/prototype. */
|
|
72
127
|
export function componentFilesMissingStories(): string[] {
|
|
73
128
|
const componentGlob = new Bun.Glob("**/*.tsx");
|
|
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "./story";
|
|
|
4
4
|
import { ThemeProvider } from "./theme-context";
|
|
5
5
|
import { Card } from "./primitives/Card";
|
|
6
6
|
|
|
7
|
-
export const meta: StoryMeta = { title: "
|
|
7
|
+
export const meta: StoryMeta = { title: "Foundations/Theme" };
|
|
8
8
|
|
|
9
9
|
/** A high-contrast variant to prove tokens flow through the provider. */
|
|
10
10
|
const HIGH_CONTRAST: Theme = { ...DARK, accent: "#00ffff", elevated: "#000000", border: "#ffffff" };
|
package/src/config.test.ts
CHANGED
|
@@ -8,14 +8,12 @@ import {
|
|
|
8
8
|
actionFor,
|
|
9
9
|
loadConfig,
|
|
10
10
|
persistAuthorName,
|
|
11
|
-
persistReviewState,
|
|
12
11
|
persistActions,
|
|
13
|
-
|
|
12
|
+
persistPins,
|
|
14
13
|
persistTheme,
|
|
15
14
|
quickActionBody,
|
|
16
15
|
resolveQuickAction,
|
|
17
16
|
} from "./config";
|
|
18
|
-
import { REVIEW_DEFAULT_WIDTH, REVIEW_MAX_WIDTH } from "./review-panel";
|
|
19
17
|
import { DARK } from "./theme";
|
|
20
18
|
import { themeForName } from "./theme-presets";
|
|
21
19
|
|
|
@@ -128,48 +126,19 @@ describe("loadConfig", () => {
|
|
|
128
126
|
}
|
|
129
127
|
});
|
|
130
128
|
|
|
131
|
-
test("[ui]
|
|
132
|
-
// Act
|
|
133
|
-
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
134
|
-
|
|
135
|
-
// Assert
|
|
136
|
-
expect(config.ui.reviewState).toBe("expanded");
|
|
137
|
-
expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("[ui] parses review_width (clamped) and review_state", () => {
|
|
141
|
-
// Arrange
|
|
142
|
-
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
|
|
143
|
-
const path = join(dir, "config.toml");
|
|
144
|
-
|
|
145
|
-
writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
// Act
|
|
149
|
-
const config = loadConfig({ userConfigPath: path });
|
|
150
|
-
|
|
151
|
-
// Assert
|
|
152
|
-
expect(config.ui.reviewWidth).toBe(REVIEW_MAX_WIDTH); // out-of-range width clamps
|
|
153
|
-
expect(config.ui.reviewState).toBe("compact");
|
|
154
|
-
} finally {
|
|
155
|
-
rmSync(dir, { recursive: true, force: true });
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("[ui] ignores an unknown review_state and a non-numeric width", () => {
|
|
129
|
+
test("[ui] diff_view defaults to unified and parses split", () => {
|
|
160
130
|
// Arrange
|
|
161
|
-
const dir = mkdtempSync(join(tmpdir(), "cueloop-
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
|
|
162
132
|
const path = join(dir, "config.toml");
|
|
163
133
|
|
|
164
|
-
writeFileSync(path, `[ui]\
|
|
134
|
+
writeFileSync(path, `[ui]\ndiff_view = "split"\n`);
|
|
165
135
|
|
|
166
136
|
try {
|
|
167
|
-
// Act
|
|
168
|
-
const config = loadConfig({ userConfigPath: path });
|
|
169
|
-
|
|
170
137
|
// Assert
|
|
171
|
-
expect(config.ui.
|
|
172
|
-
|
|
138
|
+
expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.diffView).toBe(
|
|
139
|
+
"unified",
|
|
140
|
+
);
|
|
141
|
+
expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("split");
|
|
173
142
|
} finally {
|
|
174
143
|
rmSync(dir, { recursive: true, force: true });
|
|
175
144
|
}
|
|
@@ -288,28 +257,30 @@ describe("loadConfig", () => {
|
|
|
288
257
|
}
|
|
289
258
|
});
|
|
290
259
|
|
|
291
|
-
test("
|
|
260
|
+
test("persistPins round-trips the pinned-thread ids through the config file", () => {
|
|
292
261
|
// Arrange
|
|
293
|
-
const dir = mkdtempSync(join(tmpdir(), "cueloop-
|
|
262
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-pins-"));
|
|
294
263
|
const path = join(dir, "config.toml");
|
|
295
264
|
|
|
296
265
|
try {
|
|
297
266
|
// Act
|
|
298
|
-
|
|
299
|
-
persistReviewState("hidden", path);
|
|
267
|
+
persistPins(["s_one", "s_two"], path);
|
|
300
268
|
|
|
301
269
|
// Assert
|
|
302
|
-
|
|
270
|
+
expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_one", "s_two"]);
|
|
303
271
|
|
|
304
|
-
|
|
305
|
-
|
|
272
|
+
// unpinning rewrites the list in place, including down to empty
|
|
273
|
+
// Act
|
|
274
|
+
persistPins(["s_two"], path);
|
|
275
|
+
|
|
276
|
+
// Assert
|
|
277
|
+
expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_two"]);
|
|
306
278
|
|
|
307
|
-
// a second write replaces the key in place rather than appending a duplicate
|
|
308
279
|
// Act
|
|
309
|
-
|
|
280
|
+
persistPins([], path);
|
|
310
281
|
|
|
311
282
|
// Assert
|
|
312
|
-
expect(loadConfig({ userConfigPath: path }).ui.
|
|
283
|
+
expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual([]);
|
|
313
284
|
} finally {
|
|
314
285
|
rmSync(dir, { recursive: true, force: true });
|
|
315
286
|
}
|