@cueloop/client 0.1.0-alpha.89 → 0.1.0-alpha.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.diff.test.tsx +19 -0
- package/src/App.prototype-markdown.test.tsx +36 -0
- package/src/App.tsx +72 -44
- package/src/App.workbench.test.tsx +73 -0
- package/src/app-screens.tsx +24 -20
- package/src/app-view-model.ts +6 -1
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.tsx +67 -34
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/ThreadView.tsx +57 -5
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +26 -0
- package/src/intent-dispatch.test.ts +1 -1
- package/src/run.ts +10 -0
- package/src/serve.ts +1 -1
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.ts +23 -8
- package/src/use-repo-changes.ts +87 -0
- package/src/view-plan.test.ts +24 -0
- package/src/view-plan.ts +26 -0
- package/src/workbench-snapshot.test.ts +18 -0
- package/src/workbench-snapshot.ts +35 -5
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.90",
|
|
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.90",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.90",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.90",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.90",
|
|
34
34
|
"@opentui/core": "^0.5.11",
|
|
35
35
|
"@opentui/keymap": "0.5.11",
|
|
36
36
|
"@opentui/react": "^0.5.11",
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -68,6 +68,25 @@ async function renderApp(sessionId = session.id) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
describe("diff review", () => {
|
|
71
|
+
test("a submitted JJ review uses the existing refresh action and shows its source", async () => {
|
|
72
|
+
const jjReview = server.core.sessionCreate({
|
|
73
|
+
workspace: { repoRoot: "/repo", branch: "jj" },
|
|
74
|
+
artifact: {
|
|
75
|
+
type: "diff",
|
|
76
|
+
content: PATCH,
|
|
77
|
+
meta: { title: "JJ change", vcs: "jj", vcsChangeId: "abcdefghijklmnopqrstuvwxyzaaaaaa" },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const setup = await renderReadyApp(<App home={home} sessionId={jjReview.id} />, {
|
|
81
|
+
width: 120,
|
|
82
|
+
height: 30,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await waitForText(setup, "new Map()");
|
|
86
|
+
expect(setup.captureCharFrame()).toContain("vcs: jj");
|
|
87
|
+
expect(setup.captureCharFrame()).toContain("refresh");
|
|
88
|
+
});
|
|
89
|
+
|
|
71
90
|
test("pull request review shows its brief beside Changes and signals a moved head", async () => {
|
|
72
91
|
const pullRequest = server.core.sessionCreate({
|
|
73
92
|
workspace: { repoRoot: "/repo", branch: "detached" },
|
|
@@ -8,6 +8,7 @@ import React from "react";
|
|
|
8
8
|
import { DaemonServer } from "@cueloop/daemon";
|
|
9
9
|
import type { Thread } from "@cueloop/schema";
|
|
10
10
|
import { App } from "./App";
|
|
11
|
+
import { DARK } from "./theme";
|
|
11
12
|
import { isolateUserConfig, renderReadyApp, settle, waitForText } from "./test-support";
|
|
12
13
|
|
|
13
14
|
const DOC = `# PromoCard
|
|
@@ -53,6 +54,41 @@ afterEach(() => {
|
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
describe("markdown prototype review", () => {
|
|
57
|
+
test("syntax-highlights TypeScript fences without changing their text", async () => {
|
|
58
|
+
const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
|
|
59
|
+
width: 120,
|
|
60
|
+
height: 40,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await waitForText(setup, "PromoCardProps");
|
|
64
|
+
let keywordHighlighted = false;
|
|
65
|
+
|
|
66
|
+
for (let attempt = 0; attempt < 60 && !keywordHighlighted; attempt++) {
|
|
67
|
+
await setup.renderOnce();
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
69
|
+
const accentText = setup
|
|
70
|
+
.captureSpans()
|
|
71
|
+
.lines.flatMap((line) =>
|
|
72
|
+
line.spans
|
|
73
|
+
.filter((span) => {
|
|
74
|
+
const [red, green, blue] = span.fg.toInts();
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
`#${[red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("")}` ===
|
|
78
|
+
DARK.accent
|
|
79
|
+
);
|
|
80
|
+
})
|
|
81
|
+
.map((span) => span.text),
|
|
82
|
+
)
|
|
83
|
+
.join("");
|
|
84
|
+
|
|
85
|
+
keywordHighlighted = accentText.includes("interface");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
expect(keywordHighlighted).toBe(true);
|
|
89
|
+
expect(setup.captureCharFrame()).toContain("interface PromoCardProps {");
|
|
90
|
+
}, 25000);
|
|
91
|
+
|
|
56
92
|
test("renders as a markdown design doc, not the pixel view", async () => {
|
|
57
93
|
const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
|
|
58
94
|
width: 120,
|
package/src/App.tsx
CHANGED
|
@@ -52,9 +52,12 @@ import { ChangesFileTree } from "./components/ChangesColumn";
|
|
|
52
52
|
import { MenuControlProvider, useMenuControlState } from "./components/menu-control";
|
|
53
53
|
import { ProjectTreeView } from "./components/ProjectTreeView";
|
|
54
54
|
import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/AppShell";
|
|
55
|
+
import type { ClientExtensionRegistry } from "./client-extension-registry";
|
|
56
|
+
import type { ExtensionUIContext } from "@cueloop/extension-api/client";
|
|
55
57
|
import { EditorGrid } from "./components/EditorGrid";
|
|
56
58
|
import { GridTabContent } from "./components/GridTabContent";
|
|
57
59
|
import { useChangesWorkbench } from "./use-changes-workbench";
|
|
60
|
+
import { useRepoChanges } from "./use-repo-changes";
|
|
58
61
|
import { useThreadBodyEditing } from "./use-thread-body-editing";
|
|
59
62
|
import { useRememberLayout } from "./use-remember-layout";
|
|
60
63
|
import type { LaunchLayout } from "./launch-layout";
|
|
@@ -70,7 +73,7 @@ import {
|
|
|
70
73
|
treeCommandEntries,
|
|
71
74
|
} from "./thread-chords";
|
|
72
75
|
import { type DiffFoldControls } from "./components/DiffContentView";
|
|
73
|
-
import { commentCountsByFile } from "./view-diff";
|
|
76
|
+
import { commentCountsByFile, readsFrozenDiff } from "./view-diff";
|
|
74
77
|
import { annotationTarget, isAddressed, isAgentNote, threadShareLinks } from "@cueloop/schema";
|
|
75
78
|
import type {
|
|
76
79
|
Annotation,
|
|
@@ -145,6 +148,11 @@ export interface AppProps {
|
|
|
145
148
|
layout?: LaunchLayout;
|
|
146
149
|
/** Serve mode: the frozen working-tree diff an observer reads for the served workbench thread. */
|
|
147
150
|
servedArtifact?: Artifact;
|
|
151
|
+
extensionRegistry?: ClientExtensionRegistry;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function extensionContext(cwd: string | undefined, threadId: string | null): ExtensionUIContext {
|
|
155
|
+
return { workspace: cwd ?? process.cwd(), threadId };
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
/** True while the drop-up or one of its dialogs is open and owns the keyboard. */
|
|
@@ -167,6 +175,14 @@ function shareThreadName(session: Thread | null): string {
|
|
|
167
175
|
return session ? threadTitle(session) : "";
|
|
168
176
|
}
|
|
169
177
|
|
|
178
|
+
/** Show the diff source before long titles can truncate it in the header. */
|
|
179
|
+
function reviewThreadTitle(session: Thread): string {
|
|
180
|
+
if (session.artifact.type === "diff" && session.artifact.meta.vcs)
|
|
181
|
+
return `vcs: ${session.artifact.meta.vcs} · ${threadTitle(session)}`;
|
|
182
|
+
|
|
183
|
+
return threadTitle(session);
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
/** The drop-up chrome or a floating popover menu (thread actions, editor split) holds the keyboard. */
|
|
171
187
|
function keyboardHeldByMenu(
|
|
172
188
|
menuDialog: "keybinds" | "settings" | null,
|
|
@@ -253,7 +269,7 @@ function ownerThreadActions(actions: {
|
|
|
253
269
|
);
|
|
254
270
|
}
|
|
255
271
|
|
|
256
|
-
function
|
|
272
|
+
function refreshDiffAction(onRefresh: () => void, theme: Theme): React.ReactNode {
|
|
257
273
|
return (
|
|
258
274
|
<Toolbar>
|
|
259
275
|
<Button onPress={onRefresh} foreground={theme.warning} theme={theme}>
|
|
@@ -311,39 +327,25 @@ function ProjectPanelBody(props: {
|
|
|
311
327
|
mode: ProjectPanelMode;
|
|
312
328
|
loadChanges: () => Promise<readonly DiffFileContents[]>;
|
|
313
329
|
loadProjectFiles: () => Promise<string[]>;
|
|
314
|
-
|
|
330
|
+
diffSourceKey: string;
|
|
331
|
+
liveWorkingTree: boolean;
|
|
332
|
+
frozenFiles?: readonly DiffFileContents[];
|
|
315
333
|
onOpenChangedFile: (path: string) => void;
|
|
316
334
|
onOpenProjectFile: (path: string) => void;
|
|
317
335
|
commentCounts?: ReadonlyMap<string, number>;
|
|
318
336
|
focused?: boolean;
|
|
319
337
|
theme: Theme;
|
|
320
338
|
}): React.ReactNode {
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
339
|
+
const changes = useRepoChanges({
|
|
340
|
+
loadChanges: props.loadChanges,
|
|
341
|
+
visible: props.mode === "changes" && props.liveWorkingTree,
|
|
342
|
+
diffSourceKey: props.diffSourceKey,
|
|
325
343
|
});
|
|
326
|
-
useEffect(() => {
|
|
327
|
-
let alive = true;
|
|
328
|
-
|
|
329
|
-
void loadRef.current().then(
|
|
330
|
-
(files) => {
|
|
331
|
-
if (alive) setChanges(files);
|
|
332
|
-
},
|
|
333
|
-
() => {
|
|
334
|
-
if (alive) setChanges([]);
|
|
335
|
-
},
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
return () => {
|
|
339
|
-
alive = false;
|
|
340
|
-
};
|
|
341
|
-
}, [props.reloadKey, props.mode]);
|
|
342
344
|
|
|
343
345
|
if (props.mode === "changes") {
|
|
344
346
|
return (
|
|
345
347
|
<ChangesFileTree
|
|
346
|
-
files={changes}
|
|
348
|
+
files={props.liveWorkingTree ? changes : (props.frozenFiles ?? [])}
|
|
347
349
|
onSelectFile={props.onOpenChangedFile}
|
|
348
350
|
commentCounts={props.commentCounts}
|
|
349
351
|
focused={props.focused}
|
|
@@ -353,7 +355,7 @@ function ProjectPanelBody(props: {
|
|
|
353
355
|
}
|
|
354
356
|
return (
|
|
355
357
|
<ProjectTreeView
|
|
356
|
-
key={props.
|
|
358
|
+
key={props.diffSourceKey}
|
|
357
359
|
loadFiles={props.loadProjectFiles}
|
|
358
360
|
onSelectFile={props.onOpenProjectFile}
|
|
359
361
|
focused={props.focused}
|
|
@@ -362,6 +364,21 @@ function ProjectPanelBody(props: {
|
|
|
362
364
|
);
|
|
363
365
|
}
|
|
364
366
|
|
|
367
|
+
/** Frozen reviews read their captured files directly; live workspaces poll the repo. */
|
|
368
|
+
interface ProjectDiffFiles {
|
|
369
|
+
liveWorkingTree: boolean;
|
|
370
|
+
frozenFiles?: readonly DiffFileContents[];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function projectDiffFiles(session: Thread): ProjectDiffFiles {
|
|
374
|
+
const frozen = readsFrozenDiff(session);
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
liveWorkingTree: !frozen,
|
|
378
|
+
frozenFiles: frozen ? session.artifact.files : undefined,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
365
382
|
/** The render tree has left the connecting screen: an error, a session, or the no-thread shell. */
|
|
366
383
|
function usableScreenReached(
|
|
367
384
|
error: string | null,
|
|
@@ -514,6 +531,7 @@ export function App({
|
|
|
514
531
|
appearance = "dark",
|
|
515
532
|
layout,
|
|
516
533
|
servedArtifact,
|
|
534
|
+
extensionRegistry,
|
|
517
535
|
}: AppProps): React.ReactNode {
|
|
518
536
|
const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
|
|
519
537
|
const controller = useMemo(
|
|
@@ -532,6 +550,10 @@ export function App({
|
|
|
532
550
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
533
551
|
[home, sessionId],
|
|
534
552
|
);
|
|
553
|
+
const showExtensionError = useCallback(
|
|
554
|
+
(message: string) => controller.showToast(message),
|
|
555
|
+
[controller],
|
|
556
|
+
);
|
|
535
557
|
|
|
536
558
|
useEffect(() => {
|
|
537
559
|
controller.connect();
|
|
@@ -1069,6 +1091,9 @@ export function App({
|
|
|
1069
1091
|
<SlashSkillsContext.Provider value={skills}>
|
|
1070
1092
|
<PaletteNamesContext.Provider value={paletteNames}>
|
|
1071
1093
|
<NoThreadShell
|
|
1094
|
+
toast={toast}
|
|
1095
|
+
extensionRegistry={extensionRegistry}
|
|
1096
|
+
extensionContext={extensionContext(cwd, null)}
|
|
1072
1097
|
rows={grouped.rows}
|
|
1073
1098
|
inboxCursor={inboxCursor}
|
|
1074
1099
|
onOpenThread={openThread}
|
|
@@ -1137,21 +1162,16 @@ export function App({
|
|
|
1137
1162
|
setMode,
|
|
1138
1163
|
dispatch,
|
|
1139
1164
|
});
|
|
1140
|
-
const {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
isPixelPrototype,
|
|
1151
|
-
resolved,
|
|
1152
|
-
menuDialog,
|
|
1153
|
-
resolvedIds,
|
|
1154
|
-
});
|
|
1165
|
+
const { showOwnerActions, showDiffRefresh, prototypeCanComment, chromeHidden, prototypePath } =
|
|
1166
|
+
buildRenderFlags({
|
|
1167
|
+
session: activeSession,
|
|
1168
|
+
isOwner,
|
|
1169
|
+
isDiff,
|
|
1170
|
+
isPixelPrototype,
|
|
1171
|
+
resolved,
|
|
1172
|
+
menuDialog,
|
|
1173
|
+
resolvedIds,
|
|
1174
|
+
});
|
|
1155
1175
|
|
|
1156
1176
|
const onEditRequest = (): void => {
|
|
1157
1177
|
// A share viewer/observer has no Edit affordance (the button is hidden), so
|
|
@@ -1190,10 +1210,17 @@ export function App({
|
|
|
1190
1210
|
<ThemeProvider theme={theme}>
|
|
1191
1211
|
<MenuControlProvider value={menuControl}>
|
|
1192
1212
|
<AppShell
|
|
1213
|
+
extensionRegistry={extensionRegistry}
|
|
1214
|
+
extensionContext={{
|
|
1215
|
+
workspace: activeSession.workspace.repoRoot,
|
|
1216
|
+
threadId: activeSession.id,
|
|
1217
|
+
}}
|
|
1218
|
+
onExtensionError={showExtensionError}
|
|
1193
1219
|
sidebarOpen={sidebarOpen}
|
|
1194
1220
|
onToggleSidebar={() => setSidebarOpen((open) => !open)}
|
|
1195
1221
|
onOpenMenu={openSettings}
|
|
1196
1222
|
onFocusPane={setFocusedPane}
|
|
1223
|
+
focusedPane={focusedPane}
|
|
1197
1224
|
threadsPanel={
|
|
1198
1225
|
<scrollbox style={{ flexGrow: 1 }} focused={false}>
|
|
1199
1226
|
<ThreadTree
|
|
@@ -1215,7 +1242,7 @@ export function App({
|
|
|
1215
1242
|
/>
|
|
1216
1243
|
</scrollbox>
|
|
1217
1244
|
}
|
|
1218
|
-
threadTitle={
|
|
1245
|
+
threadTitle={reviewThreadTitle(activeSession)}
|
|
1219
1246
|
threadActions={
|
|
1220
1247
|
showOwnerActions
|
|
1221
1248
|
? ownerThreadActions({
|
|
@@ -1225,8 +1252,8 @@ export function App({
|
|
|
1225
1252
|
onShare: () => dispatch({ type: "share" }),
|
|
1226
1253
|
theme,
|
|
1227
1254
|
})
|
|
1228
|
-
:
|
|
1229
|
-
?
|
|
1255
|
+
: showDiffRefresh
|
|
1256
|
+
? refreshDiffAction(() => void controller.refreshDiff(), theme)
|
|
1230
1257
|
: undefined
|
|
1231
1258
|
}
|
|
1232
1259
|
threadPanel={
|
|
@@ -1412,7 +1439,8 @@ export function App({
|
|
|
1412
1439
|
mode={workbench.projectMode}
|
|
1413
1440
|
loadChanges={() => controller.repoChanges()}
|
|
1414
1441
|
loadProjectFiles={() => controller.repoFiles()}
|
|
1415
|
-
|
|
1442
|
+
diffSourceKey={activeSession.id}
|
|
1443
|
+
{...projectDiffFiles(activeSession)}
|
|
1416
1444
|
// the Changes navigator always opens a changed file as a diff - a diff review shows its
|
|
1417
1445
|
// captured snapshot, every other thread the live working-tree diff
|
|
1418
1446
|
onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
|
|
@@ -113,6 +113,71 @@ const rightToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sid
|
|
|
113
113
|
const railToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sidebarRightOff);
|
|
114
114
|
|
|
115
115
|
describe("the four-pane workbench", () => {
|
|
116
|
+
test("a live thread's Changes tree follows edits and a clean working tree", async () => {
|
|
117
|
+
const liveSession = server.core.sessionCreate({
|
|
118
|
+
workspace: { repoRoot: repo, branch: "main" },
|
|
119
|
+
artifact: { type: "diff", content: "", meta: { title: "live changes", workbench: true } },
|
|
120
|
+
});
|
|
121
|
+
writeFileSync(join(repo, "README.md"), "# Edited\n");
|
|
122
|
+
const setup = await renderReadyApp(
|
|
123
|
+
<App home={home} sessionId={liveSession.id} layout={reviewLayout()} />,
|
|
124
|
+
{
|
|
125
|
+
width: 160,
|
|
126
|
+
height: 20,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await waitForText(setup, "README.md");
|
|
131
|
+
writeFileSync(join(repo, "README.md"), "# Workbench Fixture\n\nA tiny tracked repo.\n");
|
|
132
|
+
await waitForState(setup, () => !setup.captureCharFrame().includes("README.md"));
|
|
133
|
+
writeFileSync(join(repo, "src", "util.ts"), "export const noop = () => 1;\n");
|
|
134
|
+
await waitForText(setup, "util.ts");
|
|
135
|
+
}, 12_000);
|
|
136
|
+
|
|
137
|
+
test("a frozen diff's Changes tree follows a new captured revision", async () => {
|
|
138
|
+
const frozen = server.core.sessionCreate({
|
|
139
|
+
workspace: { repoRoot: repo, branch: "main" },
|
|
140
|
+
artifact: {
|
|
141
|
+
type: "diff",
|
|
142
|
+
content: "diff --git a/README.md b/README.md\n",
|
|
143
|
+
files: [
|
|
144
|
+
{
|
|
145
|
+
path: "README.md",
|
|
146
|
+
status: "modified",
|
|
147
|
+
oldContents: "before\n",
|
|
148
|
+
newContents: "after\n",
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
meta: { title: "captured diff" },
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const setup = await renderReadyApp(
|
|
155
|
+
<App home={home} sessionId={frozen.id} layout={reviewLayout()} />,
|
|
156
|
+
{ width: 160, height: 20 },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await waitForText(setup, "README.md");
|
|
160
|
+
server.core.sessionSubmitRevision(
|
|
161
|
+
frozen.id,
|
|
162
|
+
"diff --git a/src/util.ts b/src/util.ts\n",
|
|
163
|
+
[],
|
|
164
|
+
[
|
|
165
|
+
{
|
|
166
|
+
path: "src/util.ts",
|
|
167
|
+
status: "modified",
|
|
168
|
+
oldContents: "before\n",
|
|
169
|
+
newContents: "after\n",
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
);
|
|
173
|
+
await waitForState(setup, () => {
|
|
174
|
+
const frame = setup.captureCharFrame();
|
|
175
|
+
|
|
176
|
+
return frame.includes("util.ts") && !frame.includes("README.md");
|
|
177
|
+
});
|
|
178
|
+
setup.renderer.destroy();
|
|
179
|
+
});
|
|
180
|
+
|
|
116
181
|
test("a diff session lays out the four panes with the diff toggle active", async () => {
|
|
117
182
|
const setup = await renderApp();
|
|
118
183
|
|
|
@@ -423,6 +488,14 @@ describe("the bare-launch welcome shell", () => {
|
|
|
423
488
|
expect(setup.captureCharFrame()).toContain("README.md");
|
|
424
489
|
});
|
|
425
490
|
|
|
491
|
+
test("the changes tree clears after the launch repo becomes clean", async () => {
|
|
492
|
+
const setup = await renderWelcome();
|
|
493
|
+
|
|
494
|
+
await waitForText(setup, "README.md");
|
|
495
|
+
writeFileSync(join(welcomeRepo, "README.md"), "# Workbench Fixture\n\nA tiny tracked repo.\n");
|
|
496
|
+
await waitForState(setup, () => !setup.captureCharFrame().includes("README.md"));
|
|
497
|
+
});
|
|
498
|
+
|
|
426
499
|
test("a review layout opens the Changes panel, listing the working-tree changes", async () => {
|
|
427
500
|
const setup = await testRender(
|
|
428
501
|
<App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} layout={reviewLayout()} />,
|
package/src/app-screens.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ScrollArea } from "./components/ScrollArea";
|
|
2
|
-
import React, {
|
|
3
|
-
import type {
|
|
2
|
+
import React, { useCallback, useMemo, type Dispatch, type SetStateAction } from "react";
|
|
3
|
+
import type { Thread, MessageOutcome } from "@cueloop/schema";
|
|
4
4
|
import { returnPaneFor } from "@cueloop/schema";
|
|
5
5
|
import type { Theme } from "./theme";
|
|
6
6
|
import type { QuickAction } from "./config";
|
|
@@ -22,6 +22,8 @@ import { CompletionOverlay } from "./components/CompletionOverlay";
|
|
|
22
22
|
import { ThreadTree } from "./components/ThreadTree";
|
|
23
23
|
import { WelcomePlayground } from "./components/WelcomePlayground";
|
|
24
24
|
import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/AppShell";
|
|
25
|
+
import type { ClientExtensionRegistry } from "./client-extension-registry";
|
|
26
|
+
import type { ExtensionUIContext } from "@cueloop/extension-api/client";
|
|
25
27
|
import { EditorGrid } from "./components/EditorGrid";
|
|
26
28
|
import { MenuControlProvider, type MenuControlApi } from "./components/menu-control";
|
|
27
29
|
import { ProjectTreeView } from "./components/ProjectTreeView";
|
|
@@ -29,6 +31,7 @@ import { ChangesFileTree } from "./components/ChangesColumn";
|
|
|
29
31
|
import { BareWorkbenchFileView, draftThread } from "./components/BareWorkbenchFileView";
|
|
30
32
|
import { GridTabContent, type DiffSurfaceProps } from "./components/GridTabContent";
|
|
31
33
|
import { useChangesWorkbench } from "./use-changes-workbench";
|
|
34
|
+
import { useRepoChanges } from "./use-repo-changes";
|
|
32
35
|
import { ConfirmDialog } from "./components/ConfirmDialog";
|
|
33
36
|
import { PromptDialog } from "./components/PromptDialog";
|
|
34
37
|
import { WalkWizard } from "./components/WalkWizard";
|
|
@@ -115,24 +118,11 @@ function WelcomeProjectPanel({
|
|
|
115
118
|
focused?: boolean;
|
|
116
119
|
theme: Theme;
|
|
117
120
|
}): React.ReactNode {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
void controller.repoChanges().then(
|
|
124
|
-
(files) => {
|
|
125
|
-
if (alive) setChanges(files);
|
|
126
|
-
},
|
|
127
|
-
() => {
|
|
128
|
-
if (alive) setChanges([]);
|
|
129
|
-
},
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
return () => {
|
|
133
|
-
alive = false;
|
|
134
|
-
};
|
|
135
|
-
}, [controller]);
|
|
121
|
+
const changes = useRepoChanges({
|
|
122
|
+
loadChanges: () => controller.repoChanges(),
|
|
123
|
+
visible: mode === "changes",
|
|
124
|
+
diffSourceKey: "welcome",
|
|
125
|
+
});
|
|
136
126
|
|
|
137
127
|
if (mode === "changes") {
|
|
138
128
|
return (
|
|
@@ -162,6 +152,9 @@ function WelcomeProjectPanel({
|
|
|
162
152
|
* the center for it. Closing the Welcome tab leaves a bare "select a thread" hint.
|
|
163
153
|
*/
|
|
164
154
|
export function NoThreadShell(props: {
|
|
155
|
+
toast?: ToastState | null;
|
|
156
|
+
extensionRegistry?: ClientExtensionRegistry;
|
|
157
|
+
extensionContext?: ExtensionUIContext;
|
|
165
158
|
rows: InboxRow[];
|
|
166
159
|
inboxCursor: number;
|
|
167
160
|
/** Open a sidebar thread and move the cursor onto it, so the row shows its selected backdrop at once. */
|
|
@@ -211,6 +204,10 @@ export function NoThreadShell(props: {
|
|
|
211
204
|
onWelcomeComposingChange,
|
|
212
205
|
layout,
|
|
213
206
|
} = props;
|
|
207
|
+
const showExtensionError = useCallback(
|
|
208
|
+
(message: string) => controller.showToast(message),
|
|
209
|
+
[controller],
|
|
210
|
+
);
|
|
214
211
|
const confirming = mode.type === "confirmDelete" ? mode : null;
|
|
215
212
|
// The bare-launch shell is the same four panes as a thread: the Thread pane waits in its empty state
|
|
216
213
|
// and a disposable Welcome tab rides in the Changes editor until a thread or diff is opened.
|
|
@@ -264,10 +261,14 @@ export function NoThreadShell(props: {
|
|
|
264
261
|
<ThemeProvider theme={theme}>
|
|
265
262
|
<MenuControlProvider value={menuControl}>
|
|
266
263
|
<AppShell
|
|
264
|
+
extensionRegistry={props.extensionRegistry}
|
|
265
|
+
extensionContext={props.extensionContext}
|
|
266
|
+
onExtensionError={showExtensionError}
|
|
267
267
|
sidebarOpen={sidebarOpen}
|
|
268
268
|
onToggleSidebar={onToggleSidebar}
|
|
269
269
|
onOpenMenu={onOpenMenu}
|
|
270
270
|
onFocusPane={onFocusPane}
|
|
271
|
+
focusedPane={focusedPane}
|
|
271
272
|
threadsPanel={
|
|
272
273
|
<ScrollArea>
|
|
273
274
|
<ThreadTree
|
|
@@ -394,6 +395,9 @@ export function NoThreadShell(props: {
|
|
|
394
395
|
theme={theme}
|
|
395
396
|
/>
|
|
396
397
|
) : null}
|
|
398
|
+
{props.toast ? (
|
|
399
|
+
<Toast title={props.toast.title} body={props.toast.body} theme={theme} />
|
|
400
|
+
) : null}
|
|
397
401
|
</AppShell>
|
|
398
402
|
</MenuControlProvider>
|
|
399
403
|
</ThemeProvider>
|
package/src/app-view-model.ts
CHANGED
|
@@ -115,7 +115,12 @@ export function buildRenderFlags(params: {
|
|
|
115
115
|
|
|
116
116
|
return {
|
|
117
117
|
showOwnerActions: isOwner && !isDiff && !resolved,
|
|
118
|
-
|
|
118
|
+
showDiffRefresh:
|
|
119
|
+
!resolved &&
|
|
120
|
+
(session.artifact.meta.prRefreshHeadSha !== undefined ||
|
|
121
|
+
(session.artifact.type === "diff" &&
|
|
122
|
+
session.artifact.meta.vcsChangeId !== undefined &&
|
|
123
|
+
session.artifact.meta.workbench !== true)),
|
|
119
124
|
prototypeCanComment: isOwner && !resolved,
|
|
120
125
|
chromeHidden: menuDialog !== null,
|
|
121
126
|
prototypePath: session.artifact.meta.prototypePath ?? "",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { ClientExtensionRegistry } from "./client-extension-registry";
|
|
4
|
+
|
|
5
|
+
const Component = () => <></>;
|
|
6
|
+
|
|
7
|
+
describe("client extension registry", () => {
|
|
8
|
+
test("registers each contribution and removes it on disposal", async () => {
|
|
9
|
+
const registry = new ClientExtensionRegistry();
|
|
10
|
+
const before = registry.snapshot();
|
|
11
|
+
let notifications = 0;
|
|
12
|
+
const unsubscribe = registry.subscribe(() => notifications++);
|
|
13
|
+
const loaded = await registry.load("example", (api) => {
|
|
14
|
+
api.registerSection({ id: "summary", zone: "threads.sidebar", title: "Summary", Component });
|
|
15
|
+
api.registerAction({ id: "open", zone: "thread.header", label: "Open", onPress() {} });
|
|
16
|
+
api.registerView({ id: "details", zone: "workspace.panels", title: "Details", Component });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(registry.snapshot()).not.toBe(before);
|
|
20
|
+
expect(registry.snapshot().sections.map(({ key }) => key)).toEqual(["example:summary"]);
|
|
21
|
+
expect(registry.snapshot().actions.map(({ key }) => key)).toEqual(["example:open"]);
|
|
22
|
+
expect(registry.snapshot().views.map(({ key }) => key)).toEqual(["example:details"]);
|
|
23
|
+
loaded.dispose();
|
|
24
|
+
unsubscribe();
|
|
25
|
+
expect(registry.snapshot().sections).toEqual([]);
|
|
26
|
+
expect(registry.snapshot().actions).toEqual([]);
|
|
27
|
+
expect(registry.snapshot().views).toEqual([]);
|
|
28
|
+
expect(notifications).toBe(6);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("rolls back a factory that fails after registering", async () => {
|
|
32
|
+
const registry = new ClientExtensionRegistry();
|
|
33
|
+
|
|
34
|
+
await registry.load("broken", (api) => {
|
|
35
|
+
api.registerSection({ id: "summary", zone: "threads.sidebar", title: "Summary", Component });
|
|
36
|
+
throw new Error("boom");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(registry.snapshot().sections).toEqual([]);
|
|
40
|
+
expect(registry.errors[0]).toContain("boom");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a disposed registration cannot remove a later registration with the same ID", async () => {
|
|
44
|
+
const registry = new ClientExtensionRegistry();
|
|
45
|
+
let first: { dispose(): void } | undefined;
|
|
46
|
+
|
|
47
|
+
await registry.load("example", (api) => {
|
|
48
|
+
first = api.registerSection({
|
|
49
|
+
id: "summary",
|
|
50
|
+
zone: "threads.sidebar",
|
|
51
|
+
title: "Old",
|
|
52
|
+
Component,
|
|
53
|
+
});
|
|
54
|
+
first.dispose();
|
|
55
|
+
api.registerSection({ id: "summary", zone: "threads.sidebar", title: "New", Component });
|
|
56
|
+
});
|
|
57
|
+
first?.dispose();
|
|
58
|
+
|
|
59
|
+
expect(registry.snapshot().sections[0]?.value.title).toBe("New");
|
|
60
|
+
});
|
|
61
|
+
});
|