@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,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,96 @@
|
|
|
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
|
+
/** Drop the folder/file glyph prefix, for a plain text tree (e.g. the settings nav). */
|
|
15
|
+
hideIcons?: boolean;
|
|
16
|
+
indentWidth?: number;
|
|
17
|
+
icons?: TreeIcons;
|
|
18
|
+
onSelect?: (id: string) => void;
|
|
19
|
+
onToggle?: (id: string) => void;
|
|
20
|
+
theme?: Theme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function rowGlyph(isFolder: boolean, expanded: boolean, icons: TreeIcons): string {
|
|
24
|
+
if (!isFolder) return icons.leaf;
|
|
25
|
+
|
|
26
|
+
return expanded ? icons.expanded : icons.collapsed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toneColor(tone: TreeTone, theme: Theme): string {
|
|
30
|
+
if (tone === "green") return theme.green;
|
|
31
|
+
if (tone === "blue") return theme.blue;
|
|
32
|
+
if (tone === "red") return theme.red;
|
|
33
|
+
|
|
34
|
+
return theme.textDim;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Tree({
|
|
38
|
+
nodes,
|
|
39
|
+
expandedIds,
|
|
40
|
+
selectedId,
|
|
41
|
+
flattenEmptyDirectories,
|
|
42
|
+
showStatus,
|
|
43
|
+
hideIcons,
|
|
44
|
+
indentWidth = 2,
|
|
45
|
+
icons = NERD_TREE_ICONS,
|
|
46
|
+
onSelect,
|
|
47
|
+
onToggle,
|
|
48
|
+
theme,
|
|
49
|
+
}: TreeProps): React.ReactNode {
|
|
50
|
+
const tokens = theme ?? DARK;
|
|
51
|
+
const rows = flattenTree(nodes, { expandedIds, flattenEmptyDirectories });
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<box style={{ flexDirection: "column" }}>
|
|
55
|
+
{rows.map((row) => {
|
|
56
|
+
const selected = row.id === selectedId;
|
|
57
|
+
const status = showStatus && row.status !== undefined ? statusMeta(row.status) : null;
|
|
58
|
+
const labelColor = selected
|
|
59
|
+
? tokens.text
|
|
60
|
+
: status
|
|
61
|
+
? toneColor(status.tone, tokens)
|
|
62
|
+
: row.isFolder
|
|
63
|
+
? tokens.text
|
|
64
|
+
: tokens.textMuted;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<box
|
|
68
|
+
key={row.id}
|
|
69
|
+
id={`tree-row-${row.id}`}
|
|
70
|
+
style={{
|
|
71
|
+
flexDirection: "row",
|
|
72
|
+
paddingLeft: 1 + row.depth * indentWidth,
|
|
73
|
+
paddingRight: 1,
|
|
74
|
+
backgroundColor: selected ? tokens.elevated : undefined,
|
|
75
|
+
}}
|
|
76
|
+
onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
|
|
77
|
+
>
|
|
78
|
+
{hideIcons ? null : (
|
|
79
|
+
<text fg={row.isFolder ? tokens.blue : tokens.textDim}>
|
|
80
|
+
{row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
|
|
81
|
+
</text>
|
|
82
|
+
)}
|
|
83
|
+
<box style={{ flexShrink: 1, minWidth: 0 }}>
|
|
84
|
+
<text fg={selected ? tokens.accent : labelColor} truncate>
|
|
85
|
+
{row.label}
|
|
86
|
+
</text>
|
|
87
|
+
</box>
|
|
88
|
+
<box style={{ flexGrow: 1 }} />
|
|
89
|
+
{row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
|
|
90
|
+
{status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
|
|
91
|
+
</box>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</box>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
copy: "",
|
|
14
|
+
unfold: "",
|
|
15
|
+
kebab: "⋮",
|
|
16
|
+
pin: "⚑",
|
|
17
|
+
close: "✕",
|
|
18
|
+
listTree: "",
|
|
19
|
+
sidebarLeft: "",
|
|
20
|
+
sidebarLeftOff: "",
|
|
21
|
+
sidebarRight: "",
|
|
22
|
+
sidebarRightOff: "",
|
|
23
|
+
submit: "",
|
|
24
|
+
zoom: "⛶",
|
|
25
|
+
split: "",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
// The thin rule under each pane header: an overline sits at the top edge of its cell so it hugs the
|
|
29
|
+
// header text (unlike the default light-horizontal, which floats mid-cell); painted in divider gray upstream.
|
|
30
|
+
export const HEADER_UNDERLINE_CHARS = {
|
|
31
|
+
topLeft: "\u250c",
|
|
32
|
+
topRight: "\u2510",
|
|
33
|
+
bottomLeft: "\u2514",
|
|
34
|
+
bottomRight: "\u2518",
|
|
35
|
+
horizontal: "\u203e",
|
|
36
|
+
vertical: "\u2502",
|
|
37
|
+
topT: "\u252c",
|
|
38
|
+
bottomT: "\u2534",
|
|
39
|
+
leftT: "\u251c",
|
|
40
|
+
rightT: "\u2524",
|
|
41
|
+
cross: "\u253c",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
export interface TreeIcons {
|
|
45
|
+
collapsed: string;
|
|
46
|
+
expanded: string;
|
|
47
|
+
leaf: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const NERD_TREE_ICONS: TreeIcons = {
|
|
51
|
+
collapsed: NERD.folderClosed,
|
|
52
|
+
expanded: NERD.folderOpen,
|
|
53
|
+
leaf: NERD.file,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Fallback for terminals without a Nerd Font.
|
|
57
|
+
export const ASCII_TREE_ICONS: TreeIcons = {
|
|
58
|
+
collapsed: "▸",
|
|
59
|
+
expanded: "▾",
|
|
60
|
+
leaf: " ",
|
|
61
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { allFolderIds, flattenTree, statusMeta, type TreeNode } from "./tree-model";
|
|
3
|
+
|
|
4
|
+
const TREE: TreeNode[] = [
|
|
5
|
+
{
|
|
6
|
+
id: "src",
|
|
7
|
+
label: "src",
|
|
8
|
+
children: [
|
|
9
|
+
{ id: "src/a.ts", label: "a.ts", status: "modified" },
|
|
10
|
+
{
|
|
11
|
+
id: "src/nested",
|
|
12
|
+
label: "nested",
|
|
13
|
+
children: [
|
|
14
|
+
{
|
|
15
|
+
id: "src/nested/only",
|
|
16
|
+
label: "only",
|
|
17
|
+
children: [{ id: "deep", label: "deep.ts", status: "added" }],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{ id: "readme", label: "README.md" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
describe("flattenTree", () => {
|
|
27
|
+
test("a collapsed folder shows only itself; expanding it reveals its children", () => {
|
|
28
|
+
// Act
|
|
29
|
+
const collapsed = flattenTree(TREE, { expandedIds: new Set() });
|
|
30
|
+
const rows = flattenTree(TREE, { expandedIds: new Set(["src"]) });
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
33
|
+
expect(collapsed.map((row) => row.label)).toEqual(["src", "README.md"]);
|
|
34
|
+
expect(rows.map((row) => [row.label, row.depth, row.isFolder])).toEqual([
|
|
35
|
+
["src", 0, true],
|
|
36
|
+
["a.ts", 1, false],
|
|
37
|
+
["nested", 1, true],
|
|
38
|
+
["README.md", 0, false],
|
|
39
|
+
]);
|
|
40
|
+
expect(rows.find((row) => row.label === "src")!.expanded).toBe(true);
|
|
41
|
+
expect(rows.find((row) => row.label === "a.ts")!.status).toBe("modified");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("flattenEmptyDirectories collapses a single-subfolder chain into one row", () => {
|
|
45
|
+
// Act - src open but the chain itself collapsed (none of its ids expanded)
|
|
46
|
+
const collapsed = flattenTree(TREE, {
|
|
47
|
+
expandedIds: new Set(["src"]),
|
|
48
|
+
flattenEmptyDirectories: true,
|
|
49
|
+
});
|
|
50
|
+
// Act - expanding via any id along the chain opens it
|
|
51
|
+
const expanded = flattenTree(TREE, {
|
|
52
|
+
expandedIds: new Set(["src", "src/nested/only"]),
|
|
53
|
+
flattenEmptyDirectories: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
|
+
expect(collapsed.map((row) => row.label)).toEqual(["src", "a.ts", "nested/only", "README.md"]);
|
|
58
|
+
expect(expanded.map((row) => row.label)).toEqual([
|
|
59
|
+
"src",
|
|
60
|
+
"a.ts",
|
|
61
|
+
"nested/only",
|
|
62
|
+
"deep.ts",
|
|
63
|
+
"README.md",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("without the flag the chain stays one folder per row", () => {
|
|
68
|
+
// Act
|
|
69
|
+
const rows = flattenTree(TREE, {
|
|
70
|
+
expandedIds: new Set(["src", "src/nested", "src/nested/only"]),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Assert
|
|
74
|
+
expect(rows.map((row) => [row.label, row.depth])).toEqual([
|
|
75
|
+
["src", 0],
|
|
76
|
+
["a.ts", 1],
|
|
77
|
+
["nested", 1],
|
|
78
|
+
["only", 2],
|
|
79
|
+
["deep.ts", 3],
|
|
80
|
+
["README.md", 0],
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("allFolderIds", () => {
|
|
86
|
+
test("collects every folder, not the leaves", () => {
|
|
87
|
+
// Assert
|
|
88
|
+
expect([...allFolderIds(TREE)].sort()).toEqual(["src", "src/nested", "src/nested/only"]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("statusMeta", () => {
|
|
93
|
+
test("maps each status to a letter and a tone", () => {
|
|
94
|
+
// Assert
|
|
95
|
+
expect(statusMeta("added")).toEqual({ letter: "A", tone: "green" });
|
|
96
|
+
expect(statusMeta("deleted")).toEqual({ letter: "D", tone: "red" });
|
|
97
|
+
expect(statusMeta("ignored")).toEqual({ letter: "I", tone: "muted" });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Headless tree model mirrored from @pierre/trees (DOM-only): share the model, render our own.
|
|
2
|
+
|
|
3
|
+
export type GitStatus = "added" | "modified" | "deleted" | "renamed" | "untracked" | "ignored";
|
|
4
|
+
|
|
5
|
+
export interface TreeNode {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
children?: TreeNode[];
|
|
9
|
+
status?: GitStatus;
|
|
10
|
+
badge?: string;
|
|
11
|
+
icon?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FlattenOptions {
|
|
15
|
+
expandedIds: ReadonlySet<string>;
|
|
16
|
+
flattenEmptyDirectories?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface VisibleRow {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
depth: number;
|
|
23
|
+
isFolder: boolean;
|
|
24
|
+
expanded: boolean;
|
|
25
|
+
status?: GitStatus;
|
|
26
|
+
badge?: string;
|
|
27
|
+
icon?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isFolder(node: TreeNode): boolean {
|
|
31
|
+
return node.children !== undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CollapsedChain {
|
|
35
|
+
label: string;
|
|
36
|
+
tail: TreeNode;
|
|
37
|
+
chainIds: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// flattenEmptyDirectories: fold a chain of single-subfolder folders into one row.
|
|
41
|
+
function collapseChain(node: TreeNode): CollapsedChain {
|
|
42
|
+
let tail = node;
|
|
43
|
+
const labels = [node.label];
|
|
44
|
+
const chainIds = [node.id];
|
|
45
|
+
|
|
46
|
+
while (tail.children?.length === 1 && isFolder(tail.children[0]!)) {
|
|
47
|
+
tail = tail.children[0]!;
|
|
48
|
+
labels.push(tail.label);
|
|
49
|
+
chainIds.push(tail.id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { label: labels.join("/"), tail, chainIds };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function flattenTree(nodes: readonly TreeNode[], options: FlattenOptions): VisibleRow[] {
|
|
56
|
+
const rows: VisibleRow[] = [];
|
|
57
|
+
const walk = (list: readonly TreeNode[], depth: number): void => {
|
|
58
|
+
for (const node of list) {
|
|
59
|
+
if (!isFolder(node)) {
|
|
60
|
+
const leaf: VisibleRow = {
|
|
61
|
+
id: node.id,
|
|
62
|
+
label: node.label,
|
|
63
|
+
depth,
|
|
64
|
+
isFolder: false,
|
|
65
|
+
expanded: false,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (node.status !== undefined) leaf.status = node.status;
|
|
69
|
+
if (node.badge !== undefined) leaf.badge = node.badge;
|
|
70
|
+
if (node.icon !== undefined) leaf.icon = node.icon;
|
|
71
|
+
rows.push(leaf);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const collapsed = options.flattenEmptyDirectories
|
|
75
|
+
? collapseChain(node)
|
|
76
|
+
: { label: node.label, tail: node, chainIds: [node.id] };
|
|
77
|
+
// open when any id along a collapsed chain is expanded
|
|
78
|
+
const expanded = collapsed.chainIds.some((id) => options.expandedIds.has(id));
|
|
79
|
+
const row: VisibleRow = {
|
|
80
|
+
id: node.id,
|
|
81
|
+
label: collapsed.label,
|
|
82
|
+
depth,
|
|
83
|
+
isFolder: true,
|
|
84
|
+
expanded,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (node.badge !== undefined) row.badge = node.badge;
|
|
88
|
+
if (node.icon !== undefined) row.icon = node.icon;
|
|
89
|
+
rows.push(row);
|
|
90
|
+
if (expanded) walk(collapsed.tail.children ?? [], depth + 1);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
walk(nodes, 0);
|
|
95
|
+
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function allFolderIds(nodes: readonly TreeNode[]): Set<string> {
|
|
100
|
+
const ids = new Set<string>();
|
|
101
|
+
const walk = (list: readonly TreeNode[]): void => {
|
|
102
|
+
for (const node of list) {
|
|
103
|
+
if (isFolder(node)) {
|
|
104
|
+
ids.add(node.id);
|
|
105
|
+
walk(node.children ?? []);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
walk(nodes);
|
|
111
|
+
|
|
112
|
+
return ids;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type TreeTone = "green" | "blue" | "red" | "muted";
|
|
116
|
+
|
|
117
|
+
export interface StatusMeta {
|
|
118
|
+
letter: string;
|
|
119
|
+
tone: TreeTone;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function statusMeta(status: GitStatus): StatusMeta {
|
|
123
|
+
switch (status) {
|
|
124
|
+
case "added":
|
|
125
|
+
return { letter: "A", tone: "green" };
|
|
126
|
+
case "modified":
|
|
127
|
+
return { letter: "M", tone: "blue" };
|
|
128
|
+
case "deleted":
|
|
129
|
+
return { letter: "D", tone: "red" };
|
|
130
|
+
case "renamed":
|
|
131
|
+
return { letter: "R", tone: "blue" };
|
|
132
|
+
case "untracked":
|
|
133
|
+
return { letter: "U", tone: "green" };
|
|
134
|
+
case "ignored":
|
|
135
|
+
return { letter: "I", tone: "muted" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -4,7 +4,7 @@ import { DEFAULT_QUICK_ACTIONS } from "../config";
|
|
|
4
4
|
import type { Story, StoryMeta } from "./story";
|
|
5
5
|
import { QuickActionsEditor } from "./quick-actions-editor";
|
|
6
6
|
|
|
7
|
-
export const meta: StoryMeta = { title: "QuickActionsEditor" };
|
|
7
|
+
export const meta: StoryMeta = { title: "Chrome/QuickActionsEditor" };
|
|
8
8
|
|
|
9
9
|
/** The editor renders inside the settings body column. */
|
|
10
10
|
function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ReviewSession, WorkspaceKey } from "@cueloop/schema";
|
|
3
|
+
import { groupInbox, projectName } from "./session-tree";
|
|
4
|
+
|
|
5
|
+
function session(id: string, title: string, rootCommit?: string, remote?: string): ReviewSession {
|
|
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
|
+
});
|