@cueloop/client 0.1.0-alpha.72 → 0.1.0-alpha.74
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.tsx +35 -170
- package/src/App.workbench.test.tsx +103 -2
- package/src/app-screens.tsx +84 -10
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.tsx +32 -6
- package/src/components/BareWorkbenchFileView.tsx +1 -1
- package/src/components/ConfirmCard.tsx +0 -1
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +165 -0
- package/src/components/SettingsDialog.tsx +4 -13
- package/src/components/ThreadTree.tsx +6 -18
- package/src/components/__snapshots__/stories.test.tsx.snap +112 -68
- package/src/components/primitives/Card.tsx +1 -1
- package/src/components/primitives/frame.test.ts +5 -5
- package/src/components/primitives/frame.ts +5 -7
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +36 -2
- package/src/components/quick-actions-editor.tsx +104 -29
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config.test.ts +36 -0
- package/src/config.ts +38 -8
- package/src/index.ts +2 -0
- package/src/intent-dispatch.test.ts +1 -0
- package/src/launch-layout.test.ts +52 -0
- package/src/launch-layout.ts +42 -0
- package/src/run.ts +10 -0
- package/src/serve.ts +31 -0
- package/src/thread-controller.live-diff.test.ts +119 -0
- package/src/thread-controller.ts +88 -27
- package/src/use-changes-workbench.ts +41 -4
- package/src/use-remember-layout.ts +31 -0
- package/src/use-settings-dialog.tsx +11 -2
- package/src/view-diff.test.ts +73 -2
- package/src/view-diff.ts +38 -0
- package/src/workbench-snapshot.test.ts +81 -0
- package/src/workbench-snapshot.ts +29 -0
- package/src/components/fade-title.test.ts +0 -49
- package/src/components/fade-title.ts +0 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.74",
|
|
4
4
|
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.74",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.74",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.74",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.74",
|
|
34
34
|
"@opentui/core": "^0.5.8",
|
|
35
35
|
"@opentui/keymap": "0.5.8",
|
|
36
36
|
"@opentui/react": "^0.5.8",
|
package/src/App.tsx
CHANGED
|
@@ -9,7 +9,6 @@ import React, {
|
|
|
9
9
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
10
10
|
import type { Clock } from "@opentui/core";
|
|
11
11
|
import { marksByDisplay, type Mark } from "./view-plan";
|
|
12
|
-
import { dimmedTheme } from "./theme";
|
|
13
12
|
import {
|
|
14
13
|
DEFAULT_KEYS,
|
|
15
14
|
DEFAULT_QUICK_ACTIONS,
|
|
@@ -44,11 +43,12 @@ import { groupInbox, projectName, threadTitle } from "./components/session-tree"
|
|
|
44
43
|
import { ThreadTree } from "./components/ThreadTree";
|
|
45
44
|
import { ChangesFileTree } from "./components/ChangesColumn";
|
|
46
45
|
import { ProjectTreeView } from "./components/ProjectTreeView";
|
|
47
|
-
import { AnnotatableFileView } from "./components/AnnotatableFileView";
|
|
48
46
|
import { AppShell, type ProjectPanelMode } from "./components/AppShell";
|
|
49
47
|
import { EditorGrid } from "./components/EditorGrid";
|
|
50
|
-
import
|
|
48
|
+
import { GridTabContent } from "./components/GridTabContent";
|
|
51
49
|
import { useChangesWorkbench } from "./use-changes-workbench";
|
|
50
|
+
import { useRememberLayout } from "./use-remember-layout";
|
|
51
|
+
import type { LaunchLayout } from "./launch-layout";
|
|
52
52
|
import { ThreadFooter } from "./components/ThreadFooter";
|
|
53
53
|
import { ConfirmCard } from "./components/ConfirmCard";
|
|
54
54
|
import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
|
|
@@ -59,14 +59,10 @@ import {
|
|
|
59
59
|
THREAD_CHORD_ENTRIES,
|
|
60
60
|
TREE_CHORD_ENTRIES,
|
|
61
61
|
} from "./thread-chords";
|
|
62
|
-
import {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
} from "./components/DiffContentView";
|
|
67
|
-
import { commentCountsByFile, fileTargetMarks, marksByRows, type DiffRow } from "./view-diff";
|
|
68
|
-
import { annotationTarget, type Anchor } from "@cueloop/schema";
|
|
69
|
-
import type { DiffFileContents, Thread } from "@cueloop/schema";
|
|
62
|
+
import { type DiffFoldControls } from "./components/DiffContentView";
|
|
63
|
+
import { commentCountsByFile } from "./view-diff";
|
|
64
|
+
import { annotationTarget } from "@cueloop/schema";
|
|
65
|
+
import type { Artifact, DiffFileContents, Thread } from "@cueloop/schema";
|
|
70
66
|
import { PrototypePixels } from "./prototype-pixels";
|
|
71
67
|
import type { PrototypeElement } from "./prototype-browser";
|
|
72
68
|
import {
|
|
@@ -127,6 +123,10 @@ export interface AppProps {
|
|
|
127
123
|
* light-on-light. Defaults to dark - the historical assumption.
|
|
128
124
|
*/
|
|
129
125
|
appearance?: Appearance;
|
|
126
|
+
/** The pane composition to open in; a create-command sets it, a bare launch restores the remembered one. */
|
|
127
|
+
layout?: LaunchLayout;
|
|
128
|
+
/** Serve mode: the frozen working-tree diff an observer reads for the served workbench thread. */
|
|
129
|
+
servedArtifact?: Artifact;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/** True while the drop-up or one of its dialogs is open and owns the keyboard. */
|
|
@@ -144,163 +144,6 @@ function canSubmitReview(isOwner: boolean, resolved: boolean, observer: boolean)
|
|
|
144
144
|
return isOwner && !resolved && !observer;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
/** The inline-commenting props the diff sheet shares with the thread view, wired once by the app. */
|
|
148
|
-
type DiffSurfaceProps = Pick<
|
|
149
|
-
DiffContentViewProps,
|
|
150
|
-
| "session"
|
|
151
|
-
| "quickActions"
|
|
152
|
-
| "observer"
|
|
153
|
-
| "commentsEnabled"
|
|
154
|
-
| "resolved"
|
|
155
|
-
| "suspended"
|
|
156
|
-
| "onComposingChange"
|
|
157
|
-
| "onObserverBlocked"
|
|
158
|
-
| "onCursorChange"
|
|
159
|
-
| "focusedAnnotationId"
|
|
160
|
-
| "onFocusAnnotation"
|
|
161
|
-
| "onAnnotate"
|
|
162
|
-
| "onReply"
|
|
163
|
-
| "onUpdateAnnotation"
|
|
164
|
-
| "onExit"
|
|
165
|
-
>;
|
|
166
|
-
|
|
167
|
-
/** The Changes tab body: the whole diff in one scroll container, or a bare hint when nothing changed. */
|
|
168
|
-
function ChangesTabBody(props: {
|
|
169
|
-
rows: DiffRow[];
|
|
170
|
-
surface: DiffSurfaceProps;
|
|
171
|
-
rejectedRows: Set<number>;
|
|
172
|
-
fold?: DiffFoldControls;
|
|
173
|
-
fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
|
|
174
|
-
split?: boolean;
|
|
175
|
-
dimmed: boolean;
|
|
176
|
-
theme: Theme;
|
|
177
|
-
}): React.ReactNode {
|
|
178
|
-
const annotations = props.surface.session.annotations;
|
|
179
|
-
// a diff thread's Changes view is the artifact itself; any other thread's is the working-tree diff,
|
|
180
|
-
// whose notes carry a file target and resolve per file - each surface paints only its own notes
|
|
181
|
-
const forArtifact = props.surface.session.artifact.type === "diff";
|
|
182
|
-
const marks = useMemo(
|
|
183
|
-
() =>
|
|
184
|
-
forArtifact
|
|
185
|
-
? marksByRows(
|
|
186
|
-
annotations.filter((annotation) => annotationTarget(annotation).kind === "artifact"),
|
|
187
|
-
props.rows,
|
|
188
|
-
props.surface.focusedAnnotationId,
|
|
189
|
-
)
|
|
190
|
-
: fileTargetMarks(annotations, props.rows, props.surface.focusedAnnotationId),
|
|
191
|
-
[annotations, forArtifact, props.rows, props.surface.focusedAnnotationId],
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
if (props.rows.length === 0) {
|
|
195
|
-
return (
|
|
196
|
-
<box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
|
|
197
|
-
<text fg={props.theme.textDim}>No changes</text>
|
|
198
|
-
</box>
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return (
|
|
203
|
-
<DiffContentView
|
|
204
|
-
rows={props.rows}
|
|
205
|
-
marks={marks}
|
|
206
|
-
{...props.surface}
|
|
207
|
-
rejectedRows={props.rejectedRows}
|
|
208
|
-
fold={props.fold}
|
|
209
|
-
fileStats={props.fileStats}
|
|
210
|
-
split={props.split}
|
|
211
|
-
theme={props.dimmed ? dimmedTheme(props.theme) : undefined}
|
|
212
|
-
/>
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** An editor tab's body: the whole diff for the Changes tab, a file's diff or contents for a file tab. */
|
|
217
|
-
function GridTabContent(props: {
|
|
218
|
-
tab: EditorTab;
|
|
219
|
-
rows: DiffRow[];
|
|
220
|
-
surface: DiffSurfaceProps;
|
|
221
|
-
rejectedRows: Set<number>;
|
|
222
|
-
fold?: DiffFoldControls;
|
|
223
|
-
fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
|
|
224
|
-
split?: boolean;
|
|
225
|
-
dimmed: boolean;
|
|
226
|
-
readFile: (path: string) => Promise<string | null>;
|
|
227
|
-
/** Persist a comment on a project file; the file view built the anchor against its own lines. */
|
|
228
|
-
onAddFileComment: (path: string, anchor: Anchor, body: string) => void;
|
|
229
|
-
theme: Theme;
|
|
230
|
-
}): React.ReactNode {
|
|
231
|
-
const { tab } = props;
|
|
232
|
-
if (tab.kind === "file" && tab.fileView === "contents" && tab.path !== undefined) {
|
|
233
|
-
const filePath = tab.path;
|
|
234
|
-
|
|
235
|
-
return (
|
|
236
|
-
<AnnotatableFileView
|
|
237
|
-
path={filePath}
|
|
238
|
-
loadContents={props.readFile}
|
|
239
|
-
session={props.surface.session}
|
|
240
|
-
quickActions={props.surface.quickActions}
|
|
241
|
-
observer={props.surface.observer}
|
|
242
|
-
resolved={props.surface.resolved}
|
|
243
|
-
suspended={props.surface.suspended}
|
|
244
|
-
focusedAnnotationId={props.surface.focusedAnnotationId}
|
|
245
|
-
onFocusAnnotation={props.surface.onFocusAnnotation}
|
|
246
|
-
onComposingChange={props.surface.onComposingChange}
|
|
247
|
-
onCursorChange={props.surface.onCursorChange}
|
|
248
|
-
onObserverBlocked={props.surface.onObserverBlocked}
|
|
249
|
-
onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
|
|
250
|
-
onReply={props.surface.onReply}
|
|
251
|
-
onUpdateAnnotation={props.surface.onUpdateAnnotation}
|
|
252
|
-
onExit={props.surface.onExit}
|
|
253
|
-
theme={props.theme}
|
|
254
|
-
/>
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
if (tab.kind !== "file") {
|
|
258
|
-
return (
|
|
259
|
-
<ChangesTabBody
|
|
260
|
-
rows={props.rows}
|
|
261
|
-
surface={props.surface}
|
|
262
|
-
rejectedRows={props.rejectedRows}
|
|
263
|
-
fold={props.fold}
|
|
264
|
-
fileStats={props.fileStats}
|
|
265
|
-
split={props.split}
|
|
266
|
-
dimmed={props.dimmed}
|
|
267
|
-
theme={props.theme}
|
|
268
|
-
/>
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
// a single-file tab shows that file's rows alone, so its row indices are its own: comments and
|
|
272
|
-
// the caret report back in whole-diff indices, and the fold controls (a band to fold) do not apply
|
|
273
|
-
const fileRowIndices = props.rows.flatMap((row, index) => (row.file === tab.path ? [index] : []));
|
|
274
|
-
const rows = fileRowIndices.map((index) => props.rows[index]!);
|
|
275
|
-
const wholeIndex = (rowIndex: number): number => fileRowIndices[rowIndex] ?? rowIndex;
|
|
276
|
-
const rejectedRows = new Set(
|
|
277
|
-
fileRowIndices.flatMap((index, rowIndex) => (props.rejectedRows.has(index) ? [rowIndex] : [])),
|
|
278
|
-
);
|
|
279
|
-
|
|
280
|
-
return (
|
|
281
|
-
<ChangesTabBody
|
|
282
|
-
rows={rows}
|
|
283
|
-
surface={{
|
|
284
|
-
...props.surface,
|
|
285
|
-
onCursorChange: (rowIndex) => props.surface.onCursorChange?.(wholeIndex(rowIndex)),
|
|
286
|
-
onAnnotate: (span, body) =>
|
|
287
|
-
props.surface.onAnnotate(
|
|
288
|
-
{
|
|
289
|
-
start: { blockIndex: wholeIndex(span.start.blockIndex), char: span.start.char },
|
|
290
|
-
end: { blockIndex: wholeIndex(span.end.blockIndex), char: span.end.char },
|
|
291
|
-
},
|
|
292
|
-
body,
|
|
293
|
-
),
|
|
294
|
-
}}
|
|
295
|
-
rejectedRows={rejectedRows}
|
|
296
|
-
fileStats={props.fileStats}
|
|
297
|
-
split={props.split}
|
|
298
|
-
dimmed={props.dimmed}
|
|
299
|
-
theme={props.theme}
|
|
300
|
-
/>
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
147
|
/** The Project pane body: the resolved repo's changed files in changes mode, its full tree otherwise. */
|
|
305
148
|
function ProjectPanelBody(props: {
|
|
306
149
|
mode: ProjectPanelMode;
|
|
@@ -381,6 +224,14 @@ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): Che
|
|
|
381
224
|
];
|
|
382
225
|
}
|
|
383
226
|
|
|
227
|
+
/** The Threads sidebar opens from the launch layout; a bare launch opens it only on the inbox. */
|
|
228
|
+
function initialThreadsOpen(
|
|
229
|
+
layout: LaunchLayout | undefined,
|
|
230
|
+
sessionId: string | undefined,
|
|
231
|
+
): boolean {
|
|
232
|
+
return layout ? layout.threads : sessionId === undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
384
235
|
export function App({
|
|
385
236
|
home,
|
|
386
237
|
sessionId,
|
|
@@ -394,6 +245,8 @@ export function App({
|
|
|
394
245
|
role = "owner",
|
|
395
246
|
selfAuthor,
|
|
396
247
|
appearance = "dark",
|
|
248
|
+
layout,
|
|
249
|
+
servedArtifact,
|
|
397
250
|
}: AppProps): React.ReactNode {
|
|
398
251
|
const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
|
|
399
252
|
const controller = useMemo(
|
|
@@ -407,6 +260,7 @@ export function App({
|
|
|
407
260
|
clock,
|
|
408
261
|
openClient,
|
|
409
262
|
shareTransport,
|
|
263
|
+
servedArtifact,
|
|
410
264
|
}),
|
|
411
265
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
412
266
|
[home, sessionId],
|
|
@@ -461,9 +315,19 @@ export function App({
|
|
|
461
315
|
// picking a thread keeps the sidebar as it was. It opens when the app lands with
|
|
462
316
|
// nothing selected (pick a thread) and stays collapsed on a direct thread open,
|
|
463
317
|
// where the thread owns the width.
|
|
464
|
-
const [sidebarOpen, setSidebarOpen] = useState(
|
|
318
|
+
const [sidebarOpen, setSidebarOpen] = useState(() => initialThreadsOpen(layout, sessionId));
|
|
465
319
|
// the Changes + Project right region and its editor grid (tabs, splits, zoom)
|
|
466
|
-
const workbench = useChangesWorkbench();
|
|
320
|
+
const workbench = useChangesWorkbench({ layout });
|
|
321
|
+
// only the session view persists from here; the bare shell owns its own (NoThreadShell), so this
|
|
322
|
+
// workbench stays inactive with no session and never clobbers what the shell saved
|
|
323
|
+
useRememberLayout(
|
|
324
|
+
layout,
|
|
325
|
+
session !== null,
|
|
326
|
+
sidebarOpen,
|
|
327
|
+
workbench.changesOpen,
|
|
328
|
+
workbench.projectOpen,
|
|
329
|
+
workbench.zoomed,
|
|
330
|
+
);
|
|
467
331
|
const [mode, setMode] = useState<Mode>({ type: "normal" });
|
|
468
332
|
// the top-left settings gear drop-down and the centered dialog it opens
|
|
469
333
|
const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
|
|
@@ -813,6 +677,7 @@ export function App({
|
|
|
813
677
|
onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
|
|
814
678
|
quickActions={quickActions}
|
|
815
679
|
onWelcomeComposingChange={setWelcomeComposing}
|
|
680
|
+
layout={layout}
|
|
816
681
|
/>
|
|
817
682
|
</PaletteNamesContext.Provider>
|
|
818
683
|
</SlashSkillsContext.Provider>
|
|
@@ -15,6 +15,8 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
15
15
|
import { DaemonServer } from "@cueloop/daemon";
|
|
16
16
|
import type { Thread } from "@cueloop/schema";
|
|
17
17
|
import { App } from "./App";
|
|
18
|
+
import { loadConfig } from "./config";
|
|
19
|
+
import { planLayout, reviewLayout } from "./launch-layout";
|
|
18
20
|
import {
|
|
19
21
|
dragText,
|
|
20
22
|
isolateUserConfig,
|
|
@@ -247,6 +249,29 @@ describe("the four-pane workbench", () => {
|
|
|
247
249
|
expect(diffToggleColumn(setup)).toBeGreaterThan(0);
|
|
248
250
|
});
|
|
249
251
|
|
|
252
|
+
test("a create-command session remembers its zoom toggle for the next bare launch", async () => {
|
|
253
|
+
// a create-command opens the review zoomed; toggling zoom off must persist to the remembered layout
|
|
254
|
+
const setup = await renderReadyApp(
|
|
255
|
+
<App home={home} sessionId={session.id} layout={reviewLayout()} />,
|
|
256
|
+
{
|
|
257
|
+
width: 160,
|
|
258
|
+
height: 20,
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
await waitForText(setup, "store.ts");
|
|
262
|
+
|
|
263
|
+
const zoom = locateText(setup, NERD.zoom);
|
|
264
|
+
await setup.mockMouse.click(zoom.column, zoom.row);
|
|
265
|
+
await waitForState(
|
|
266
|
+
setup,
|
|
267
|
+
() =>
|
|
268
|
+
loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout?.zoomChanges === false,
|
|
269
|
+
);
|
|
270
|
+
expect(loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout?.zoomChanges).toBe(
|
|
271
|
+
false,
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
250
275
|
test("selecting a non-diff thread while zoomed exits zoom and restores the Thread pane", async () => {
|
|
251
276
|
// a second, non-diff thread to navigate to; its sync has no Changes editor to fill a zoom
|
|
252
277
|
server.core.sessionCreate({
|
|
@@ -303,11 +328,11 @@ describe("the four-pane workbench", () => {
|
|
|
303
328
|
const setup = await renderApp();
|
|
304
329
|
|
|
305
330
|
// the tip is not painted until the pointer is over the control
|
|
306
|
-
expect(setup.captureCharFrame()).not.toContain("Toggle
|
|
331
|
+
expect(setup.captureCharFrame()).not.toContain("Toggle Sidebar");
|
|
307
332
|
|
|
308
333
|
await setup.mockMouse.moveTo(rightToggleColumn(setup), HEADER_ROW);
|
|
309
334
|
// the label surfaces at the root, escaping the header cell that would otherwise clip it
|
|
310
|
-
await waitForText(setup, "Toggle
|
|
335
|
+
await waitForText(setup, "Toggle Sidebar");
|
|
311
336
|
});
|
|
312
337
|
});
|
|
313
338
|
|
|
@@ -396,6 +421,82 @@ describe("the bare-launch welcome shell", () => {
|
|
|
396
421
|
expect(setup.captureCharFrame()).toContain("README.md");
|
|
397
422
|
});
|
|
398
423
|
|
|
424
|
+
test("a review layout opens the Changes panel, listing the working-tree changes", async () => {
|
|
425
|
+
const setup = await testRender(
|
|
426
|
+
<App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} layout={reviewLayout()} />,
|
|
427
|
+
{ width: 180, height: 14 },
|
|
428
|
+
);
|
|
429
|
+
await waitForText(setup, "README.md");
|
|
430
|
+
expect(setup.captureCharFrame()).toContain("README.md");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("a plan layout opens with the right region closed, so no Changes panel shows", async () => {
|
|
434
|
+
const setup = await testRender(
|
|
435
|
+
<App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} layout={planLayout()} />,
|
|
436
|
+
{ width: 180, height: 14 },
|
|
437
|
+
);
|
|
438
|
+
await waitForText(setup, "cueloop");
|
|
439
|
+
// rightSidebar "off": neither the Changes list nor the launch repo's files paint
|
|
440
|
+
expect(setup.captureCharFrame()).not.toContain("README.md");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("a hand-edited off+zoom layout drops the zoom so the center is not stranded blank", async () => {
|
|
444
|
+
// zoom fills the middle with the Changes editor; with the region off there is none, so honoring zoom
|
|
445
|
+
// would hide the Thread pane over a blank center. The Thread placeholder must still render.
|
|
446
|
+
const setup = await testRender(
|
|
447
|
+
<App
|
|
448
|
+
home={welcomeHome}
|
|
449
|
+
sessionId={undefined}
|
|
450
|
+
cwd={welcomeRepo}
|
|
451
|
+
layout={{ threads: true, rightSidebar: "off", zoomChanges: true }}
|
|
452
|
+
/>,
|
|
453
|
+
{ width: 180, height: 14 },
|
|
454
|
+
);
|
|
455
|
+
await waitForText(setup, "no threads");
|
|
456
|
+
expect(setup.captureCharFrame()).toContain("no threads");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("closing the right region on the vanilla shell remembers it for the next launch", async () => {
|
|
460
|
+
const setup = await testRender(
|
|
461
|
+
<App
|
|
462
|
+
home={welcomeHome}
|
|
463
|
+
sessionId={undefined}
|
|
464
|
+
cwd={welcomeRepo}
|
|
465
|
+
layout={{ threads: true, rightSidebar: "changes", zoomChanges: false }}
|
|
466
|
+
/>,
|
|
467
|
+
{ width: 180, height: 14 },
|
|
468
|
+
);
|
|
469
|
+
await waitForText(setup, "README.md");
|
|
470
|
+
|
|
471
|
+
// close the Changes/Project region; the remembered layout follows it to "off"
|
|
472
|
+
await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
|
|
473
|
+
await waitForState(
|
|
474
|
+
setup,
|
|
475
|
+
() =>
|
|
476
|
+
loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout?.rightSidebar ===
|
|
477
|
+
"off",
|
|
478
|
+
);
|
|
479
|
+
expect(loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout).toEqual({
|
|
480
|
+
threads: true,
|
|
481
|
+
rightSidebar: "off",
|
|
482
|
+
zoomChanges: false,
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("clicking a changed file in the bare shell opens its diff, not its contents", async () => {
|
|
487
|
+
const setup = await renderWelcome();
|
|
488
|
+
|
|
489
|
+
await waitForText(setup, "README.md");
|
|
490
|
+
const readme = locateText(setup, "README.md");
|
|
491
|
+
await setup.mockMouse.click(readme.column, readme.row);
|
|
492
|
+
|
|
493
|
+
// a diff shows both the removed old line and the added new line; contents would show only the new
|
|
494
|
+
await waitForText(setup, "A tiny tracked repo.");
|
|
495
|
+
const frame = setup.captureCharFrame();
|
|
496
|
+
expect(frame).toContain("A tiny tracked repo.");
|
|
497
|
+
expect(frame).toContain("edited in the working tree");
|
|
498
|
+
});
|
|
499
|
+
|
|
399
500
|
test("a bare launch rides a Welcome tab in the editor while the Thread pane waits empty", async () => {
|
|
400
501
|
const setup = await renderWelcome();
|
|
401
502
|
|
package/src/app-screens.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ScrollArea } from "./components/ScrollArea";
|
|
2
|
-
import React, { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
import React, { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
|
3
3
|
import type { DiffFileContents, Thread, VerdictKind } from "@cueloop/schema";
|
|
4
4
|
import { returnPaneFor } from "@cueloop/schema";
|
|
5
5
|
import type { Theme } from "./theme";
|
|
6
6
|
import type { QuickAction } from "./config";
|
|
7
|
+
import type { LaunchLayout } from "./launch-layout";
|
|
8
|
+
import { useRememberLayout } from "./use-remember-layout";
|
|
7
9
|
import type { Mode, TreeAsk } from "./intent-dispatch";
|
|
8
10
|
import type { Intent } from "./keymap";
|
|
9
11
|
import type { ReviewController, ToastState } from "./thread-controller";
|
|
@@ -23,7 +25,8 @@ import { AppShell, type ProjectPanelMode } from "./components/AppShell";
|
|
|
23
25
|
import { EditorGrid } from "./components/EditorGrid";
|
|
24
26
|
import { ProjectTreeView } from "./components/ProjectTreeView";
|
|
25
27
|
import { ChangesFileTree } from "./components/ChangesColumn";
|
|
26
|
-
import { BareWorkbenchFileView } from "./components/BareWorkbenchFileView";
|
|
28
|
+
import { BareWorkbenchFileView, draftThread } from "./components/BareWorkbenchFileView";
|
|
29
|
+
import { GridTabContent, type DiffSurfaceProps } from "./components/GridTabContent";
|
|
27
30
|
import { useChangesWorkbench } from "./use-changes-workbench";
|
|
28
31
|
import { ConfirmDialog } from "./components/ConfirmDialog";
|
|
29
32
|
import { PromptDialog } from "./components/PromptDialog";
|
|
@@ -88,15 +91,22 @@ export function MenuChrome(props: {
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/** The welcome shell's Project pane: the launch repo's changed files in changes mode, its full tree otherwise. */
|
|
94
|
+
/** A shared empty rejected-rows set: the bare-launch diff rejects nothing, and this keeps a stable ref. */
|
|
95
|
+
const EMPTY_ROWS: Set<number> = new Set();
|
|
96
|
+
|
|
91
97
|
function WelcomeProjectPanel({
|
|
92
98
|
mode,
|
|
93
99
|
controller,
|
|
94
|
-
|
|
100
|
+
onOpenChangedFile,
|
|
101
|
+
onOpenProjectFile,
|
|
95
102
|
theme,
|
|
96
103
|
}: {
|
|
97
104
|
mode: ProjectPanelMode;
|
|
98
105
|
controller: ReviewController;
|
|
99
|
-
|
|
106
|
+
/** Changes tree click -> open the file's working-tree diff. */
|
|
107
|
+
onOpenChangedFile: (path: string) => void;
|
|
108
|
+
/** Project tree click -> open the file's read-only contents. */
|
|
109
|
+
onOpenProjectFile: (path: string) => void;
|
|
100
110
|
theme: Theme;
|
|
101
111
|
}): React.ReactNode {
|
|
102
112
|
const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
|
|
@@ -119,13 +129,13 @@ function WelcomeProjectPanel({
|
|
|
119
129
|
}, [controller]);
|
|
120
130
|
|
|
121
131
|
if (mode === "changes") {
|
|
122
|
-
return <ChangesFileTree files={changes} onSelectFile={
|
|
132
|
+
return <ChangesFileTree files={changes} onSelectFile={onOpenChangedFile} theme={theme} />;
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
return (
|
|
126
136
|
<ProjectTreeView
|
|
127
137
|
loadFiles={() => controller.repoFiles()}
|
|
128
|
-
onSelectFile={
|
|
138
|
+
onSelectFile={onOpenProjectFile}
|
|
129
139
|
theme={theme}
|
|
130
140
|
/>
|
|
131
141
|
);
|
|
@@ -156,6 +166,8 @@ export function NoThreadShell(props: {
|
|
|
156
166
|
quickActions: QuickAction[];
|
|
157
167
|
/** Reports the welcome composer's open state, so the shell suspends its inbox keys while typing. */
|
|
158
168
|
onWelcomeComposingChange: (composing: boolean) => void;
|
|
169
|
+
/** The pane composition this vanilla launch restores; changes here are remembered for the next one. */
|
|
170
|
+
layout?: LaunchLayout;
|
|
159
171
|
}): React.ReactNode {
|
|
160
172
|
const {
|
|
161
173
|
rows,
|
|
@@ -173,12 +185,56 @@ export function NoThreadShell(props: {
|
|
|
173
185
|
onRename,
|
|
174
186
|
quickActions,
|
|
175
187
|
onWelcomeComposingChange,
|
|
188
|
+
layout,
|
|
176
189
|
} = props;
|
|
177
190
|
const confirming = mode.type === "confirmDelete" ? mode : null;
|
|
178
191
|
// The bare-launch shell is the same four panes as a thread: the Thread pane waits in its empty state
|
|
179
192
|
// and a disposable Welcome tab rides in the Changes editor until a thread or diff is opened.
|
|
180
|
-
const workbench = useChangesWorkbench({ seed: "welcome" });
|
|
181
|
-
|
|
193
|
+
const workbench = useChangesWorkbench({ seed: "welcome", layout });
|
|
194
|
+
// the vanilla shell remembers the composition the user leaves it in, for the next bare launch
|
|
195
|
+
useRememberLayout(
|
|
196
|
+
layout,
|
|
197
|
+
true,
|
|
198
|
+
sidebarOpen,
|
|
199
|
+
workbench.changesOpen,
|
|
200
|
+
workbench.projectOpen,
|
|
201
|
+
workbench.zoomed,
|
|
202
|
+
);
|
|
203
|
+
// the Changes tree opens a file's working-tree diff; the Project tree opens read-only contents
|
|
204
|
+
const openChangedFile = (path: string): void => {
|
|
205
|
+
// no thread means no live-diff refresh loop, so re-capture on open or a long-lived shell goes
|
|
206
|
+
// stale; the catch keeps a fire-and-forget refresh from throwing when the shell tears down
|
|
207
|
+
void controller.repoChanges().catch(() => undefined);
|
|
208
|
+
workbench.openFile(path, "diff");
|
|
209
|
+
};
|
|
210
|
+
const openProjectFile = (path: string): void => workbench.openFile(path, "contents");
|
|
211
|
+
// a bare launch has no thread yet, so the diff renders against a draft session; the first note
|
|
212
|
+
// promotes it to the per-repo workbench thread (commentOnWorkbenchDiff)
|
|
213
|
+
const draft = useMemo(() => draftThread(), []);
|
|
214
|
+
const bareSurface: DiffSurfaceProps = {
|
|
215
|
+
session: draft,
|
|
216
|
+
quickActions,
|
|
217
|
+
observer: false,
|
|
218
|
+
commentsEnabled: true,
|
|
219
|
+
resolved: false,
|
|
220
|
+
suspended: false,
|
|
221
|
+
onComposingChange: onWelcomeComposingChange,
|
|
222
|
+
onObserverBlocked: () => {},
|
|
223
|
+
onCursorChange: () => {},
|
|
224
|
+
focusedAnnotationId: undefined,
|
|
225
|
+
onFocusAnnotation: () => {},
|
|
226
|
+
onAnnotate: (span, body) =>
|
|
227
|
+
void controller.commentOnWorkbenchDiff(
|
|
228
|
+
span.start.blockIndex,
|
|
229
|
+
span.start.char,
|
|
230
|
+
span.end.char,
|
|
231
|
+
span.end.blockIndex,
|
|
232
|
+
body,
|
|
233
|
+
),
|
|
234
|
+
onReply: () => undefined,
|
|
235
|
+
onUpdateAnnotation: () => {},
|
|
236
|
+
onExit: () => {},
|
|
237
|
+
};
|
|
182
238
|
|
|
183
239
|
return (
|
|
184
240
|
<ThemeProvider theme={theme}>
|
|
@@ -241,7 +297,7 @@ export function NoThreadShell(props: {
|
|
|
241
297
|
onComposingChange={onWelcomeComposingChange}
|
|
242
298
|
theme={theme}
|
|
243
299
|
/>
|
|
244
|
-
) : (
|
|
300
|
+
) : tab.fileView === "contents" ? (
|
|
245
301
|
<BareWorkbenchFileView
|
|
246
302
|
path={tab.path ?? ""}
|
|
247
303
|
controller={controller}
|
|
@@ -250,6 +306,23 @@ export function NoThreadShell(props: {
|
|
|
250
306
|
onExit={() => {}}
|
|
251
307
|
theme={theme}
|
|
252
308
|
/>
|
|
309
|
+
) : (
|
|
310
|
+
<GridTabContent
|
|
311
|
+
tab={tab}
|
|
312
|
+
rows={controller.rows()}
|
|
313
|
+
surface={bareSurface}
|
|
314
|
+
rejectedRows={EMPTY_ROWS}
|
|
315
|
+
dimmed={false}
|
|
316
|
+
readFile={(path) => controller.repoReadFile(path)}
|
|
317
|
+
onAddFileComment={(path, anchor, body) =>
|
|
318
|
+
void controller.commentOnWorkbench(
|
|
319
|
+
anchor,
|
|
320
|
+
{ kind: "file", path, rev: "worktree" },
|
|
321
|
+
body,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
theme={theme}
|
|
325
|
+
/>
|
|
253
326
|
)
|
|
254
327
|
}
|
|
255
328
|
theme={theme}
|
|
@@ -259,7 +332,8 @@ export function NoThreadShell(props: {
|
|
|
259
332
|
<WelcomeProjectPanel
|
|
260
333
|
mode={workbench.projectMode}
|
|
261
334
|
controller={controller}
|
|
262
|
-
|
|
335
|
+
onOpenChangedFile={openChangedFile}
|
|
336
|
+
onOpenProjectFile={openProjectFile}
|
|
263
337
|
theme={theme}
|
|
264
338
|
/>
|
|
265
339
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The collapsed-sidebar brand row keeps the gear, the Threads toggle, and the
|
|
3
|
+
* "cueloop" mark on one line. A long thread title used to shrink the brand box
|
|
4
|
+
* until "cueloop" and the toggle wrapped onto the header's underline row; the
|
|
5
|
+
* title truncates instead, so the brand never shrinks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { AppShell } from "./AppShell";
|
|
12
|
+
import { NERD } from "./primitives/icons";
|
|
13
|
+
import { settle } from "../test-support";
|
|
14
|
+
|
|
15
|
+
const LONG_TITLE = "working tree @ GRO-1981-fe-billing-promo-cookie-migration";
|
|
16
|
+
|
|
17
|
+
async function brandFrame(): Promise<string[]> {
|
|
18
|
+
const setup = await testRender(
|
|
19
|
+
<AppShell
|
|
20
|
+
sidebarOpen={false}
|
|
21
|
+
onToggleSidebar={() => {}}
|
|
22
|
+
onOpenMenu={() => {}}
|
|
23
|
+
threadsPanel={<text>threads</text>}
|
|
24
|
+
threadTitle={LONG_TITLE}
|
|
25
|
+
threadPanel={<text>review the changes on the right</text>}
|
|
26
|
+
changesOpen
|
|
27
|
+
projectOpen
|
|
28
|
+
onToggleChanges={() => {}}
|
|
29
|
+
onToggleProject={() => {}}
|
|
30
|
+
onToggleRight={() => {}}
|
|
31
|
+
projectMode="changes"
|
|
32
|
+
projectPanel={<text>project</text>}
|
|
33
|
+
changesPanel={<text>diff</text>}
|
|
34
|
+
/>,
|
|
35
|
+
{ width: 160, height: 12 },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
await settle(setup);
|
|
39
|
+
await settle(setup);
|
|
40
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
41
|
+
|
|
42
|
+
setup.renderer.destroy();
|
|
43
|
+
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("collapsed-sidebar brand row", () => {
|
|
48
|
+
test("the Threads toggle stays on the cueloop row and the title tails off in an ellipsis", async () => {
|
|
49
|
+
const lines = await brandFrame();
|
|
50
|
+
const brandRow = lines.findIndex((line) => line.includes("cueloop"));
|
|
51
|
+
const toggleRow = lines.findIndex((line) => line.includes(NERD.sidebarLeftOff));
|
|
52
|
+
|
|
53
|
+
expect(brandRow).toBeGreaterThanOrEqual(0);
|
|
54
|
+
expect(toggleRow).toBe(brandRow);
|
|
55
|
+
expect(lines[brandRow]).toContain("…");
|
|
56
|
+
expect(lines[brandRow]).not.toContain(LONG_TITLE);
|
|
57
|
+
});
|
|
58
|
+
});
|