@uniqbit/mate-core 0.17.0-canary.9 → 0.17.1
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 +11 -5
- package/src/cli/commands/artifact/finish/index.ts +0 -9
- package/src/cli/commands/artifact/finish/openspec.ts +2 -2
- package/src/cli/commands/artifact/finish/registry.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +1 -1
- package/src/cli/commands/artifact/pending/index.ts +0 -16
- package/src/cli/commands/cap/graphify.ts +1 -1
- package/src/cli/commands/cap/index-cmd.ts +1 -1
- package/src/cli/commands/cap/tokensave.ts +4 -3
- package/src/cli/commands/companion/companion.ts +4 -0
- package/src/cli/commands/companion/hub.ts +2 -1
- package/src/cli/commands/companion/list.ts +2 -1
- package/src/cli/commands/companion/prepare.ts +2 -2
- package/src/cli/commands/companion/update.ts +334 -0
- package/src/cli/commands/doctor.ts +2 -1
- package/src/cli/commands/install.ts +59 -5
- package/src/cli/commands/launch/shared.ts +2 -1
- package/src/cli/commands/plugin/plugin.ts +4 -0
- package/src/cli/commands/plugin/verify.ts +25 -0
- package/src/cli/commands/report/highlight.ts +1 -4
- package/src/cli/commands/report/renderer.ts +0 -2
- package/src/cli/commands/studio/access.ts +201 -0
- package/src/cli/commands/studio/index.ts +207 -26
- package/src/cli/commands/studio/openspec-cli.ts +1 -1
- package/src/cli/commands/studio/selection.ts +127 -11
- package/src/cli/commands/studio/server.ts +355 -64
- package/src/cli/commands/studio/terminal-assets.ts +50 -0
- package/src/cli/commands/studio/terminal-launch.ts +71 -0
- package/src/cli/commands/studio/terminal.ts +593 -0
- package/src/cli/commands/studio/vault.ts +320 -75
- package/src/cli/commands/studio/views/client.ts +90 -9
- package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
- package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
- package/src/cli/commands/studio/views/document.tsx +411 -107
- package/src/cli/commands/studio/views/model.ts +14 -1
- package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
- package/src/cli/commands/studio/views/styles.ts +106 -13
- package/src/cli/commands/studio/views/terminal.tsx +426 -0
- package/src/cli/commands/studio/views/vault/state.ts +42 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
- package/src/cli/commands/update.ts +11 -8
- package/src/cli/companion-link-wizard.tsx +1 -1
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/launch-selector.tsx +1 -1
- package/src/cli/main.ts +2 -1
- package/src/cli/usage.ts +7 -3
- package/src/cli/write-json-stdout.ts +2 -2
- package/src/lib/components/spinner.tsx +2 -2
- package/src/lib/components/startup-progress.tsx +1 -1
- package/src/lib/context-mode-package.ts +1 -1
- package/src/lib/opencode-plugin-package.ts +3 -0
- package/src/lib/orchestrator/adapters/opencode.ts +53 -13
- package/src/lib/orchestrator/companion-hub.ts +3 -3
- package/src/lib/orchestrator/companion-registry-store.ts +1 -1
- package/src/lib/orchestrator/config-store.ts +2 -2
- package/src/lib/orchestrator/editor.ts +4 -12
- package/src/lib/orchestrator/engine-guard.ts +9 -1
- package/src/lib/orchestrator/projection-entries.ts +1 -1
- package/src/lib/orchestrator/projection-record.ts +1 -1
- package/src/lib/orchestrator/projection-types.ts +0 -4
- package/src/lib/orchestrator/repo-local-registry.ts +0 -1
- package/src/lib/orchestrator/root-context.ts +1 -1
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +1 -1
- package/src/lib/orchestrator/types.ts +1 -1
- package/src/lib/orchestrator/working-repo-projection.ts +1 -1
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/package-paths.ts +1 -11
- package/src/lib/package-root.ts +43 -0
- package/src/lib/prebuilt-workspace.ts +1 -1
- package/src/lib/public-npm.ts +1 -1
- package/src/opencode/companion-hooks.ts +131 -153
- package/src/opencode/index.ts +12 -6
- package/src/opencode/tui.tsx +46 -49
- package/src/playbooks/companion-guidance.ts +1 -28
- package/src/runtime/companion-sync.ts +0 -3
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/providers/opencode/opencode.json +6 -4
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
- package/src/tools/setup/capabilities/context-mode.ts +45 -25
- package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
- package/src/tools/setup/capabilities/graphify.ts +2 -2
- package/src/tools/setup/capabilities/openspec.ts +2 -7
- package/src/tools/setup/capabilities/react-doctor.ts +1 -1
- package/src/tools/setup/capabilities/rtk.ts +2 -2
- package/src/tools/setup/capabilities/tokensave.ts +98 -12
- package/src/tools/setup/context-services.ts +3 -6
- package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
- package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
- package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
- package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
- package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
- package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +1 -1
- package/src/tools/setup/providers/claude.ts +1 -1
- package/src/tools/setup/providers/opencode-format.ts +220 -25
- package/src/tools/setup/providers/opencode.ts +63 -21
- package/src/tools/setup/runtime-documents.ts +9 -11
- package/src/tools/setup.ts +0 -4
- package/src/cli/commands/studio/views/pairings.tsx +0 -38
- package/src/templates/providers/opencode/tui.json +0 -3
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/** @jsxImportSource hono/jsx */
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
companionDigest,
|
|
5
|
+
openFile,
|
|
6
|
+
openFolder,
|
|
7
|
+
refresh,
|
|
8
|
+
type StudioSelection,
|
|
9
|
+
type StudioVaultSelection,
|
|
10
|
+
type StudioView,
|
|
11
|
+
switchView,
|
|
12
|
+
toHref,
|
|
13
|
+
} from "../selection";
|
|
4
14
|
import { STUDIO_CLIENT_SCRIPT, STUDIO_PREPAINT_SCRIPT } from "./client";
|
|
5
15
|
import { CompanionPicker } from "./companion-picker";
|
|
6
16
|
import { CompanionSelector } from "./companion-selector";
|
|
@@ -9,9 +19,18 @@ import { CompanionError } from "./error";
|
|
|
9
19
|
import { formatCollectedAt, type StudioPage, type StudioVaultPage } from "./model";
|
|
10
20
|
import type { VaultTreeNode } from "../vault";
|
|
11
21
|
import { Specs } from "./specs/index";
|
|
22
|
+
import { SelectionFields } from "./selection-fields";
|
|
12
23
|
import { STUDIO_STYLES } from "./styles";
|
|
13
24
|
import { Skills } from "./skills/index";
|
|
14
25
|
import { Workflow } from "./workflow/index";
|
|
26
|
+
import {
|
|
27
|
+
STUDIO_TERMINAL_PREPAINT_SCRIPT,
|
|
28
|
+
STUDIO_TERMINAL_SCRIPT,
|
|
29
|
+
STUDIO_TERMINAL_SIDEBAR_SCRIPT,
|
|
30
|
+
TerminalDrawerButton,
|
|
31
|
+
TerminalSidebar,
|
|
32
|
+
} from "./terminal";
|
|
33
|
+
import { TERMINAL_SCRIPTS, TERMINAL_STYLESHEET } from "../terminal-assets";
|
|
15
34
|
|
|
16
35
|
const STUDIO_TITLE = "Mate Studio";
|
|
17
36
|
|
|
@@ -46,6 +65,13 @@ const VIEW_DETAILS = {
|
|
|
46
65
|
* escape hatch around the renderer's escaping.
|
|
47
66
|
*/
|
|
48
67
|
export function renderStudioDocument(page: StudioPage): string {
|
|
68
|
+
/** The terminal client is Studio's only library, served by Studio itself and only with the terminal. */
|
|
69
|
+
const terminalHead = page.terminal
|
|
70
|
+
? `\n<link rel="stylesheet" href="${TERMINAL_STYLESHEET}">\n<script>${STUDIO_TERMINAL_PREPAINT_SCRIPT}</script>`
|
|
71
|
+
: "";
|
|
72
|
+
const terminalBody = page.terminal
|
|
73
|
+
? `${TERMINAL_SCRIPTS.map((src) => `\n<script src="${src}"></script>`).join("")}\n<script>${STUDIO_TERMINAL_SIDEBAR_SCRIPT}</script>\n<script>${STUDIO_TERMINAL_SCRIPT}</script>`
|
|
74
|
+
: "";
|
|
49
75
|
return `<!doctype html>
|
|
50
76
|
<html lang="en">
|
|
51
77
|
<head>
|
|
@@ -53,10 +79,10 @@ export function renderStudioDocument(page: StudioPage): string {
|
|
|
53
79
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
54
80
|
<title>${STUDIO_TITLE}</title>
|
|
55
81
|
<style>${STUDIO_STYLES}</style>
|
|
56
|
-
<script>${STUDIO_PREPAINT_SCRIPT}</script
|
|
82
|
+
<script>${STUDIO_PREPAINT_SCRIPT}</script>${terminalHead}
|
|
57
83
|
</head>
|
|
58
84
|
<body>${String(<StudioShell page={page} />)}
|
|
59
|
-
<script>${STUDIO_CLIENT_SCRIPT}</script
|
|
85
|
+
<script>${STUDIO_CLIENT_SCRIPT}</script>${terminalBody}
|
|
60
86
|
</body>
|
|
61
87
|
</html>
|
|
62
88
|
`;
|
|
@@ -72,13 +98,16 @@ function StudioShell({ page }: { page: StudioPage }) {
|
|
|
72
98
|
<div
|
|
73
99
|
className="shell"
|
|
74
100
|
data-companion={page.companion ? companionDigest(page.companion.path) : undefined}
|
|
101
|
+
data-terminal={page.terminal ? "" : undefined}
|
|
75
102
|
>
|
|
76
103
|
<Sidebar page={page} />
|
|
77
104
|
<main className="main">
|
|
78
105
|
{page.companion ? <PageHeader page={page} /> : null}
|
|
79
106
|
<Content page={page} />
|
|
80
107
|
</main>
|
|
108
|
+
{page.terminal ? <TerminalSidebar terminal={page.terminal} /> : null}
|
|
81
109
|
</div>
|
|
110
|
+
{page.terminal ? <TerminalDrawerButton /> : null}
|
|
82
111
|
<div className="toast" id="studio-toast" data-shown="false" />
|
|
83
112
|
</>
|
|
84
113
|
);
|
|
@@ -91,7 +120,7 @@ function Content({ page }: { page: StudioPage }) {
|
|
|
91
120
|
if (page.error) {
|
|
92
121
|
return <CompanionError companionPath={page.error.companionPath} reason={page.error.reason} />;
|
|
93
122
|
}
|
|
94
|
-
if (page.selection.view === "vault") return <Vault page={page} />;
|
|
123
|
+
if (page.selection.view === "vault") return <Vault page={page} selection={page.selection} />;
|
|
95
124
|
if (!page.payload) {
|
|
96
125
|
return (
|
|
97
126
|
<section className="panel">
|
|
@@ -150,49 +179,32 @@ function PageHeader({ page }: { page: StudioPage }) {
|
|
|
150
179
|
);
|
|
151
180
|
}
|
|
152
181
|
|
|
182
|
+
const VIEW_NAV: readonly (readonly [StudioView, string])[] = [
|
|
183
|
+
["dashboard", "Overview"],
|
|
184
|
+
["vault", "Vault"],
|
|
185
|
+
["specs", "Specs"],
|
|
186
|
+
["workflow", "Workflow"],
|
|
187
|
+
["skills", "Skills"],
|
|
188
|
+
];
|
|
189
|
+
|
|
153
190
|
/**
|
|
154
|
-
*
|
|
155
|
-
* the browser's history moves between rendered states.
|
|
191
|
+
* One GET form per view, so switching a view is a navigation to the URL naming
|
|
192
|
+
* it and the browser's history moves between rendered states.
|
|
156
193
|
*/
|
|
157
194
|
function ViewNav({ selection }: { selection: StudioSelection }) {
|
|
158
195
|
return (
|
|
159
196
|
<nav className="sidebar-nav" aria-label="Studio views">
|
|
160
197
|
<span className="sidebar-label">Views</span>
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
type="submit"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
</button>
|
|
172
|
-
<button type="submit" name="view" value="vault" aria-pressed={selection.view === "vault"}>
|
|
173
|
-
<span>Vault</span>
|
|
174
|
-
</button>
|
|
175
|
-
<button type="submit" name="view" value="specs" aria-pressed={selection.view === "specs"}>
|
|
176
|
-
<span>Specs</span>
|
|
177
|
-
</button>
|
|
178
|
-
<button
|
|
179
|
-
type="submit"
|
|
180
|
-
name="view"
|
|
181
|
-
value="workflow"
|
|
182
|
-
aria-pressed={selection.view === "workflow"}
|
|
183
|
-
>
|
|
184
|
-
<span>Workflow</span>
|
|
185
|
-
</button>
|
|
186
|
-
<button
|
|
187
|
-
type="submit"
|
|
188
|
-
name="view"
|
|
189
|
-
value="skills"
|
|
190
|
-
aria-pressed={selection.view === "skills"}
|
|
191
|
-
>
|
|
192
|
-
<span>Skills</span>
|
|
193
|
-
</button>
|
|
194
|
-
</div>
|
|
195
|
-
</form>
|
|
198
|
+
<div className="sidebar-nav-list">
|
|
199
|
+
{VIEW_NAV.map(([view, label]) => (
|
|
200
|
+
<form key={view} method="get" action="/" data-studio-view={view}>
|
|
201
|
+
<SelectionFields selection={switchView(selection, view)} />
|
|
202
|
+
<button type="submit" aria-pressed={selection.view === view}>
|
|
203
|
+
<span>{label}</span>
|
|
204
|
+
</button>
|
|
205
|
+
</form>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
196
208
|
</nav>
|
|
197
209
|
);
|
|
198
210
|
}
|
|
@@ -218,10 +230,7 @@ function Metrics({ page }: { page: StudioPage }) {
|
|
|
218
230
|
);
|
|
219
231
|
}
|
|
220
232
|
|
|
221
|
-
/**
|
|
222
|
-
* The refresh control is the only thing that collects again, so it is the only
|
|
223
|
-
* control that names the refresh parameter.
|
|
224
|
-
*/
|
|
233
|
+
/** The refresh control is the only thing that collects again, so only it leads to a refresh. */
|
|
225
234
|
function Footer({ page }: { page: StudioPage }) {
|
|
226
235
|
return (
|
|
227
236
|
<div className="sidebar-footer">
|
|
@@ -232,8 +241,7 @@ function Footer({ page }: { page: StudioPage }) {
|
|
|
232
241
|
: `state as of ${formatCollectedAt(page.collectedAt)}`}
|
|
233
242
|
</span>
|
|
234
243
|
<form method="get" action="/">
|
|
235
|
-
<SelectionFields selection={page.selection} />
|
|
236
|
-
<input type="hidden" name={REFRESH_PARAM} value="1" />
|
|
244
|
+
<SelectionFields selection={refresh(page.selection)} />
|
|
237
245
|
<button type="submit">Refresh</button>
|
|
238
246
|
</form>
|
|
239
247
|
</div>
|
|
@@ -244,32 +252,67 @@ function Footer({ page }: { page: StudioPage }) {
|
|
|
244
252
|
);
|
|
245
253
|
}
|
|
246
254
|
|
|
247
|
-
/**
|
|
248
|
-
function
|
|
249
|
-
selection,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}: {
|
|
253
|
-
selection: StudioSelection;
|
|
254
|
-
omit?: keyof StudioSelection;
|
|
255
|
-
dropOpenPath?: boolean;
|
|
256
|
-
}) {
|
|
257
|
-
return (
|
|
255
|
+
/** The tree and folder listing, fetched by the page when it was served before the listing finished. */
|
|
256
|
+
export function renderVaultView(page: StudioPage): string {
|
|
257
|
+
const { selection, vault } = page;
|
|
258
|
+
if (!vault || selection.view !== "vault") return "";
|
|
259
|
+
return String(
|
|
258
260
|
<>
|
|
259
|
-
{selection
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
) : null}
|
|
265
|
-
{selection.view === "vault" && selection.openPath && !dropOpenPath ? (
|
|
266
|
-
<input type="hidden" name={FILE_PARAM} value={selection.openPath} />
|
|
267
|
-
) : null}
|
|
268
|
-
</>
|
|
261
|
+
<VaultTreeSlot vault={vault} selection={selection} />
|
|
262
|
+
{vault.open || vault.refusal || selection.openPath ? null : (
|
|
263
|
+
<VaultListingSlot selection={selection} vault={vault} />
|
|
264
|
+
)}
|
|
265
|
+
</>,
|
|
269
266
|
);
|
|
270
267
|
}
|
|
271
268
|
|
|
272
|
-
function
|
|
269
|
+
function segments(relative: string): string[] {
|
|
270
|
+
return relative.split(/[\\/]/).filter(Boolean);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Walks node names, so a requested folder is only ever one the listed tree holds. */
|
|
274
|
+
function findDirectory(tree: VaultTreeNode[], relative: string): VaultTreeNode | null {
|
|
275
|
+
let level = tree;
|
|
276
|
+
let found: VaultTreeNode | null = null;
|
|
277
|
+
for (const name of segments(relative)) {
|
|
278
|
+
found = level.find((node) => node.kind === "directory" && node.name === name) ?? null;
|
|
279
|
+
if (!found) return null;
|
|
280
|
+
level = found.children ?? [];
|
|
281
|
+
}
|
|
282
|
+
return found;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function expandedPaths(selection: StudioVaultSelection): Set<string> {
|
|
286
|
+
const expanded = new Set<string>();
|
|
287
|
+
const add = (relative: string, includeLast: boolean) => {
|
|
288
|
+
const parts = segments(relative);
|
|
289
|
+
const count = includeLast ? parts.length : parts.length - 1;
|
|
290
|
+
for (let index = 1; index <= count; index += 1) expanded.add(parts.slice(0, index).join("/"));
|
|
291
|
+
};
|
|
292
|
+
if (selection.openPath) add(selection.openPath, false);
|
|
293
|
+
if (selection.openDir) add(selection.openDir, true);
|
|
294
|
+
return expanded;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function byKindThenName(a: VaultTreeNode, b: VaultTreeNode): number {
|
|
298
|
+
if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1;
|
|
299
|
+
return a.name.localeCompare(b.name);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Drawn in CSS: one empty element per entry keeps a large tree's markup small. */
|
|
303
|
+
function FolderIcon() {
|
|
304
|
+
return <span className="vault-icon vault-icon-folder" aria-hidden="true" />;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function FileIcon() {
|
|
308
|
+
return <span className="vault-icon vault-icon-file" aria-hidden="true" />;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function Chevron() {
|
|
312
|
+
return <span className="vault-chevron" aria-hidden="true" />;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function Vault({ page, selection }: { page: StudioPage; selection: StudioVaultSelection }) {
|
|
273
316
|
const vault = page.vault;
|
|
274
317
|
if (!vault)
|
|
275
318
|
return (
|
|
@@ -277,65 +320,325 @@ function Vault({ page }: { page: StudioPage }) {
|
|
|
277
320
|
<p className="empty">No vault state collected.</p>
|
|
278
321
|
</section>
|
|
279
322
|
);
|
|
323
|
+
const showListing = !vault.open && !vault.refusal;
|
|
280
324
|
return (
|
|
281
|
-
<section
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
: "Every non-ignored markdown file in this companion."}
|
|
290
|
-
</p>
|
|
291
|
-
</div>
|
|
292
|
-
{!vault.watching ? (
|
|
293
|
-
<form method="get" action="/" data-studio-navigation>
|
|
294
|
-
<SelectionFields selection={page.selection} />
|
|
295
|
-
<input type="hidden" name={REFRESH_PARAM} value="1" />
|
|
296
|
-
<button type="submit">Refresh tree</button>
|
|
297
|
-
</form>
|
|
298
|
-
) : null}
|
|
325
|
+
<section
|
|
326
|
+
className="vault-layout"
|
|
327
|
+
data-vault-layout
|
|
328
|
+
data-vault-view={vault.tree === null ? toHref(selection, "/api/vault/view") : undefined}
|
|
329
|
+
>
|
|
330
|
+
<aside className="panel vault-tree-panel" id="vault-tree-panel" aria-label="Files">
|
|
331
|
+
<div className="vault-tree-head">
|
|
332
|
+
<strong>Files</strong>
|
|
299
333
|
</div>
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
334
|
+
<input
|
|
335
|
+
type="search"
|
|
336
|
+
id="vault-filter"
|
|
337
|
+
className="vault-filter"
|
|
338
|
+
placeholder="Go to file"
|
|
339
|
+
aria-label="Go to file"
|
|
340
|
+
hidden
|
|
341
|
+
/>
|
|
342
|
+
<VaultTreeSlot vault={vault} selection={selection} />
|
|
343
|
+
</aside>
|
|
344
|
+
<div className="vault-main">
|
|
345
|
+
<div className="vault-bar">
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
id="vault-tree-toggle"
|
|
349
|
+
className="vault-tree-toggle"
|
|
350
|
+
aria-controls="vault-tree-panel"
|
|
351
|
+
aria-expanded="true"
|
|
352
|
+
hidden
|
|
353
|
+
>
|
|
354
|
+
Hide files
|
|
355
|
+
</button>
|
|
356
|
+
<VaultBreadcrumb page={page} selection={selection} />
|
|
357
|
+
</div>
|
|
358
|
+
{showListing ? <VaultListingSlot selection={selection} vault={vault} /> : null}
|
|
359
|
+
{showListing ? null : <VaultEditor page={page} selection={selection} vault={vault} />}
|
|
308
360
|
</div>
|
|
309
|
-
<VaultEditor page={page} vault={vault} />
|
|
310
361
|
</section>
|
|
311
362
|
);
|
|
312
363
|
}
|
|
313
364
|
|
|
314
|
-
function
|
|
365
|
+
function RefreshTree({ selection }: { selection: StudioVaultSelection }) {
|
|
366
|
+
return (
|
|
367
|
+
<form method="get" action="/" data-studio-navigation>
|
|
368
|
+
<SelectionFields selection={refresh(selection)} />
|
|
369
|
+
<button type="submit">Refresh tree</button>
|
|
370
|
+
</form>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function VaultTreeSlot({
|
|
375
|
+
vault,
|
|
376
|
+
selection,
|
|
377
|
+
}: {
|
|
378
|
+
vault: StudioVaultPage;
|
|
379
|
+
selection: StudioVaultSelection;
|
|
380
|
+
}) {
|
|
381
|
+
if (vault.failure) {
|
|
382
|
+
return (
|
|
383
|
+
<div data-vault-slot="tree">
|
|
384
|
+
<p className="note vault-warning">The files could not be listed: {vault.failure}</p>
|
|
385
|
+
<RefreshTree selection={selection} />
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (vault.tree === null) {
|
|
390
|
+
return (
|
|
391
|
+
<div data-vault-slot="tree" aria-busy="true">
|
|
392
|
+
<p className="empty">Listing files…</p>
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
const expanded = expandedPaths(selection);
|
|
397
|
+
return (
|
|
398
|
+
<div data-vault-slot="tree">
|
|
399
|
+
{vault.warning ? (
|
|
400
|
+
<p className="note vault-warning">{vault.warning}. The tree may be out of date.</p>
|
|
401
|
+
) : null}
|
|
402
|
+
{!vault.watching ? <RefreshTree selection={selection} /> : null}
|
|
403
|
+
{vault.tree.length === 0 ? (
|
|
404
|
+
<p className="empty">This companion has no markdown files.</p>
|
|
405
|
+
) : (
|
|
406
|
+
<nav className="vault-tree" aria-label="Markdown files">
|
|
407
|
+
{vault.tree.toSorted(byKindThenName).map((node) => (
|
|
408
|
+
<VaultNode key={node.path} node={node} selection={selection} expanded={expanded} />
|
|
409
|
+
))}
|
|
410
|
+
</nav>
|
|
411
|
+
)}
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function VaultNode({
|
|
417
|
+
node,
|
|
418
|
+
selection,
|
|
419
|
+
expanded,
|
|
420
|
+
}: {
|
|
421
|
+
node: VaultTreeNode;
|
|
422
|
+
selection: StudioVaultSelection;
|
|
423
|
+
expanded: Set<string>;
|
|
424
|
+
}) {
|
|
425
|
+
const key = segments(node.path).join("/");
|
|
315
426
|
if (node.kind === "directory") {
|
|
427
|
+
const open = expanded.has(key);
|
|
316
428
|
return (
|
|
317
|
-
<details
|
|
318
|
-
|
|
429
|
+
<details
|
|
430
|
+
className="vault-directory"
|
|
431
|
+
open={open}
|
|
432
|
+
data-vault-dir={key}
|
|
433
|
+
data-vault-expanded={open ? "" : undefined}
|
|
434
|
+
>
|
|
435
|
+
<summary
|
|
436
|
+
aria-current={
|
|
437
|
+
selection.openDir && segments(selection.openDir).join("/") === key ? "page" : undefined
|
|
438
|
+
}
|
|
439
|
+
>
|
|
440
|
+
<Chevron />
|
|
441
|
+
<FolderIcon />
|
|
442
|
+
<span>{node.name}</span>
|
|
443
|
+
</summary>
|
|
319
444
|
<div className="vault-children">
|
|
320
|
-
{node.children
|
|
321
|
-
<VaultNode key={child.path} node={child} selection={selection} />
|
|
445
|
+
{(node.children ?? []).toSorted(byKindThenName).map((child) => (
|
|
446
|
+
<VaultNode key={child.path} node={child} selection={selection} expanded={expanded} />
|
|
322
447
|
))}
|
|
323
448
|
</div>
|
|
324
449
|
</details>
|
|
325
450
|
);
|
|
326
451
|
}
|
|
327
452
|
return (
|
|
328
|
-
<form
|
|
329
|
-
|
|
330
|
-
|
|
453
|
+
<form
|
|
454
|
+
method="get"
|
|
455
|
+
action="/"
|
|
456
|
+
className="vault-file"
|
|
457
|
+
data-studio-navigation
|
|
458
|
+
data-vault-entry={key}
|
|
459
|
+
>
|
|
460
|
+
<SelectionFields selection={openFile(selection, node.path)} />
|
|
331
461
|
<button type="submit" aria-current={selection.openPath === node.path ? "page" : undefined}>
|
|
332
|
-
|
|
462
|
+
<FileIcon />
|
|
463
|
+
<span>{node.name}</span>
|
|
464
|
+
</button>
|
|
465
|
+
</form>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function OpenFolder({
|
|
470
|
+
selection,
|
|
471
|
+
relative,
|
|
472
|
+
className,
|
|
473
|
+
children,
|
|
474
|
+
}: {
|
|
475
|
+
selection: StudioVaultSelection;
|
|
476
|
+
relative: string | null;
|
|
477
|
+
className?: string;
|
|
478
|
+
children: unknown;
|
|
479
|
+
}) {
|
|
480
|
+
return (
|
|
481
|
+
<form method="get" action="/" className={className} data-studio-navigation>
|
|
482
|
+
<SelectionFields selection={openFolder(selection, relative)} />
|
|
483
|
+
<button type="submit">{children}</button>
|
|
484
|
+
</form>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Built from the selection alone, so it renders before the tree is listed. */
|
|
489
|
+
function VaultBreadcrumb({
|
|
490
|
+
page,
|
|
491
|
+
selection,
|
|
492
|
+
}: {
|
|
493
|
+
page: StudioPage;
|
|
494
|
+
selection: StudioVaultSelection;
|
|
495
|
+
}) {
|
|
496
|
+
const current = selection.openPath ?? selection.openDir ?? "";
|
|
497
|
+
const parts = segments(current);
|
|
498
|
+
const rootName = page.companion
|
|
499
|
+
? (segments(page.companion.path).at(-1) ?? "companion")
|
|
500
|
+
: "companion";
|
|
501
|
+
return (
|
|
502
|
+
<nav className="vault-breadcrumb" aria-label="Breadcrumb">
|
|
503
|
+
<ol>
|
|
504
|
+
<li>
|
|
505
|
+
{parts.length === 0 ? (
|
|
506
|
+
<span aria-current="page">{rootName}</span>
|
|
507
|
+
) : (
|
|
508
|
+
<OpenFolder selection={selection} relative={null} className="vault-crumb">
|
|
509
|
+
{rootName}
|
|
510
|
+
</OpenFolder>
|
|
511
|
+
)}
|
|
512
|
+
</li>
|
|
513
|
+
{parts.map((name, index) => (
|
|
514
|
+
<li key={parts.slice(0, index + 1).join("/")}>
|
|
515
|
+
{index === parts.length - 1 ? (
|
|
516
|
+
<span aria-current="page">{name}</span>
|
|
517
|
+
) : (
|
|
518
|
+
<OpenFolder
|
|
519
|
+
selection={selection}
|
|
520
|
+
relative={parts.slice(0, index + 1).join("/")}
|
|
521
|
+
className="vault-crumb"
|
|
522
|
+
>
|
|
523
|
+
{name}
|
|
524
|
+
</OpenFolder>
|
|
525
|
+
)}
|
|
526
|
+
</li>
|
|
527
|
+
))}
|
|
528
|
+
</ol>
|
|
529
|
+
</nav>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function VaultListingSlot({
|
|
534
|
+
selection,
|
|
535
|
+
vault,
|
|
536
|
+
}: {
|
|
537
|
+
selection: StudioVaultSelection;
|
|
538
|
+
vault: StudioVaultPage;
|
|
539
|
+
}) {
|
|
540
|
+
if (vault.failure) {
|
|
541
|
+
return (
|
|
542
|
+
<div data-vault-slot="listing" className="panel vault-listing">
|
|
543
|
+
<p className="note vault-warning">The files could not be listed: {vault.failure}</p>
|
|
544
|
+
</div>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
if (vault.tree === null) {
|
|
548
|
+
return (
|
|
549
|
+
<div data-vault-slot="listing" className="panel vault-listing" aria-busy="true">
|
|
550
|
+
<p className="empty">Listing files…</p>
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
const requested = selection.openDir ? findDirectory(vault.tree, selection.openDir) : null;
|
|
555
|
+
const entries = (requested ? (requested.children ?? []) : vault.tree).toSorted(byKindThenName);
|
|
556
|
+
const parts = requested ? segments(selection.openDir ?? "") : [];
|
|
557
|
+
return (
|
|
558
|
+
<div data-vault-slot="listing" className="panel vault-listing">
|
|
559
|
+
{selection.openDir !== null && requested === null ? (
|
|
560
|
+
<p className="note vault-warning">That folder is not in the tree; showing the root.</p>
|
|
561
|
+
) : null}
|
|
562
|
+
{entries.length === 0 && parts.length === 0 ? (
|
|
563
|
+
<p className="empty">This companion has no markdown files.</p>
|
|
564
|
+
) : (
|
|
565
|
+
<VaultListingRows selection={selection} parts={parts} entries={entries} />
|
|
566
|
+
)}
|
|
567
|
+
</div>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function VaultListingRows({
|
|
572
|
+
selection,
|
|
573
|
+
parts,
|
|
574
|
+
entries,
|
|
575
|
+
}: {
|
|
576
|
+
selection: StudioVaultSelection;
|
|
577
|
+
parts: string[];
|
|
578
|
+
entries: VaultTreeNode[];
|
|
579
|
+
}) {
|
|
580
|
+
return (
|
|
581
|
+
<ul className="vault-rows" aria-label="Folder contents">
|
|
582
|
+
{parts.length > 0 ? (
|
|
583
|
+
<li>
|
|
584
|
+
<OpenFolder
|
|
585
|
+
selection={selection}
|
|
586
|
+
relative={parts.length > 1 ? parts.slice(0, -1).join("/") : null}
|
|
587
|
+
className="vault-row"
|
|
588
|
+
>
|
|
589
|
+
<FolderIcon />
|
|
590
|
+
<span>..</span>
|
|
591
|
+
</OpenFolder>
|
|
592
|
+
</li>
|
|
593
|
+
) : null}
|
|
594
|
+
{entries.map((node) => (
|
|
595
|
+
<li key={node.path}>
|
|
596
|
+
<VaultListingEntry selection={selection} node={node} />
|
|
597
|
+
</li>
|
|
598
|
+
))}
|
|
599
|
+
</ul>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function VaultListingEntry({
|
|
604
|
+
selection,
|
|
605
|
+
node,
|
|
606
|
+
}: {
|
|
607
|
+
selection: StudioVaultSelection;
|
|
608
|
+
node: VaultTreeNode;
|
|
609
|
+
}) {
|
|
610
|
+
if (node.kind === "directory") {
|
|
611
|
+
return (
|
|
612
|
+
<OpenFolder
|
|
613
|
+
selection={selection}
|
|
614
|
+
relative={segments(node.path).join("/")}
|
|
615
|
+
className="vault-row"
|
|
616
|
+
>
|
|
617
|
+
<FolderIcon />
|
|
618
|
+
<span>{node.name}</span>
|
|
619
|
+
</OpenFolder>
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
return (
|
|
623
|
+
<form method="get" action="/" className="vault-row" data-studio-navigation>
|
|
624
|
+
<SelectionFields selection={openFile(selection, node.path)} />
|
|
625
|
+
<button type="submit">
|
|
626
|
+
<FileIcon />
|
|
627
|
+
<span>{node.name}</span>
|
|
333
628
|
</button>
|
|
334
629
|
</form>
|
|
335
630
|
);
|
|
336
631
|
}
|
|
337
632
|
|
|
338
|
-
function VaultEditor({
|
|
633
|
+
function VaultEditor({
|
|
634
|
+
page,
|
|
635
|
+
selection,
|
|
636
|
+
vault,
|
|
637
|
+
}: {
|
|
638
|
+
page: StudioPage;
|
|
639
|
+
selection: StudioVaultSelection;
|
|
640
|
+
vault: StudioVaultPage;
|
|
641
|
+
}) {
|
|
339
642
|
if (!vault.open) {
|
|
340
643
|
return (
|
|
341
644
|
<div className="panel vault-editor-panel">
|
|
@@ -354,7 +657,7 @@ function VaultEditor({ page, vault }: { page: StudioPage; vault: StudioVaultPage
|
|
|
354
657
|
>
|
|
355
658
|
<div className="section-header">
|
|
356
659
|
<div>
|
|
357
|
-
<h3>{vault.open.path}</h3>
|
|
660
|
+
<h3>{segments(vault.open.path).at(-1) ?? vault.open.path}</h3>
|
|
358
661
|
<p className="section-note">
|
|
359
662
|
{page.writable
|
|
360
663
|
? "Changes stay in the editor until you save."
|
|
@@ -371,6 +674,7 @@ function VaultEditor({ page, vault }: { page: StudioPage; vault: StudioVaultPage
|
|
|
371
674
|
id="vault-editor"
|
|
372
675
|
data-vault-editor
|
|
373
676
|
data-vault-path={vault.open.path}
|
|
677
|
+
data-vault-events={toHref(openFile(selection, vault.open.path), "/api/vault/events")}
|
|
374
678
|
data-vault-token={vault.open.token}
|
|
375
679
|
{...(page.writable ? {} : { readOnly: true })}
|
|
376
680
|
spellCheck={false}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { StudioInventory, StudioInventoryCompanion } from "../inventory";
|
|
2
2
|
import type { StudioCompanionPayload } from "../payload";
|
|
3
3
|
import type { StudioSelection } from "../selection";
|
|
4
|
+
import type { TerminalAgent } from "../terminal";
|
|
4
5
|
import type { VaultFile, VaultTreeNode } from "../vault";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -18,10 +19,22 @@ export interface StudioPage {
|
|
|
18
19
|
collectedAt: number | null;
|
|
19
20
|
writable: boolean;
|
|
20
21
|
vault: StudioVaultPage | null;
|
|
22
|
+
/** `null` unless the terminal is enabled. */
|
|
23
|
+
terminal: StudioTerminalPage | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StudioTerminalPage {
|
|
27
|
+
/** The launch companion was pinned at start and ignores the page selection. */
|
|
28
|
+
pinned: boolean;
|
|
29
|
+
target: { path: string; digest: string } | null;
|
|
30
|
+
agents: TerminalAgent[];
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
export interface StudioVaultPage {
|
|
24
|
-
tree
|
|
34
|
+
/** `null` while the listing runs; the page then fetches the tree separately. */
|
|
35
|
+
tree: VaultTreeNode[] | null;
|
|
36
|
+
/** Why the listing failed, whether requested in the page or separately. */
|
|
37
|
+
failure?: string | null;
|
|
25
38
|
open: VaultFile | null;
|
|
26
39
|
refusal: string | null;
|
|
27
40
|
incoming: VaultFile | null;
|