@cueloop/client 0.1.0-alpha.66 → 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 +71 -39
- package/src/App.plan-review.test.tsx +20 -21
- package/src/App.readonly.test.tsx +7 -3
- package/src/App.test.tsx +73 -14
- package/src/App.tsx +375 -129
- package/src/App.walk.test.tsx +14 -15
- 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 +176 -45
- package/src/app-view-model.ts +7 -65
- 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 +47 -42
- package/src/components/AppShell.tsx +212 -100
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/ChangesColumn.tsx +49 -26
- package/src/components/DiffSheet.scroll.test.tsx +38 -55
- package/src/components/DiffSheet.split.test.tsx +140 -0
- package/src/components/DiffSheet.stories.tsx +55 -53
- 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.tsx +159 -30
- 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.tsx +8 -1
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/SettingsDialog.tsx +2 -1
- 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 +2 -2
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +12 -7
- package/src/components/ThreadView.tsx +62 -1440
- package/src/components/ThreadsSidebar.tsx +14 -1
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/WelcomeSurface.stories.tsx +12 -0
- package/src/components/WelcomeSurface.tsx +52 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
- 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 +15 -0
- package/src/components/file-tree.ts +59 -4
- package/src/components/pages/Workspace.stories.tsx +2 -2
- package/src/components/primitives/IconButton.tsx +21 -2
- package/src/components/primitives/Tree.tsx +13 -4
- package/src/components/primitives/icons.ts +27 -4
- package/src/components/session-tree.test.ts +20 -0
- package/src/components/session-tree.ts +16 -2
- package/src/components/stories-app.tsx +2 -2
- 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 +153 -29
- 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/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 +14 -24
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/view-diff.ts +114 -0
- 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
|
@@ -1,39 +1,171 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pending-review inbox: a Projects
|
|
3
|
-
* the keyboard grammar - the cursor indexes the flat thread order and this
|
|
4
|
-
* component
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The pending-review inbox: a Pinned / Projects / Threads tree. Selection stays
|
|
3
|
+
* with the keyboard grammar - the cursor indexes the flat thread order and this
|
|
4
|
+
* component renders the snapshot. A hovered or selected thread reveals a kebab
|
|
5
|
+
* that opens an inline pin / rename / delete menu; long titles fade on the right
|
|
6
|
+
* instead of wrapping. App supplies the surrounding chrome.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import React from "react";
|
|
9
|
+
import React, { useState } from "react";
|
|
10
10
|
import type { Theme } from "../theme";
|
|
11
11
|
import { useComponentTheme } from "./theme-context";
|
|
12
|
-
import {
|
|
12
|
+
import { IconButton } from "./primitives/IconButton";
|
|
13
13
|
import { NERD } from "./primitives/icons";
|
|
14
|
-
import
|
|
14
|
+
import { fadeTitle } from "./fade-title";
|
|
15
|
+
import { threadTitle, type InboxRow } from "./session-tree";
|
|
15
16
|
|
|
16
17
|
export interface InboxListProps {
|
|
17
18
|
rows: InboxRow[];
|
|
18
19
|
cursor: number;
|
|
19
20
|
/** The open thread's id; highlights it instead of the cursor (the left sidebar case). */
|
|
20
21
|
activeId?: string;
|
|
22
|
+
/** Ids of pinned threads; a pinned row carries the pin glyph and the menu offers Unpin. */
|
|
23
|
+
pinnedIds?: ReadonlySet<string>;
|
|
24
|
+
/** The column width, so titles fade to fit one line. */
|
|
25
|
+
width?: number;
|
|
21
26
|
/** Open a thread by clicking its row (the left sidebar case). */
|
|
22
27
|
onSelect?: (sessionId: string) => void;
|
|
23
|
-
/** Ask to delete a thread (the
|
|
28
|
+
/** Ask to delete a thread (the menu's Delete, wired to the confirm dialog). */
|
|
24
29
|
onRequestDelete?: (id: string, title: string) => void;
|
|
30
|
+
/** Toggle a thread's pinned state (the menu's Pin/Unpin). */
|
|
31
|
+
onPin?: (id: string) => void;
|
|
32
|
+
/** Rename a thread's title (the menu's Rename). */
|
|
33
|
+
onRename?: (id: string, title: string) => void;
|
|
25
34
|
theme?: Theme;
|
|
26
35
|
}
|
|
27
36
|
|
|
37
|
+
function ActionsMenu({
|
|
38
|
+
pinned,
|
|
39
|
+
onPin,
|
|
40
|
+
onRename,
|
|
41
|
+
onDelete,
|
|
42
|
+
tokens,
|
|
43
|
+
}: {
|
|
44
|
+
pinned: boolean;
|
|
45
|
+
onPin?: () => void;
|
|
46
|
+
onRename?: () => void;
|
|
47
|
+
onDelete?: () => void;
|
|
48
|
+
tokens: Theme;
|
|
49
|
+
}): React.ReactNode {
|
|
50
|
+
const item = (
|
|
51
|
+
glyph: string,
|
|
52
|
+
label: string,
|
|
53
|
+
color: string,
|
|
54
|
+
onPick?: () => void,
|
|
55
|
+
): React.ReactNode =>
|
|
56
|
+
onPick !== undefined ? (
|
|
57
|
+
<box onMouseUp={onPick} style={{ flexDirection: "row", paddingLeft: 4, paddingRight: 1 }}>
|
|
58
|
+
<text fg={color}>{`${glyph} ${label}`}</text>
|
|
59
|
+
</box>
|
|
60
|
+
) : null;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<box
|
|
64
|
+
style={{
|
|
65
|
+
flexDirection: "column",
|
|
66
|
+
marginLeft: 3,
|
|
67
|
+
borderStyle: "single",
|
|
68
|
+
border: ["left"],
|
|
69
|
+
borderColor: tokens.border,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{item(NERD.pin, pinned ? "Unpin" : "Pin", tokens.text, onPin)}
|
|
73
|
+
{item(NERD.file, "Rename", tokens.text, onRename)}
|
|
74
|
+
{item(NERD.close, "Delete", tokens.red, onDelete)}
|
|
75
|
+
</box>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ThreadRowProps {
|
|
80
|
+
title: string;
|
|
81
|
+
selected: boolean;
|
|
82
|
+
pinned: boolean;
|
|
83
|
+
titleWidth: number;
|
|
84
|
+
menuOpen: boolean;
|
|
85
|
+
onToggleMenu: () => void;
|
|
86
|
+
onSelect?: () => void;
|
|
87
|
+
onPin?: () => void;
|
|
88
|
+
onRename?: () => void;
|
|
89
|
+
onDelete?: () => void;
|
|
90
|
+
tokens: Theme;
|
|
91
|
+
theme?: Theme;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ThreadRow(props: ThreadRowProps): React.ReactNode {
|
|
95
|
+
const { title, selected, pinned, titleWidth, menuOpen, onToggleMenu, tokens, theme } = props;
|
|
96
|
+
const [hovered, setHovered] = useState(false);
|
|
97
|
+
// the tail fades toward the row's own background, so it masks out instead of
|
|
98
|
+
// leaving a colored band over the selected (elevated) or hovered (panel) row
|
|
99
|
+
const rowBackground = selected ? tokens.elevated : hovered ? tokens.panel : tokens.background;
|
|
100
|
+
const segments = fadeTitle(
|
|
101
|
+
title,
|
|
102
|
+
titleWidth,
|
|
103
|
+
selected ? tokens.accent : tokens.textMuted,
|
|
104
|
+
rowBackground,
|
|
105
|
+
);
|
|
106
|
+
const hasActions =
|
|
107
|
+
props.onPin !== undefined || props.onRename !== undefined || props.onDelete !== undefined;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<box style={{ flexDirection: "column" }} onMouseOut={() => setHovered(false)}>
|
|
111
|
+
<box
|
|
112
|
+
onMouseUp={props.onSelect}
|
|
113
|
+
onMouseOver={() => setHovered(true)}
|
|
114
|
+
style={{
|
|
115
|
+
flexDirection: "row",
|
|
116
|
+
backgroundColor: selected ? tokens.elevated : hovered ? tokens.panel : undefined,
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<text>
|
|
120
|
+
<span fg={tokens.textDim}>{pinned ? ` ${NERD.pin} ` : " "}</span>
|
|
121
|
+
{segments.map((segment, index) => (
|
|
122
|
+
<span key={index} fg={segment.fg}>
|
|
123
|
+
{segment.text}
|
|
124
|
+
</span>
|
|
125
|
+
))}
|
|
126
|
+
</text>
|
|
127
|
+
<box style={{ flexGrow: 1 }} />
|
|
128
|
+
{(hovered || menuOpen) && hasActions ? (
|
|
129
|
+
<IconButton
|
|
130
|
+
glyph={NERD.kebab}
|
|
131
|
+
active={menuOpen}
|
|
132
|
+
onPress={onToggleMenu}
|
|
133
|
+
marginRight={1}
|
|
134
|
+
theme={theme}
|
|
135
|
+
/>
|
|
136
|
+
) : null}
|
|
137
|
+
</box>
|
|
138
|
+
{menuOpen ? (
|
|
139
|
+
<ActionsMenu
|
|
140
|
+
pinned={pinned}
|
|
141
|
+
onPin={props.onPin}
|
|
142
|
+
onRename={props.onRename}
|
|
143
|
+
onDelete={props.onDelete}
|
|
144
|
+
tokens={tokens}
|
|
145
|
+
/>
|
|
146
|
+
) : null}
|
|
147
|
+
</box>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
28
151
|
export function InboxList({
|
|
29
152
|
rows,
|
|
30
153
|
cursor,
|
|
31
154
|
activeId,
|
|
155
|
+
pinnedIds,
|
|
156
|
+
width = 30,
|
|
32
157
|
onSelect,
|
|
33
158
|
onRequestDelete,
|
|
159
|
+
onPin,
|
|
160
|
+
onRename,
|
|
34
161
|
theme,
|
|
35
162
|
}: InboxListProps): React.ReactNode {
|
|
36
163
|
const tokens = useComponentTheme(theme);
|
|
164
|
+
const [actionsForId, setActionsForId] = useState<string | null>(null);
|
|
165
|
+
// leave room for the 3-char prefix, the border, and the hover kebab so a row
|
|
166
|
+
// never wraps: the title fades to fit whatever is left
|
|
167
|
+
const titleWidth = Math.max(8, width - 9);
|
|
168
|
+
const closeMenu = (): void => setActionsForId(null);
|
|
37
169
|
|
|
38
170
|
return (
|
|
39
171
|
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
@@ -58,30 +190,27 @@ export function InboxList({
|
|
|
58
190
|
);
|
|
59
191
|
}
|
|
60
192
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
const title = row.session.artifact.meta.title ?? row.session.id;
|
|
193
|
+
const id = row.session.id;
|
|
194
|
+
const title = threadTitle(row.session);
|
|
64
195
|
|
|
65
196
|
return (
|
|
66
|
-
<
|
|
197
|
+
<ThreadRow
|
|
67
198
|
key={row.id}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
) : null}
|
|
84
|
-
</box>
|
|
199
|
+
title={title}
|
|
200
|
+
selected={activeId !== undefined ? activeId === id : row.selectionIndex === cursor}
|
|
201
|
+
pinned={pinnedIds?.has(id) ?? false}
|
|
202
|
+
titleWidth={titleWidth}
|
|
203
|
+
menuOpen={actionsForId === id}
|
|
204
|
+
onToggleMenu={() => setActionsForId(actionsForId === id ? null : id)}
|
|
205
|
+
onSelect={onSelect ? () => onSelect(id) : undefined}
|
|
206
|
+
onPin={onPin ? () => (onPin(id), closeMenu()) : undefined}
|
|
207
|
+
onRename={onRename ? () => (onRename(id, title), closeMenu()) : undefined}
|
|
208
|
+
onDelete={
|
|
209
|
+
onRequestDelete ? () => (onRequestDelete(id, title), closeMenu()) : undefined
|
|
210
|
+
}
|
|
211
|
+
tokens={tokens}
|
|
212
|
+
theme={theme}
|
|
213
|
+
/>
|
|
85
214
|
);
|
|
86
215
|
})
|
|
87
216
|
)}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { PaneGrid } from "./PaneGrid";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Layout/PaneGrid" };
|
|
7
|
+
|
|
8
|
+
function Sidebar(): React.ReactNode {
|
|
9
|
+
return (
|
|
10
|
+
<box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
11
|
+
<text fg={DARK.textDim}>Projects</text>
|
|
12
|
+
<text fg={DARK.text}> cueloop</text>
|
|
13
|
+
<box style={{ backgroundColor: DARK.elevated }}>
|
|
14
|
+
<text fg={DARK.accent}> Read Cueloop Repository</text>
|
|
15
|
+
</box>
|
|
16
|
+
<text> </text>
|
|
17
|
+
<text fg={DARK.textDim}>Threads</text>
|
|
18
|
+
<text fg={DARK.textMuted}> Welcome to cueloop</text>
|
|
19
|
+
</box>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function Main(): React.ReactNode {
|
|
24
|
+
return (
|
|
25
|
+
<box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
26
|
+
<text fg={DARK.text}>read the repo</text>
|
|
27
|
+
<text> </text>
|
|
28
|
+
<text fg={DARK.textMuted}>Repository context loaded. main is clean.</text>
|
|
29
|
+
<text> </text>
|
|
30
|
+
<text fg={DARK.text}>Core architecture</text>
|
|
31
|
+
<text fg={DARK.textMuted}> - packages/schema is the dependency root</text>
|
|
32
|
+
<text fg={DARK.textMuted}> - packages/daemon owns session state</text>
|
|
33
|
+
</box>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Inspector(): React.ReactNode {
|
|
38
|
+
return (
|
|
39
|
+
<box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
40
|
+
<text fg={DARK.text}>cueloop/main</text>
|
|
41
|
+
<text fg={DARK.textDim}> No changes</text>
|
|
42
|
+
</box>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Header(): React.ReactNode {
|
|
47
|
+
return (
|
|
48
|
+
<box style={{ flexDirection: "row", paddingLeft: 1 }}>
|
|
49
|
+
<text fg={DARK.textMuted}>Read Cueloop Repository</text>
|
|
50
|
+
<box style={{ flexGrow: 1 }} />
|
|
51
|
+
<text fg={DARK.textDim}>markdown.test.ts</text>
|
|
52
|
+
</box>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Footer(): React.ReactNode {
|
|
57
|
+
return (
|
|
58
|
+
<box style={{ flexDirection: "row", paddingLeft: 1 }}>
|
|
59
|
+
<text fg={DARK.textDim}>cueloop / main</text>
|
|
60
|
+
<box style={{ flexGrow: 1 }} />
|
|
61
|
+
<text fg={DARK.textDim}>Fable 5</text>
|
|
62
|
+
</box>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const ThreePane: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<PaneGrid
|
|
69
|
+
sidebar={<Sidebar />}
|
|
70
|
+
main={<Main />}
|
|
71
|
+
mainHeader={<Header />}
|
|
72
|
+
inspector={<Inspector />}
|
|
73
|
+
footer={<Footer />}
|
|
74
|
+
/>
|
|
75
|
+
),
|
|
76
|
+
expectedColors: [DARK.border, DARK.accent],
|
|
77
|
+
size: { width: 120, height: 28 },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const SidebarAndMain: Story = {
|
|
81
|
+
render: () => <PaneGrid sidebar={<Sidebar />} main={<Main />} />,
|
|
82
|
+
expectedColors: [DARK.border],
|
|
83
|
+
size: { width: 90, height: 20 },
|
|
84
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// A generic pane grid: a row of columns, each stacking a header cell over its body, with the
|
|
2
|
+
// vertical rules run full height and every header cell's bottom rule joined into one horizontal
|
|
3
|
+
// line. Rules only - no enclosing frames. This is the layout primitive for dev surfaces (the
|
|
4
|
+
// storybook runner); the product's four-pane workbench is AppShell.
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { DARK, type Theme } from "../theme";
|
|
8
|
+
import type { BorderSides } from "@opentui/core";
|
|
9
|
+
|
|
10
|
+
export interface PaneGridProps {
|
|
11
|
+
headerHeight?: number;
|
|
12
|
+
sidebar?: React.ReactNode;
|
|
13
|
+
sidebarHeader?: React.ReactNode;
|
|
14
|
+
sidebarWidth?: number;
|
|
15
|
+
main: React.ReactNode;
|
|
16
|
+
mainHeader?: React.ReactNode;
|
|
17
|
+
inspector?: React.ReactNode;
|
|
18
|
+
inspectorHeader?: React.ReactNode;
|
|
19
|
+
inspectorWidth?: number;
|
|
20
|
+
footer?: React.ReactNode;
|
|
21
|
+
theme?: Theme;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PaneColumnProps {
|
|
25
|
+
width?: number;
|
|
26
|
+
grow?: boolean;
|
|
27
|
+
side?: BorderSides;
|
|
28
|
+
header: React.ReactNode;
|
|
29
|
+
body: React.ReactNode;
|
|
30
|
+
headerHeight: number;
|
|
31
|
+
theme: Theme;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function PaneColumn({
|
|
35
|
+
width,
|
|
36
|
+
grow,
|
|
37
|
+
side,
|
|
38
|
+
header,
|
|
39
|
+
body,
|
|
40
|
+
headerHeight,
|
|
41
|
+
theme,
|
|
42
|
+
}: PaneColumnProps): React.ReactNode {
|
|
43
|
+
return (
|
|
44
|
+
<box
|
|
45
|
+
style={{
|
|
46
|
+
flexDirection: "column",
|
|
47
|
+
...(grow ? { flexGrow: 1 } : { width }),
|
|
48
|
+
borderStyle: "single",
|
|
49
|
+
border: side ? [side] : [],
|
|
50
|
+
borderColor: theme.border,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<box
|
|
54
|
+
style={{
|
|
55
|
+
height: headerHeight,
|
|
56
|
+
borderStyle: "single",
|
|
57
|
+
border: ["bottom"],
|
|
58
|
+
borderColor: theme.border,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{header}
|
|
62
|
+
</box>
|
|
63
|
+
<box style={{ flexGrow: 1 }}>{body}</box>
|
|
64
|
+
</box>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function PaneGrid({
|
|
69
|
+
headerHeight = 3,
|
|
70
|
+
sidebar,
|
|
71
|
+
sidebarHeader,
|
|
72
|
+
sidebarWidth = 28,
|
|
73
|
+
main,
|
|
74
|
+
mainHeader,
|
|
75
|
+
inspector,
|
|
76
|
+
inspectorHeader,
|
|
77
|
+
inspectorWidth = 40,
|
|
78
|
+
footer,
|
|
79
|
+
theme,
|
|
80
|
+
}: PaneGridProps): React.ReactNode {
|
|
81
|
+
const tokens = theme ?? DARK;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<box
|
|
85
|
+
style={{
|
|
86
|
+
flexDirection: "column",
|
|
87
|
+
width: "100%",
|
|
88
|
+
height: "100%",
|
|
89
|
+
backgroundColor: tokens.background,
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
93
|
+
{sidebar !== undefined ? (
|
|
94
|
+
<PaneColumn
|
|
95
|
+
width={sidebarWidth}
|
|
96
|
+
side="right"
|
|
97
|
+
header={sidebarHeader}
|
|
98
|
+
body={sidebar}
|
|
99
|
+
headerHeight={headerHeight}
|
|
100
|
+
theme={tokens}
|
|
101
|
+
/>
|
|
102
|
+
) : null}
|
|
103
|
+
<PaneColumn
|
|
104
|
+
grow
|
|
105
|
+
header={mainHeader}
|
|
106
|
+
body={main}
|
|
107
|
+
headerHeight={headerHeight}
|
|
108
|
+
theme={tokens}
|
|
109
|
+
/>
|
|
110
|
+
{inspector !== undefined ? (
|
|
111
|
+
<PaneColumn
|
|
112
|
+
width={inspectorWidth}
|
|
113
|
+
side="left"
|
|
114
|
+
header={inspectorHeader}
|
|
115
|
+
body={inspector}
|
|
116
|
+
headerHeight={headerHeight}
|
|
117
|
+
theme={tokens}
|
|
118
|
+
/>
|
|
119
|
+
) : null}
|
|
120
|
+
</box>
|
|
121
|
+
{footer !== undefined ? (
|
|
122
|
+
<box style={{ borderStyle: "single", border: ["top"], borderColor: tokens.border }}>
|
|
123
|
+
{footer}
|
|
124
|
+
</box>
|
|
125
|
+
) : null}
|
|
126
|
+
</box>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { PanelColumn, FileTab } from "./PanelColumn";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Surfaces/PanelColumn" };
|
|
7
|
+
|
|
8
|
+
export const WithFileTab: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<box style={{ width: "100%", height: "100%", flexDirection: "row" }}>
|
|
11
|
+
<PanelColumn
|
|
12
|
+
width={40}
|
|
13
|
+
border="left"
|
|
14
|
+
header={<FileTab label="Welcome" active onClose={() => {}} theme={DARK} />}
|
|
15
|
+
theme={DARK}
|
|
16
|
+
>
|
|
17
|
+
<box style={{ paddingLeft: 1, paddingTop: 1 }}>
|
|
18
|
+
<text fg={DARK.textDim}>panel body</text>
|
|
19
|
+
</box>
|
|
20
|
+
</PanelColumn>
|
|
21
|
+
</box>
|
|
22
|
+
),
|
|
23
|
+
expectedColors: [DARK.accent, DARK.border],
|
|
24
|
+
size: { width: 50, height: 8 },
|
|
25
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A workbench panel: one full-height grid column with a file-tab header cell on
|
|
2
|
+
// top and a body below. The header row carries the tabs on the left and optional
|
|
3
|
+
// controls on the right, underlined by a thin rule in the same divider gray so the
|
|
4
|
+
// header bottoms join the side rules into one grid. The side border runs from the top.
|
|
5
|
+
|
|
6
|
+
import React, { useState } from "react";
|
|
7
|
+
import type { Theme } from "../theme";
|
|
8
|
+
import { NERD, HEADER_UNDERLINE_CHARS } from "./primitives/icons";
|
|
9
|
+
|
|
10
|
+
export interface FileTabProps {
|
|
11
|
+
label: string;
|
|
12
|
+
active?: boolean;
|
|
13
|
+
/** When set, a close control reveals on hover (a disposable tab). */
|
|
14
|
+
onClose?: () => void;
|
|
15
|
+
theme?: Theme;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A file tab: the label in the accent when active, with a hover-revealed close. */
|
|
19
|
+
export function FileTab({ label, active, onClose, theme }: FileTabProps): React.ReactNode {
|
|
20
|
+
const [hovered, setHovered] = useState(false);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<box
|
|
24
|
+
style={{ flexDirection: "row", paddingRight: 1 }}
|
|
25
|
+
onMouseOver={() => setHovered(true)}
|
|
26
|
+
onMouseOut={() => setHovered(false)}
|
|
27
|
+
>
|
|
28
|
+
<text fg={active ? theme?.accent : theme?.textDim}>{label}</text>
|
|
29
|
+
{onClose ? (
|
|
30
|
+
<box onMouseUp={onClose} style={{ paddingLeft: 1 }}>
|
|
31
|
+
<text fg={theme?.textDim}>{hovered ? NERD.close : " "}</text>
|
|
32
|
+
</box>
|
|
33
|
+
) : null}
|
|
34
|
+
</box>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PanelColumnProps {
|
|
39
|
+
/** Fixed column width; omit for the flex-growing center panel. */
|
|
40
|
+
width?: number;
|
|
41
|
+
/** Draw the divider on this side (the sidebar borders right, the rest border left). */
|
|
42
|
+
border?: "left" | "right";
|
|
43
|
+
/** Tabs and controls for the header cell. */
|
|
44
|
+
header: React.ReactNode;
|
|
45
|
+
/** Right-aligned header controls (toggles, search). */
|
|
46
|
+
headerRight?: React.ReactNode;
|
|
47
|
+
children: React.ReactNode;
|
|
48
|
+
theme?: Theme;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function PanelColumn({
|
|
52
|
+
width,
|
|
53
|
+
border,
|
|
54
|
+
header,
|
|
55
|
+
headerRight,
|
|
56
|
+
children,
|
|
57
|
+
theme,
|
|
58
|
+
}: PanelColumnProps): React.ReactNode {
|
|
59
|
+
return (
|
|
60
|
+
<box
|
|
61
|
+
style={{
|
|
62
|
+
width,
|
|
63
|
+
// grow panes split evenly and clip wide content (a long diff line) rather than stretching
|
|
64
|
+
flexGrow: width === undefined ? 1 : undefined,
|
|
65
|
+
flexBasis: width === undefined ? 0 : undefined,
|
|
66
|
+
minWidth: 0,
|
|
67
|
+
flexDirection: "column",
|
|
68
|
+
borderStyle: "single",
|
|
69
|
+
border: border ? [border] : [],
|
|
70
|
+
borderColor: theme?.border,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<box
|
|
74
|
+
customBorderChars={HEADER_UNDERLINE_CHARS}
|
|
75
|
+
style={{
|
|
76
|
+
flexDirection: "row",
|
|
77
|
+
height: 2,
|
|
78
|
+
paddingLeft: 1,
|
|
79
|
+
// right controls need the same breathing room as the left, or the rightmost icon renders
|
|
80
|
+
// squeezed against the terminal's last column
|
|
81
|
+
paddingRight: 1,
|
|
82
|
+
backgroundColor: theme?.panel,
|
|
83
|
+
borderStyle: "single",
|
|
84
|
+
border: ["bottom"],
|
|
85
|
+
borderColor: theme?.border,
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
{header}
|
|
89
|
+
<box style={{ flexGrow: 1 }} />
|
|
90
|
+
{headerRight}
|
|
91
|
+
</box>
|
|
92
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>{children}</box>
|
|
93
|
+
</box>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ProjectTreeView } from "./ProjectTreeView";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Surfaces/ProjectTreeView" };
|
|
7
|
+
|
|
8
|
+
const SAMPLE = [
|
|
9
|
+
"packages/client/src/App.tsx",
|
|
10
|
+
"packages/client/src/components/AppShell.tsx",
|
|
11
|
+
"packages/daemon/src/api.ts",
|
|
12
|
+
"README.md",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const Loaded: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<ProjectTreeView
|
|
18
|
+
loadFiles={() => Promise.resolve(SAMPLE)}
|
|
19
|
+
onSelectFile={() => {}}
|
|
20
|
+
theme={DARK}
|
|
21
|
+
/>
|
|
22
|
+
),
|
|
23
|
+
expectedColors: [DARK.textDim],
|
|
24
|
+
size: { width: 40, height: 16 },
|
|
25
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { ProjectTreeView } from "./ProjectTreeView";
|
|
5
|
+
import { clickText, waitForText } from "../test-support";
|
|
6
|
+
import { DARK } from "../theme";
|
|
7
|
+
|
|
8
|
+
test("renders the loaded files as a tree and opens a file on click", async () => {
|
|
9
|
+
const opened: string[] = [];
|
|
10
|
+
const setup = await testRender(
|
|
11
|
+
<ProjectTreeView
|
|
12
|
+
loadFiles={() => Promise.resolve(["src/App.tsx", "README.md"])}
|
|
13
|
+
onSelectFile={(path) => opened.push(path)}
|
|
14
|
+
theme={DARK}
|
|
15
|
+
/>,
|
|
16
|
+
{ width: 40, height: 16 },
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
await waitForText(setup, "README.md");
|
|
20
|
+
await clickText(setup, "README.md");
|
|
21
|
+
|
|
22
|
+
expect(opened).toContain("README.md");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("shows an empty hint when there are no files", async () => {
|
|
26
|
+
const setup = await testRender(
|
|
27
|
+
<ProjectTreeView loadFiles={() => Promise.resolve([])} onSelectFile={() => {}} theme={DARK} />,
|
|
28
|
+
{ width: 40, height: 8 },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
await waitForText(setup, "empty");
|
|
32
|
+
});
|