@cueloop/client 0.1.0-alpha.65 → 0.1.0-alpha.66
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.diff.test.tsx +8 -73
- package/src/App.plan-review.test.tsx +4 -185
- package/src/App.prototype.test.tsx +1 -8
- package/src/App.readonly.test.tsx +6 -28
- package/src/App.submit-confirm.test.tsx +19 -72
- package/src/App.test.tsx +70 -24
- package/src/App.tsx +177 -254
- package/src/App.walk.test.tsx +1 -1
- package/src/app-screens.tsx +40 -51
- package/src/app-view-model.ts +2 -4
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AppShell.stories.tsx +84 -0
- package/src/components/AppShell.tsx +127 -0
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +147 -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.stories.tsx +1 -1
- package/src/components/InboxList.stories.tsx +23 -8
- package/src/components/InboxList.tsx +45 -19
- 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/PromptDialog.stories.tsx +1 -1
- package/src/components/PrototypeSheet.stories.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +1 -1
- package/src/components/ReviewRail.stories.tsx +1 -1
- package/src/components/SettingsDialog.stories.tsx +7 -1
- package/src/components/SettingsDialog.tsx +112 -66
- 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/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.tsx +49 -0
- package/src/components/ThreadView.slash.test.tsx +113 -0
- package/src/components/ThreadView.stories.tsx +1 -1
- package/src/components/ThreadView.tsx +99 -11
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +57 -0
- package/src/components/Toast.stories.tsx +1 -1
- package/src/components/TreePane.stories.tsx +1 -1
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +348 -138
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/file-tree.test.ts +45 -0
- package/src/components/file-tree.ts +57 -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 +43 -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 +87 -0
- package/src/components/primitives/icons.ts +38 -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 +114 -0
- package/src/components/session-tree.ts +94 -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/session-controller.ts +9 -4
- package/src/theme.ts +4 -4
- package/src/use-settings-dialog.tsx +9 -0
- package/src/App.review-panel.test.tsx +0 -140
- package/src/App.tree.test.tsx +0 -224
|
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "./story";
|
|
|
4
4
|
import { AgentLauncher } from "./agent-launcher";
|
|
5
5
|
import { fixturePlanSession } from "./story-fixtures";
|
|
6
6
|
|
|
7
|
-
export const meta: StoryMeta = { title: "AgentLauncher" };
|
|
7
|
+
export const meta: StoryMeta = { title: "Chrome/AgentLauncher" };
|
|
8
8
|
|
|
9
9
|
/** The launcher expects the rail's narrow, height-constrained column. */
|
|
10
10
|
function RailFrame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { DiffFileContents } from "@cueloop/schema";
|
|
3
|
+
import { buildFileTree } from "./file-tree";
|
|
4
|
+
|
|
5
|
+
function file(path: string, status: DiffFileContents["status"] = "modified"): DiffFileContents {
|
|
6
|
+
return { path, oldContents: "a", newContents: "b", status };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("buildFileTree", () => {
|
|
10
|
+
test("nests files under their directories and keeps the full path as the leaf id", () => {
|
|
11
|
+
// Arrange
|
|
12
|
+
const files = [file("src/app/main.ts"), file("src/app/util.ts"), file("README.md")];
|
|
13
|
+
|
|
14
|
+
// Act
|
|
15
|
+
const tree = buildFileTree(files);
|
|
16
|
+
|
|
17
|
+
// Assert - one src folder over one app folder over two leaves, plus a root file
|
|
18
|
+
expect(tree.map((node) => node.label)).toEqual(["src", "README.md"]);
|
|
19
|
+
const app = tree[0]!.children![0]!;
|
|
20
|
+
|
|
21
|
+
expect(app.label).toBe("app");
|
|
22
|
+
expect(app.children!.map((leaf) => leaf.id)).toEqual(["src/app/main.ts", "src/app/util.ts"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("carries the git status onto each leaf so the tree can tint it", () => {
|
|
26
|
+
// Act
|
|
27
|
+
const tree = buildFileTree([file("a.ts", "added"), file("b.ts", "deleted")]);
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
30
|
+
expect(tree.map((leaf) => leaf.status)).toEqual(["added", "deleted"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("merges files that share a directory into one folder node", () => {
|
|
34
|
+
// Act
|
|
35
|
+
const tree = buildFileTree([file("pkg/one.ts"), file("pkg/two.ts")]);
|
|
36
|
+
|
|
37
|
+
// Assert
|
|
38
|
+
expect(tree).toHaveLength(1);
|
|
39
|
+
expect(tree[0]!.children).toHaveLength(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns an empty tree for no changes", () => {
|
|
43
|
+
expect(buildFileTree([])).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a nested file tree from a diff's changed files, so the Changes column
|
|
3
|
+
* can browse them by directory. Each leaf keeps its repo-relative path as the id
|
|
4
|
+
* (what the diff rows are keyed by) and carries the git status for its tint.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DiffFileContents, DiffFileStatus } from "@cueloop/schema";
|
|
8
|
+
import type { GitStatus, TreeNode } from "./primitives/tree-model";
|
|
9
|
+
|
|
10
|
+
/** The diff file statuses are a subset of the tree's git statuses. */
|
|
11
|
+
function gitStatusOf(status: DiffFileStatus): GitStatus {
|
|
12
|
+
return status;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface MutableTreeNode {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
status?: GitStatus;
|
|
19
|
+
children?: Map<string, MutableTreeNode>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function freeze(node: MutableTreeNode): TreeNode {
|
|
23
|
+
if (node.children === undefined) return { id: node.id, label: node.label, status: node.status };
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
id: node.id,
|
|
27
|
+
label: node.label,
|
|
28
|
+
children: [...node.children.values()].map(freeze),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Group changed files into a directory tree; leaves are keyed by their full path. */
|
|
33
|
+
export function buildFileTree(files: readonly DiffFileContents[]): TreeNode[] {
|
|
34
|
+
const roots = new Map<string, MutableTreeNode>();
|
|
35
|
+
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
const segments = file.path.split("/").filter((segment) => segment.length > 0);
|
|
38
|
+
let level = roots;
|
|
39
|
+
let prefix = "";
|
|
40
|
+
|
|
41
|
+
segments.forEach((segment, index) => {
|
|
42
|
+
prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment;
|
|
43
|
+
const isLeaf = index === segments.length - 1;
|
|
44
|
+
let node = level.get(segment);
|
|
45
|
+
|
|
46
|
+
if (node === undefined) {
|
|
47
|
+
node = isLeaf
|
|
48
|
+
? { id: file.path, label: segment, status: gitStatusOf(file.status) }
|
|
49
|
+
: { id: prefix, label: segment, children: new Map() };
|
|
50
|
+
level.set(segment, node);
|
|
51
|
+
}
|
|
52
|
+
if (!isLeaf) level = node.children!;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [...roots.values()].map(freeze);
|
|
57
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { AppShell } from "../AppShell";
|
|
5
|
+
import { Tree } from "../primitives/Tree";
|
|
6
|
+
import type { TreeNode } from "../primitives/tree-model";
|
|
7
|
+
|
|
8
|
+
export const meta: StoryMeta = { title: "Pages/Workspace" };
|
|
9
|
+
|
|
10
|
+
const NAVIGATION: TreeNode[] = [
|
|
11
|
+
{
|
|
12
|
+
id: "projects",
|
|
13
|
+
label: "Projects",
|
|
14
|
+
children: [
|
|
15
|
+
{
|
|
16
|
+
id: "cueloop",
|
|
17
|
+
label: "cueloop",
|
|
18
|
+
children: [
|
|
19
|
+
{ id: "cueloop/review", label: "Review the app shell" },
|
|
20
|
+
{ id: "cueloop/read", label: "Read Cueloop Repository" },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "threads",
|
|
27
|
+
label: "Threads",
|
|
28
|
+
children: [{ id: "threads/welcome", label: "Welcome to cueloop" }],
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const CHANGED_FILES: TreeNode[] = [
|
|
33
|
+
{
|
|
34
|
+
id: "packages",
|
|
35
|
+
label: "packages",
|
|
36
|
+
children: [
|
|
37
|
+
{
|
|
38
|
+
id: "client",
|
|
39
|
+
label: "client",
|
|
40
|
+
children: [
|
|
41
|
+
{ id: "client/AppShell.tsx", label: "AppShell.tsx", status: "modified" },
|
|
42
|
+
{ id: "client/Workspace.stories.tsx", label: "Workspace.stories.tsx", status: "added" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{ id: "README.md", label: "README.md", status: "modified" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
interface CodeSampleLine {
|
|
51
|
+
number: number;
|
|
52
|
+
code: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CODE_SAMPLE: readonly CodeSampleLine[] = [
|
|
56
|
+
{ number: 17, code: "export function AppShell({" },
|
|
57
|
+
{ number: 18, code: " header," },
|
|
58
|
+
{ number: 19, code: " sidebar," },
|
|
59
|
+
{ number: 20, code: " main," },
|
|
60
|
+
{ number: 21, code: " inspector," },
|
|
61
|
+
{ number: 22, code: "}: AppShellProps) {" },
|
|
62
|
+
{ number: 23, code: " const tokens = theme ?? DARK;" },
|
|
63
|
+
{ number: 24, code: " return <box>{main}</box>;" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function Sidebar(): React.ReactNode {
|
|
67
|
+
return (
|
|
68
|
+
<box style={{ flexDirection: "column", paddingTop: 1 }}>
|
|
69
|
+
<Tree
|
|
70
|
+
nodes={NAVIGATION}
|
|
71
|
+
expandedIds={new Set(["projects", "cueloop", "threads"])}
|
|
72
|
+
selectedId="cueloop/review"
|
|
73
|
+
/>
|
|
74
|
+
</box>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function TranscriptLine({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
79
|
+
return <text fg={DARK.textMuted}>{children}</text>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function CommentRow(): React.ReactNode {
|
|
83
|
+
return (
|
|
84
|
+
<box
|
|
85
|
+
style={{
|
|
86
|
+
border: ["left"],
|
|
87
|
+
borderStyle: "single",
|
|
88
|
+
borderColor: DARK.accent,
|
|
89
|
+
flexDirection: "column",
|
|
90
|
+
paddingLeft: 1,
|
|
91
|
+
marginTop: 1,
|
|
92
|
+
marginBottom: 1,
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<text fg={DARK.textDim}>markus commented on AppShell.tsx</text>
|
|
96
|
+
<text fg={DARK.text}>The single rules read cleaner than an enclosing frame here.</text>
|
|
97
|
+
</box>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Main(): React.ReactNode {
|
|
102
|
+
return (
|
|
103
|
+
<box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
104
|
+
<text fg={DARK.text}>Review the app shell</text>
|
|
105
|
+
<text> </text>
|
|
106
|
+
<TranscriptLine>
|
|
107
|
+
The three-pane layout divides regions with straight rules only.
|
|
108
|
+
</TranscriptLine>
|
|
109
|
+
<TranscriptLine>
|
|
110
|
+
Sidebar navigates, the transcript reads, the inspector shows code.
|
|
111
|
+
</TranscriptLine>
|
|
112
|
+
<CommentRow />
|
|
113
|
+
<TranscriptLine>Header and footer are thin label rows the shell rules itself.</TranscriptLine>
|
|
114
|
+
</box>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function CodePane(): React.ReactNode {
|
|
119
|
+
return (
|
|
120
|
+
<box style={{ flexDirection: "column" }}>
|
|
121
|
+
<text fg={DARK.textMuted}>AppShell.tsx</text>
|
|
122
|
+
<text> </text>
|
|
123
|
+
{CODE_SAMPLE.map((line) => (
|
|
124
|
+
<box key={line.number} style={{ flexDirection: "row" }}>
|
|
125
|
+
<text fg={DARK.textDim}>{String(line.number).padStart(3, " ")} </text>
|
|
126
|
+
<text fg={DARK.text}>{line.code}</text>
|
|
127
|
+
</box>
|
|
128
|
+
))}
|
|
129
|
+
</box>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function Inspector(): React.ReactNode {
|
|
134
|
+
return (
|
|
135
|
+
<box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
136
|
+
<CodePane />
|
|
137
|
+
<text> </text>
|
|
138
|
+
<text fg={DARK.textDim}>Changed files</text>
|
|
139
|
+
<Tree
|
|
140
|
+
nodes={CHANGED_FILES}
|
|
141
|
+
expandedIds={new Set(["packages", "client"])}
|
|
142
|
+
selectedId="client/AppShell.tsx"
|
|
143
|
+
showStatus
|
|
144
|
+
/>
|
|
145
|
+
</box>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function Header(): React.ReactNode {
|
|
150
|
+
return (
|
|
151
|
+
<box style={{ flexDirection: "row", paddingLeft: 1 }}>
|
|
152
|
+
<text fg={DARK.textMuted}>Review the app shell</text>
|
|
153
|
+
<box style={{ flexGrow: 1 }} />
|
|
154
|
+
<text fg={DARK.textDim}>cueloop / main</text>
|
|
155
|
+
</box>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function Footer(): React.ReactNode {
|
|
160
|
+
return (
|
|
161
|
+
<box style={{ flexDirection: "row", paddingLeft: 1 }}>
|
|
162
|
+
<text fg={DARK.textDim}>2 files changed</text>
|
|
163
|
+
<box style={{ flexGrow: 1 }} />
|
|
164
|
+
<text fg={DARK.textDim}>Fable 5</text>
|
|
165
|
+
</box>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const ThreePane: Story = {
|
|
170
|
+
render: () => (
|
|
171
|
+
<AppShell
|
|
172
|
+
sidebar={<Sidebar />}
|
|
173
|
+
main={<Main />}
|
|
174
|
+
mainHeader={<Header />}
|
|
175
|
+
inspector={<Inspector />}
|
|
176
|
+
footer={<Footer />}
|
|
177
|
+
/>
|
|
178
|
+
),
|
|
179
|
+
expectedColors: [DARK.border, DARK.accent],
|
|
180
|
+
size: { width: 160, height: 42 },
|
|
181
|
+
};
|
|
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "../story";
|
|
|
4
4
|
import { Button } from "./Button";
|
|
5
5
|
import { Toolbar } from "./Toolbar";
|
|
6
6
|
|
|
7
|
-
export const meta: StoryMeta = { title: "
|
|
7
|
+
export const meta: StoryMeta = { title: "Primitives/Button" };
|
|
8
8
|
|
|
9
9
|
export const Variants: Story = {
|
|
10
10
|
render: () => (
|
|
@@ -3,7 +3,7 @@ import { DARK } from "../../theme";
|
|
|
3
3
|
import type { Story, StoryMeta } from "../story";
|
|
4
4
|
import { Card } from "./Card";
|
|
5
5
|
|
|
6
|
-
export const meta: StoryMeta = { title: "
|
|
6
|
+
export const meta: StoryMeta = { title: "Primitives/Card" };
|
|
7
7
|
|
|
8
8
|
export const Titled: Story = {
|
|
9
9
|
render: () => (
|
|
@@ -3,7 +3,7 @@ import { DARK } from "../../theme";
|
|
|
3
3
|
import type { Story, StoryMeta } from "../story";
|
|
4
4
|
import { Dialog } from "./Dialog";
|
|
5
5
|
|
|
6
|
-
export const meta: StoryMeta = { title: "
|
|
6
|
+
export const meta: StoryMeta = { title: "Primitives/Dialog" };
|
|
7
7
|
|
|
8
8
|
export const Open: Story = {
|
|
9
9
|
render: () => (
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { IconButton } from "./IconButton";
|
|
5
|
+
import { NERD } from "./icons";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "Primitives/IconButton" };
|
|
8
|
+
|
|
9
|
+
function Row({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
10
|
+
return (
|
|
11
|
+
<box style={{ flexDirection: "row", padding: 1, backgroundColor: DARK.panel }}>{children}</box>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const States: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<Row>
|
|
18
|
+
<IconButton glyph={NERD.settings} onPress={() => {}} marginRight={3} />
|
|
19
|
+
<IconButton glyph={NERD.listTree} active onPress={() => {}} marginRight={3} />
|
|
20
|
+
<IconButton glyph={NERD.diff} disabled marginRight={3} />
|
|
21
|
+
<IconButton glyph={NERD.submit} onPress={() => {}} />
|
|
22
|
+
</Row>
|
|
23
|
+
),
|
|
24
|
+
expectedColors: [DARK.accent, DARK.textMuted, DARK.textDim],
|
|
25
|
+
size: { width: 40, height: 3 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Submit: Story = {
|
|
29
|
+
render: () => (
|
|
30
|
+
<Row>
|
|
31
|
+
<IconButton glyph={NERD.submit} active onPress={() => {}} />
|
|
32
|
+
</Row>
|
|
33
|
+
),
|
|
34
|
+
expectedColors: [DARK.accent],
|
|
35
|
+
size: { width: 20, height: 3 },
|
|
36
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// A clickable single-glyph button for header and footer controls: an icon in a
|
|
2
|
+
// box that calls onPress, painted in the brand accent when active. alignSelf
|
|
3
|
+
// center keeps it on the same row as sibling text or icons.
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { DARK, type Theme } from "../../theme";
|
|
7
|
+
|
|
8
|
+
export interface IconButtonProps {
|
|
9
|
+
glyph: string;
|
|
10
|
+
onPress?: () => void;
|
|
11
|
+
/** Paint the glyph in the brand accent to mark the current mode or view. */
|
|
12
|
+
active?: boolean;
|
|
13
|
+
/** Dim the glyph and ignore presses. */
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
/** Override the resolved foreground (wins over active/disabled colouring). */
|
|
16
|
+
color?: string;
|
|
17
|
+
marginLeft?: number;
|
|
18
|
+
marginRight?: number;
|
|
19
|
+
theme?: Theme;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function IconButton({
|
|
23
|
+
glyph,
|
|
24
|
+
onPress,
|
|
25
|
+
active,
|
|
26
|
+
disabled,
|
|
27
|
+
color,
|
|
28
|
+
marginLeft,
|
|
29
|
+
marginRight,
|
|
30
|
+
theme,
|
|
31
|
+
}: IconButtonProps): React.ReactNode {
|
|
32
|
+
const tokens = theme ?? DARK;
|
|
33
|
+
const resolved = color ?? (disabled ? tokens.textDim : active ? tokens.accent : tokens.textMuted);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<box
|
|
37
|
+
onMouseUp={disabled ? undefined : onPress}
|
|
38
|
+
style={{ alignSelf: "center", marginLeft, marginRight }}
|
|
39
|
+
>
|
|
40
|
+
<text fg={resolved}>{glyph}</text>
|
|
41
|
+
</box>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -3,7 +3,7 @@ import { DARK } from "../../theme";
|
|
|
3
3
|
import type { Story, StoryMeta } from "../story";
|
|
4
4
|
import { StatusBar } from "./StatusBar";
|
|
5
5
|
|
|
6
|
-
export const meta: StoryMeta = { title: "
|
|
6
|
+
export const meta: StoryMeta = { title: "Primitives/StatusBar" };
|
|
7
7
|
|
|
8
8
|
export const HintLine: Story = {
|
|
9
9
|
render: () => <StatusBar>j/k move · v span · c comment · q quit</StatusBar>,
|
|
@@ -3,7 +3,7 @@ import { DARK } from "../../theme";
|
|
|
3
3
|
import type { Story, StoryMeta } from "../story";
|
|
4
4
|
import { Tab, TabList, Tabs } from "./Tabs";
|
|
5
5
|
|
|
6
|
-
export const meta: StoryMeta = { title: "
|
|
6
|
+
export const meta: StoryMeta = { title: "Primitives/Tabs" };
|
|
7
7
|
|
|
8
8
|
export const TwoTabs: Story = {
|
|
9
9
|
render: () => (
|
|
@@ -3,7 +3,7 @@ import type { Story, StoryMeta } from "../story";
|
|
|
3
3
|
import { Button } from "./Button";
|
|
4
4
|
import { Toolbar } from "./Toolbar";
|
|
5
5
|
|
|
6
|
-
export const meta: StoryMeta = { title: "
|
|
6
|
+
export const meta: StoryMeta = { title: "Primitives/Toolbar" };
|
|
7
7
|
|
|
8
8
|
export const ActionRow: Story = {
|
|
9
9
|
render: () => (
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { Tree } from "./Tree";
|
|
5
|
+
import type { TreeNode } from "./tree-model";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "Primitives/Tree" };
|
|
8
|
+
|
|
9
|
+
const FILES: TreeNode[] = [
|
|
10
|
+
{
|
|
11
|
+
id: "packages",
|
|
12
|
+
label: "packages",
|
|
13
|
+
children: [
|
|
14
|
+
{
|
|
15
|
+
id: "schema",
|
|
16
|
+
label: "schema",
|
|
17
|
+
children: [
|
|
18
|
+
{ id: "schema/types.ts", label: "types.ts", status: "modified" },
|
|
19
|
+
{ id: "schema/tree-model.ts", label: "tree-model.ts", status: "added" },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "daemon",
|
|
24
|
+
label: "daemon",
|
|
25
|
+
children: [{ id: "daemon/api.ts", label: "api.ts", status: "modified" }],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{ id: "README.md", label: "README.md" },
|
|
30
|
+
{ id: "old.ts", label: "old.ts", status: "deleted" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const DEEP: TreeNode[] = [
|
|
34
|
+
{
|
|
35
|
+
id: "src",
|
|
36
|
+
label: "src",
|
|
37
|
+
children: [
|
|
38
|
+
{
|
|
39
|
+
id: "src/components",
|
|
40
|
+
label: "components",
|
|
41
|
+
children: [
|
|
42
|
+
{
|
|
43
|
+
id: "src/components/primitives",
|
|
44
|
+
label: "primitives",
|
|
45
|
+
children: [
|
|
46
|
+
{ id: "src/components/primitives/Tree.tsx", label: "Tree.tsx", status: "added" },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const PROJECTS: TreeNode[] = [
|
|
56
|
+
{
|
|
57
|
+
id: "projects",
|
|
58
|
+
label: "Projects",
|
|
59
|
+
children: [
|
|
60
|
+
{
|
|
61
|
+
id: "cueloop",
|
|
62
|
+
label: "cueloop",
|
|
63
|
+
children: [{ id: "cueloop/read", label: "Read Cueloop Repository" }],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "threads",
|
|
69
|
+
label: "Threads",
|
|
70
|
+
children: [{ id: "threads/welcome", label: "Welcome to cueloop" }],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function Frame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
75
|
+
return (
|
|
76
|
+
<box style={{ width: 40, height: "100%", backgroundColor: DARK.panel, paddingTop: 1 }}>
|
|
77
|
+
{children}
|
|
78
|
+
</box>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const FileTree: Story = {
|
|
83
|
+
render: () => (
|
|
84
|
+
<Frame>
|
|
85
|
+
<Tree
|
|
86
|
+
nodes={FILES}
|
|
87
|
+
expandedIds={new Set(["packages", "schema", "daemon"])}
|
|
88
|
+
selectedId="schema/tree-model.ts"
|
|
89
|
+
showStatus
|
|
90
|
+
/>
|
|
91
|
+
</Frame>
|
|
92
|
+
),
|
|
93
|
+
expectedColors: [DARK.accent, DARK.green, DARK.red],
|
|
94
|
+
size: { width: 44, height: 16 },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const FlattenedChain: Story = {
|
|
98
|
+
render: () => (
|
|
99
|
+
<Frame>
|
|
100
|
+
<Tree
|
|
101
|
+
nodes={DEEP}
|
|
102
|
+
expandedIds={new Set(["src", "src/components/primitives"])}
|
|
103
|
+
flattenEmptyDirectories
|
|
104
|
+
showStatus
|
|
105
|
+
/>
|
|
106
|
+
</Frame>
|
|
107
|
+
),
|
|
108
|
+
expectedColors: [DARK.green],
|
|
109
|
+
size: { width: 44, height: 10 },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const ProjectsThreads: Story = {
|
|
113
|
+
render: () => (
|
|
114
|
+
<Frame>
|
|
115
|
+
<Tree
|
|
116
|
+
nodes={PROJECTS}
|
|
117
|
+
expandedIds={new Set(["projects", "cueloop", "threads"])}
|
|
118
|
+
selectedId="cueloop/read"
|
|
119
|
+
/>
|
|
120
|
+
</Frame>
|
|
121
|
+
),
|
|
122
|
+
expectedColors: [DARK.accent],
|
|
123
|
+
size: { width: 44, height: 12 },
|
|
124
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// OpenTUI renderer for the headless tree-model; the caller owns expansion and selection.
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { DARK, type Theme } from "../../theme";
|
|
5
|
+
import { flattenTree, statusMeta, type TreeNode, type TreeTone } from "./tree-model";
|
|
6
|
+
import { NERD_TREE_ICONS, type TreeIcons } from "./icons";
|
|
7
|
+
|
|
8
|
+
export interface TreeProps {
|
|
9
|
+
nodes: readonly TreeNode[];
|
|
10
|
+
expandedIds: ReadonlySet<string>;
|
|
11
|
+
selectedId?: string;
|
|
12
|
+
flattenEmptyDirectories?: boolean;
|
|
13
|
+
showStatus?: boolean;
|
|
14
|
+
indentWidth?: number;
|
|
15
|
+
icons?: TreeIcons;
|
|
16
|
+
onSelect?: (id: string) => void;
|
|
17
|
+
onToggle?: (id: string) => void;
|
|
18
|
+
theme?: Theme;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function rowGlyph(isFolder: boolean, expanded: boolean, icons: TreeIcons): string {
|
|
22
|
+
if (!isFolder) return icons.leaf;
|
|
23
|
+
|
|
24
|
+
return expanded ? icons.expanded : icons.collapsed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toneColor(tone: TreeTone, theme: Theme): string {
|
|
28
|
+
if (tone === "green") return theme.green;
|
|
29
|
+
if (tone === "blue") return theme.blue;
|
|
30
|
+
if (tone === "red") return theme.red;
|
|
31
|
+
|
|
32
|
+
return theme.textDim;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Tree({
|
|
36
|
+
nodes,
|
|
37
|
+
expandedIds,
|
|
38
|
+
selectedId,
|
|
39
|
+
flattenEmptyDirectories,
|
|
40
|
+
showStatus,
|
|
41
|
+
indentWidth = 2,
|
|
42
|
+
icons = NERD_TREE_ICONS,
|
|
43
|
+
onSelect,
|
|
44
|
+
onToggle,
|
|
45
|
+
theme,
|
|
46
|
+
}: TreeProps): React.ReactNode {
|
|
47
|
+
const tokens = theme ?? DARK;
|
|
48
|
+
const rows = flattenTree(nodes, { expandedIds, flattenEmptyDirectories });
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<box style={{ flexDirection: "column" }}>
|
|
52
|
+
{rows.map((row) => {
|
|
53
|
+
const selected = row.id === selectedId;
|
|
54
|
+
const status = showStatus && row.status !== undefined ? statusMeta(row.status) : null;
|
|
55
|
+
const labelColor = selected
|
|
56
|
+
? tokens.text
|
|
57
|
+
: status
|
|
58
|
+
? toneColor(status.tone, tokens)
|
|
59
|
+
: row.isFolder
|
|
60
|
+
? tokens.text
|
|
61
|
+
: tokens.textMuted;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<box
|
|
65
|
+
key={row.id}
|
|
66
|
+
id={`tree-row-${row.id}`}
|
|
67
|
+
style={{
|
|
68
|
+
flexDirection: "row",
|
|
69
|
+
paddingLeft: 1 + row.depth * indentWidth,
|
|
70
|
+
paddingRight: 1,
|
|
71
|
+
backgroundColor: selected ? tokens.elevated : undefined,
|
|
72
|
+
}}
|
|
73
|
+
onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
|
|
74
|
+
>
|
|
75
|
+
<text fg={row.isFolder ? tokens.blue : tokens.textDim}>
|
|
76
|
+
{row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
|
|
77
|
+
</text>
|
|
78
|
+
<text fg={selected ? tokens.accent : labelColor}>{row.label}</text>
|
|
79
|
+
<box style={{ flexGrow: 1 }} />
|
|
80
|
+
{row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
|
|
81
|
+
{status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
|
|
82
|
+
</box>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</box>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Nerd Font glyphs (Private Use Area / codicons) plus the plus-minus diff mark; render as icons under a Nerd Font, tofu without one.
|
|
2
|
+
|
|
3
|
+
export const NERD = {
|
|
4
|
+
folderClosed: "",
|
|
5
|
+
folderOpen: "",
|
|
6
|
+
file: "",
|
|
7
|
+
chevronRight: "",
|
|
8
|
+
chevronDown: "",
|
|
9
|
+
settings: "",
|
|
10
|
+
search: "",
|
|
11
|
+
expand: "",
|
|
12
|
+
diff: "±",
|
|
13
|
+
listTree: "",
|
|
14
|
+
sidebarLeft: "",
|
|
15
|
+
sidebarLeftOff: "",
|
|
16
|
+
sidebarRight: "",
|
|
17
|
+
sidebarRightOff: "",
|
|
18
|
+
submit: "",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export interface TreeIcons {
|
|
22
|
+
collapsed: string;
|
|
23
|
+
expanded: string;
|
|
24
|
+
leaf: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const NERD_TREE_ICONS: TreeIcons = {
|
|
28
|
+
collapsed: NERD.folderClosed,
|
|
29
|
+
expanded: NERD.folderOpen,
|
|
30
|
+
leaf: NERD.file,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Fallback for terminals without a Nerd Font.
|
|
34
|
+
export const ASCII_TREE_ICONS: TreeIcons = {
|
|
35
|
+
collapsed: "▸",
|
|
36
|
+
expanded: "▾",
|
|
37
|
+
leaf: " ",
|
|
38
|
+
};
|