@1agh/maude 0.53.2 → 0.54.0
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/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/client/app.jsx +208 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +1007 -1007
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +145 -12
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/ws.ts +44 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- package/plugins/flow/dependencies.json +1 -1
package/apps/studio/api.ts
CHANGED
|
@@ -98,6 +98,7 @@ import { createHistory } from './history.ts';
|
|
|
98
98
|
import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts';
|
|
99
99
|
import { STICKERS_DIR } from './paths.ts';
|
|
100
100
|
import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
|
|
101
|
+
import { sessionDir } from './session-scope.ts';
|
|
101
102
|
|
|
102
103
|
// Directories that never hold user-facing canvases. Exported so the
|
|
103
104
|
// external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
|
|
@@ -1324,8 +1325,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1324
1325
|
|
|
1325
1326
|
// ---------- Canvas state ----------
|
|
1326
1327
|
|
|
1328
|
+
// Cloud Phase 27 D3 — one member's place in the project is not another's.
|
|
1329
|
+
// `sessionDir` is a no-op without an ambient session, so a desktop resolves
|
|
1330
|
+
// the identical path it always did.
|
|
1327
1331
|
function canvasStatePath(file: string): string {
|
|
1328
|
-
return path.join(paths.canvasStateDir, `${fileSlug(file)}.json`);
|
|
1332
|
+
return path.join(sessionDir(paths.canvasStateDir), `${fileSlug(file)}.json`);
|
|
1329
1333
|
}
|
|
1330
1334
|
|
|
1331
1335
|
async function loadCanvasState(file: string) {
|
|
@@ -1468,7 +1472,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1468
1472
|
* canvas-ext). Returns null when `file` is not a valid canvas path. */
|
|
1469
1473
|
function canvasViewPath(file: string): string | null {
|
|
1470
1474
|
if (!canvasMetaPath(file)) return null; // reuse the containment + ext gate
|
|
1471
|
-
|
|
1475
|
+
// D3 again — the camera is the other per-machine singleton two members in
|
|
1476
|
+
// one cell were silently sharing.
|
|
1477
|
+
return path.join(sessionDir(paths.canvasStateDir), `${fileSlug(file)}.view.json`);
|
|
1472
1478
|
}
|
|
1473
1479
|
|
|
1474
1480
|
const MAX_OVERLAY_KEYS = 32;
|
|
@@ -1572,7 +1578,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1572
1578
|
const vp = normalizeViewport(viewport);
|
|
1573
1579
|
if (!vp) return null;
|
|
1574
1580
|
try {
|
|
1575
|
-
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1581
|
+
await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
|
|
1576
1582
|
const current = await readCanvasViewRaw(file);
|
|
1577
1583
|
await Bun.write(viewAbs, `${JSON.stringify({ ...current, viewport: vp }, null, 2)}\n`);
|
|
1578
1584
|
return vp;
|
|
@@ -1597,7 +1603,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1597
1603
|
const ov = normalizeOverlays(overlays);
|
|
1598
1604
|
if (ov === null) return null;
|
|
1599
1605
|
try {
|
|
1600
|
-
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1606
|
+
await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
|
|
1601
1607
|
const current = await readCanvasViewRaw(file);
|
|
1602
1608
|
const prevOverlays =
|
|
1603
1609
|
current.overlays && typeof current.overlays === 'object' && !Array.isArray(current.overlays)
|
|
@@ -1710,7 +1716,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1710
1716
|
try {
|
|
1711
1717
|
const current = await readCanvasViewRaw(file);
|
|
1712
1718
|
const { overlays: _drop, ...rest } = current;
|
|
1713
|
-
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1719
|
+
await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
|
|
1714
1720
|
await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
|
|
1715
1721
|
} catch {
|
|
1716
1722
|
/* best-effort clear */
|
|
@@ -1732,12 +1738,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1732
1738
|
const current = await readCanvasViewRaw(file);
|
|
1733
1739
|
if (patch.locked === null) {
|
|
1734
1740
|
const { locked: _drop, ...rest } = current;
|
|
1735
|
-
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1741
|
+
await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
|
|
1736
1742
|
await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
|
|
1737
1743
|
} else {
|
|
1738
1744
|
const lk = normalizeLocked(patch.locked);
|
|
1739
1745
|
if (lk !== null) {
|
|
1740
|
-
await mkdir(paths.canvasStateDir, { recursive: true });
|
|
1746
|
+
await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
|
|
1741
1747
|
await Bun.write(viewAbs, `${JSON.stringify({ ...current, locked: lk }, null, 2)}\n`);
|
|
1742
1748
|
}
|
|
1743
1749
|
}
|
|
@@ -292,6 +292,27 @@ pw_screenshot() {
|
|
|
292
292
|
# iframe to appear. Every leg is best-effort — chrome is the point of this mode,
|
|
293
293
|
# so a missing row or a slow mount degrades to "chrome, nothing open" rather
|
|
294
294
|
# than failing the capture.
|
|
295
|
+
# A reachable port is not a mounted app: the server can answer while the client
|
|
296
|
+
# bundle is still parsing, or React can fail to mount and leave an empty #root
|
|
297
|
+
# (the exact shape of the v0.51.1 blank-window bug). Wait for the studio's own
|
|
298
|
+
# file list before capturing, so a broken shell fails loud instead of shipping a
|
|
299
|
+
# blank PNG that looks like a real screenshot.
|
|
300
|
+
await_studio_shell() {
|
|
301
|
+
[ "$ENGINE" = "agent-browser" ] || return 0
|
|
302
|
+
local poll=0
|
|
303
|
+
while [ $poll -lt "$TIMEOUT" ]; do
|
|
304
|
+
local n
|
|
305
|
+
n=$("$AB" eval "document.querySelectorAll('[data-testid^=\"canvas-row-\"]').length" 2>/dev/null | tr -d '[:space:]')
|
|
306
|
+
case "$n" in
|
|
307
|
+
''|*[!0-9]*|0) ;;
|
|
308
|
+
*) return 0 ;;
|
|
309
|
+
esac
|
|
310
|
+
sleep 1
|
|
311
|
+
poll=$((poll + 1))
|
|
312
|
+
done
|
|
313
|
+
return 1
|
|
314
|
+
}
|
|
315
|
+
|
|
295
316
|
open_active_in_shell() {
|
|
296
317
|
[ "$ENGINE" = "agent-browser" ] || return 0
|
|
297
318
|
[ -n "$ACTIVE" ] || { echo "→ shell: no active canvas — capturing chrome as-is" >&2; return 0; }
|
|
@@ -332,11 +353,22 @@ open_active_in_shell() {
|
|
|
332
353
|
|
|
333
354
|
navigate_once() {
|
|
334
355
|
if [ "$ENGINE" = "agent-browser" ]; then
|
|
335
|
-
|
|
356
|
+
# A failed navigation MUST propagate. agent-browser leaves the session on a
|
|
357
|
+
# blank page and `screenshot` then happily writes a ~4 kB white PNG, so
|
|
358
|
+
# ignoring this exit code turns "the server is down" into "here is your
|
|
359
|
+
# screenshot" — which for the Report-a-Bug shell shot means a blank image
|
|
360
|
+
# labelled "Maude window" lands in someone's bug report.
|
|
361
|
+
if ! "$AB" open "$URL" >&2; then
|
|
362
|
+
echo "✗ navigation failed: $URL" >&2
|
|
363
|
+
return 1
|
|
364
|
+
fi
|
|
336
365
|
# Shell mode lands on the studio root, which has no DC mount to poll for —
|
|
337
366
|
# the canvas arrives only after we click a row.
|
|
338
367
|
if [ "$MODE" = "shell" ]; then
|
|
339
|
-
|
|
368
|
+
if ! await_studio_shell; then
|
|
369
|
+
echo "✗ the studio shell never mounted at $URL" >&2
|
|
370
|
+
return 1
|
|
371
|
+
fi
|
|
340
372
|
open_active_in_shell
|
|
341
373
|
apply_theme_override
|
|
342
374
|
return 0
|
|
@@ -503,7 +535,15 @@ if [ $ALL_SCREENS -eq 1 ]; then
|
|
|
503
535
|
fi
|
|
504
536
|
|
|
505
537
|
# ---------- single-shot ----------
|
|
506
|
-
|
|
538
|
+
# A failed navigation is exactly the port-bounce case (DDR-094), so give
|
|
539
|
+
# relive_url its one shot before giving up rather than capturing a blank page.
|
|
540
|
+
if ! navigate_once; then
|
|
541
|
+
if ! relive_url || ! navigate_once; then
|
|
542
|
+
echo "✗ could not load $URL — no screenshot written" >&2
|
|
543
|
+
rm -f "$OUT"
|
|
544
|
+
exit 3
|
|
545
|
+
fi
|
|
546
|
+
fi
|
|
507
547
|
if capture_resilient "$CSS_SEL" "$OUT"; then
|
|
508
548
|
echo "$OUT"
|
|
509
549
|
exit 0
|
package/apps/studio/build.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
21
21
|
|
|
22
22
|
import { browserslistToTargets, bundle as lcssBundle } from 'lightningcss';
|
|
23
23
|
|
|
24
|
+
import { cloudStubPlugin } from './cloud-build.ts';
|
|
25
|
+
|
|
24
26
|
const ROOT = dirname(fileURLToPath(import.meta.url));
|
|
25
27
|
const DIST = join(ROOT, 'dist');
|
|
26
28
|
|
|
@@ -32,6 +34,9 @@ const MODE: 'dev' | 'release' | 'dry' = ARGS.has('--dry-run')
|
|
|
32
34
|
? 'release'
|
|
33
35
|
: 'dev';
|
|
34
36
|
const WATCH = ARGS.has('--watch');
|
|
37
|
+
/** Cloud Phase 27 D1 — build the CELL's variant (secret-bearing surfaces
|
|
38
|
+
* removed from the binary, not merely un-routed). `--cloud` with `--release`. */
|
|
39
|
+
const CLOUD = ARGS.has('--cloud');
|
|
35
40
|
|
|
36
41
|
const PLATFORM_MATRIX = [
|
|
37
42
|
'bun-darwin-arm64',
|
|
@@ -336,11 +341,20 @@ import ${JSON.stringify(realServerRel)};
|
|
|
336
341
|
return entryPath;
|
|
337
342
|
}
|
|
338
343
|
|
|
339
|
-
async function buildServerBinary(
|
|
344
|
+
async function buildServerBinary(
|
|
345
|
+
target: PlatformTarget,
|
|
346
|
+
/**
|
|
347
|
+
* Cloud Phase 27 D1 — the variant a CELL runs, with the secret-bearing
|
|
348
|
+
* surfaces removed from the binary rather than merely un-routed. Named
|
|
349
|
+
* separately (`maude-cloud-<slug>`) so a desktop release can never pick one
|
|
350
|
+
* up by accident, and so both can be built from one checkout.
|
|
351
|
+
*/
|
|
352
|
+
{ cloud = false }: { cloud?: boolean } = {}
|
|
353
|
+
): Promise<{ outPath: string }> {
|
|
340
354
|
ensureDist();
|
|
341
355
|
const slug = platformSlug(target);
|
|
342
356
|
const ext = slug.startsWith('win32') ? '.exe' : '';
|
|
343
|
-
const outPath = join(DIST, `maude-${slug}${ext}`);
|
|
357
|
+
const outPath = join(DIST, `maude-${cloud ? 'cloud-' : ''}${slug}${ext}`);
|
|
344
358
|
const realEntry = join(ROOT, 'server.ts');
|
|
345
359
|
if (!existsSync(realEntry)) {
|
|
346
360
|
// Legacy fallback — pre-DDR-009 .mjs path. Doesn't use oxc-parser, so the
|
|
@@ -369,6 +383,26 @@ async function buildServerBinary(target: PlatformTarget): Promise<{ outPath: str
|
|
|
369
383
|
// filter skipped it (cross-compile case — see ensureBindingForTarget docs).
|
|
370
384
|
await ensureBindingForTarget(oxcBindingSlug(slug));
|
|
371
385
|
const entry = writeCompileEntry(target);
|
|
386
|
+
|
|
387
|
+
// The CLOUD variant goes through the Bun.build API rather than the CLI,
|
|
388
|
+
// because that is the only path that takes a plugin — and a plugin is what
|
|
389
|
+
// makes D1 elimination rather than un-routing. `--define` + dead-code
|
|
390
|
+
// elimination was tried first and does not do it: Bun inlines the
|
|
391
|
+
// dynamically-imported module and keeps the branch, so the bytes stay.
|
|
392
|
+
if (cloud) {
|
|
393
|
+
const result = await Bun.build({
|
|
394
|
+
entrypoints: [entry],
|
|
395
|
+
target: 'bun',
|
|
396
|
+
minify: true,
|
|
397
|
+
plugins: [cloudStubPlugin(ROOT)],
|
|
398
|
+
compile: { target, outfile: outPath },
|
|
399
|
+
} as Parameters<typeof Bun.build>[0]);
|
|
400
|
+
if (!result.success) {
|
|
401
|
+
throw new Error(`cloud compile failed for ${target}:\n${result.logs.map(String).join('\n')}`);
|
|
402
|
+
}
|
|
403
|
+
return { outPath };
|
|
404
|
+
}
|
|
405
|
+
|
|
372
406
|
const proc = Bun.spawn(
|
|
373
407
|
[
|
|
374
408
|
'bun',
|
|
@@ -538,7 +572,7 @@ async function main() {
|
|
|
538
572
|
: [currentTarget()];
|
|
539
573
|
for (const target of targets) {
|
|
540
574
|
const t = performance.now();
|
|
541
|
-
const bin = await buildServerBinary(target);
|
|
575
|
+
const bin = await buildServerBinary(target, { cloud: CLOUD });
|
|
542
576
|
console.log(`[build] ${bin.outPath} (${(performance.now() - t).toFixed(0)} ms)`);
|
|
543
577
|
}
|
|
544
578
|
}
|
|
@@ -2430,7 +2430,12 @@ function Sidebar({
|
|
|
2430
2430
|
// affordances are absent (buttons, composer, row menus, drag & drop).
|
|
2431
2431
|
readOnly = false,
|
|
2432
2432
|
/** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
|
|
2433
|
-
|
|
2433
|
+
// Tri-state (see the note where this value is created): `undefined` until
|
|
2434
|
+
// the server config answers, `null` for the desktop, an object for a cloud
|
|
2435
|
+
// tab. Defaulting it to `null` here is what re-broke the boot 404 after the
|
|
2436
|
+
// call sites were fixed — a default fires on `undefined`, so "not known yet"
|
|
2437
|
+
// became "not cloud" one layer further in, invisibly.
|
|
2438
|
+
cloud,
|
|
2434
2439
|
groups,
|
|
2435
2440
|
activePath,
|
|
2436
2441
|
activeDsName,
|
|
@@ -2709,6 +2714,7 @@ function Sidebar({
|
|
|
2709
2714
|
<StIcon name="search" size={13} />
|
|
2710
2715
|
<input
|
|
2711
2716
|
type="search"
|
|
2717
|
+
data-testid="canvas-search"
|
|
2712
2718
|
placeholder="Search canvases…"
|
|
2713
2719
|
value={search}
|
|
2714
2720
|
onChange={(e) => setSearch(e.target.value)}
|
|
@@ -2832,7 +2838,7 @@ function Sidebar({
|
|
|
2832
2838
|
Absent rather than disabled, because unlike the agent chat there is
|
|
2833
2839
|
nothing here to explain — the capability is not missing, it is
|
|
2834
2840
|
already satisfied. */}
|
|
2835
|
-
{cloud
|
|
2841
|
+
{cloud === null ? <CloudBar /> : null}
|
|
2836
2842
|
{/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
|
|
2837
2843
|
sign in, connected account + New/Pull/Share, sign out. Self-contained
|
|
2838
2844
|
(owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
|
|
@@ -3622,7 +3628,12 @@ function Menubar({
|
|
|
3622
3628
|
activePath,
|
|
3623
3629
|
project,
|
|
3624
3630
|
/** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
|
|
3625
|
-
|
|
3631
|
+
// Tri-state (see the note where this value is created): `undefined` until
|
|
3632
|
+
// the server config answers, `null` for the desktop, an object for a cloud
|
|
3633
|
+
// tab. Defaulting it to `null` here is what re-broke the boot 404 after the
|
|
3634
|
+
// call sites were fixed — a default fires on `undefined`, so "not known yet"
|
|
3635
|
+
// became "not cloud" one layer further in, invisibly.
|
|
3636
|
+
cloud,
|
|
3626
3637
|
tabsCount,
|
|
3627
3638
|
openMenu,
|
|
3628
3639
|
setOpenMenu,
|
|
@@ -3890,7 +3901,12 @@ function Menubar({
|
|
|
3890
3901
|
}, [openMenu, setOpenMenu]);
|
|
3891
3902
|
|
|
3892
3903
|
return (
|
|
3893
|
-
<header
|
|
3904
|
+
<header
|
|
3905
|
+
className="st-menubar"
|
|
3906
|
+
role="menubar"
|
|
3907
|
+
aria-label="Application menubar"
|
|
3908
|
+
data-testid="menubar"
|
|
3909
|
+
>
|
|
3894
3910
|
<span className="st-brand" data-tour="brand">
|
|
3895
3911
|
<span className="st-brand-mark">
|
|
3896
3912
|
<svg viewBox="0 0 32 32" width="100%" height="100%" fill="none" aria-hidden="true"><path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" fill="currentColor" /></svg>
|
|
@@ -4044,11 +4060,45 @@ function Menubar({
|
|
|
4044
4060
|
<span
|
|
4045
4061
|
className="st-stamp st-stamp--viewonly"
|
|
4046
4062
|
data-testid="view-only-stamp"
|
|
4047
|
-
data-tip=
|
|
4063
|
+
data-tip={
|
|
4064
|
+
cloud?.user
|
|
4065
|
+
? `${cloud.user} is a viewer on this project — you can browse, comment and export. Ask a project owner for edit access, or sign out if that is not the account you meant to use.`
|
|
4066
|
+
: 'Your role in this project is viewer — you can browse, comment when available, and export. Ask a project owner for edit access.'
|
|
4067
|
+
}
|
|
4048
4068
|
>
|
|
4049
4069
|
VIEW ONLY
|
|
4050
4070
|
</span>
|
|
4051
4071
|
)}
|
|
4072
|
+
{/* Cloud Phase 27 C4 — WHO YOU ARE, AND HOW TO STOP BEING THEM.
|
|
4073
|
+
A browser tab carries no account identity of its own: the cookie is
|
|
4074
|
+
invisible, and the only thing on screen that reflected it was a
|
|
4075
|
+
three-word stamp. An owner who read VIEW ONLY had no way to tell
|
|
4076
|
+
whether the role was wrong or the ACCOUNT was, and no way to change
|
|
4077
|
+
either — the session outlived any fix by up to twelve hours because
|
|
4078
|
+
nothing in the studio could end it. This ends it. */}
|
|
4079
|
+
{cloud?.user ? (
|
|
4080
|
+
<span className="st-cloudwho" data-testid="cloud-account">
|
|
4081
|
+
<span
|
|
4082
|
+
className="st-cloudwho-email"
|
|
4083
|
+
title={cloud.role ? `Signed in as ${cloud.user} — ${cloud.role}` : cloud.user}
|
|
4084
|
+
>
|
|
4085
|
+
{cloud.user}
|
|
4086
|
+
</span>
|
|
4087
|
+
{/* A FORM, not a link. Signing out revokes the token server-side,
|
|
4088
|
+
so it is a write — and a write behind a GET is one any page can
|
|
4089
|
+
force on you from across the internet. */}
|
|
4090
|
+
<form method="post" action="/auth/browser/signout" className="st-cloudwho-form">
|
|
4091
|
+
<button
|
|
4092
|
+
type="submit"
|
|
4093
|
+
className="st-cloudwho-out"
|
|
4094
|
+
data-testid="cloud-signout"
|
|
4095
|
+
title="Sign out of this project"
|
|
4096
|
+
>
|
|
4097
|
+
Sign out
|
|
4098
|
+
</button>
|
|
4099
|
+
</form>
|
|
4100
|
+
</span>
|
|
4101
|
+
) : null}
|
|
4052
4102
|
{isNativeApp() && !readOnly && (
|
|
4053
4103
|
<button
|
|
4054
4104
|
type="button"
|
|
@@ -4514,7 +4564,7 @@ function StatusBar({
|
|
|
4514
4564
|
})();
|
|
4515
4565
|
|
|
4516
4566
|
return (
|
|
4517
|
-
<footer className="st-statusbar" role="contentinfo">
|
|
4567
|
+
<footer className="st-statusbar" role="contentinfo" data-testid="statusbar">
|
|
4518
4568
|
<span className="st-sb-slot st-sb-active" role="group" aria-label="Active file">
|
|
4519
4569
|
<span className="lead" aria-hidden="true" />
|
|
4520
4570
|
<span className="lbl">active</span>
|
|
@@ -4792,6 +4842,69 @@ function CommentsPanel({
|
|
|
4792
4842
|
// Phase 32 (Task 1) — auto-update notice. Shown after the shell has downloaded +
|
|
4793
4843
|
// staged a newer build in the background. Non-blocking: "Restart now" applies it,
|
|
4794
4844
|
// "Later" dismisses (the next focus/4h check re-stages and re-surfaces it).
|
|
4845
|
+
/**
|
|
4846
|
+
* What this role can do, said once, where a teammate lands — Cloud Phase 27 C3.
|
|
4847
|
+
*
|
|
4848
|
+
* A browser tab arrives with none of the context a desktop has: no window
|
|
4849
|
+
* title, no app the person chose to install, and — for a viewer — a set of
|
|
4850
|
+
* controls that will refuse them without explaining why. One line, dismissible,
|
|
4851
|
+
* remembered per role. It re-appears if the role CHANGES, because "you can edit
|
|
4852
|
+
* this now" is worth saying exactly as much as the first sentence was.
|
|
4853
|
+
*
|
|
4854
|
+
* Copy comes from the role the cell vouched (`role-matrix.mjs` is the authority
|
|
4855
|
+
* on what each one means; this is its sentence, not a second opinion).
|
|
4856
|
+
*/
|
|
4857
|
+
const ROLE_LINE = {
|
|
4858
|
+
owner: 'You own this project — edit, invite and export.',
|
|
4859
|
+
member: 'You can edit this project, comment and export.',
|
|
4860
|
+
viewer: 'You can look at this project, comment and download it, but not change it.',
|
|
4861
|
+
};
|
|
4862
|
+
|
|
4863
|
+
function CloudRoleBanner({ cloud }) {
|
|
4864
|
+
const role = cloud?.role || null;
|
|
4865
|
+
// Dismissal is READ AT RENDER, not seeded into state. `cfg.cloud` is
|
|
4866
|
+
// `undefined` until `/_config` lands, so a `useState` initializer runs while
|
|
4867
|
+
// there is no role yet — and whatever it decided then would stick forever,
|
|
4868
|
+
// which on the first attempt meant the banner never appeared at all. Found by
|
|
4869
|
+
// opening it in a browser; no test in this repo would have said a word.
|
|
4870
|
+
const [dismissedNow, setDismissedNow] = useState(false);
|
|
4871
|
+
if (!role || dismissedNow) return null;
|
|
4872
|
+
const storageKey = `maude-cloud-role-seen:${role}`;
|
|
4873
|
+
let alreadySeen = false;
|
|
4874
|
+
try {
|
|
4875
|
+
alreadySeen = localStorage.getItem(storageKey) === '1';
|
|
4876
|
+
} catch {
|
|
4877
|
+
/* private mode / storage disabled — show it, saying it twice beats never */
|
|
4878
|
+
}
|
|
4879
|
+
if (alreadySeen) return null;
|
|
4880
|
+
const line = ROLE_LINE[role];
|
|
4881
|
+
if (!line) return null;
|
|
4882
|
+
return (
|
|
4883
|
+
<div
|
|
4884
|
+
role="status"
|
|
4885
|
+
aria-live="polite"
|
|
4886
|
+
className="st-banner st-banner--info"
|
|
4887
|
+
data-testid="cloud-role-banner"
|
|
4888
|
+
>
|
|
4889
|
+
<span className="st-banner-dot" aria-hidden="true" />
|
|
4890
|
+
<span>{line}</span>
|
|
4891
|
+
<button
|
|
4892
|
+
type="button"
|
|
4893
|
+
className="btn btn--ghost btn--sm"
|
|
4894
|
+
data-testid="cloud-role-banner-dismiss"
|
|
4895
|
+
onClick={() => {
|
|
4896
|
+
setDismissedNow(true);
|
|
4897
|
+
try {
|
|
4898
|
+
localStorage.setItem(storageKey, '1');
|
|
4899
|
+
} catch {}
|
|
4900
|
+
}}
|
|
4901
|
+
>
|
|
4902
|
+
Got it
|
|
4903
|
+
</button>
|
|
4904
|
+
</div>
|
|
4905
|
+
);
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4795
4908
|
function UpdateBanner({ update, onDismiss }) {
|
|
4796
4909
|
const [restarting, setRestarting] = useState(false);
|
|
4797
4910
|
if (!update) return null;
|
|
@@ -8304,6 +8417,9 @@ function photoAssetOfSelection(el) {
|
|
|
8304
8417
|
}
|
|
8305
8418
|
|
|
8306
8419
|
function InspectorPanel({
|
|
8420
|
+
/** E4 — `inspector-panel` / `layers-panel`, so one parity spec can assert
|
|
8421
|
+
* both shells show them. Undefined elsewhere; the attribute simply absent. */
|
|
8422
|
+
testId,
|
|
8307
8423
|
selected,
|
|
8308
8424
|
onClose,
|
|
8309
8425
|
layersTree,
|
|
@@ -8773,6 +8889,7 @@ function InspectorPanel({
|
|
|
8773
8889
|
style={width ? { width, flexBasis: width } : undefined}
|
|
8774
8890
|
aria-label="Inspector"
|
|
8775
8891
|
data-tour="inspector"
|
|
8892
|
+
data-testid={testId}
|
|
8776
8893
|
>
|
|
8777
8894
|
<div
|
|
8778
8895
|
className="st-rp-tabs"
|
|
@@ -9236,7 +9353,18 @@ function App() {
|
|
|
9236
9353
|
// `config-updated` push (config.json hot-reload — /design:setup-ds rewrites
|
|
9237
9354
|
// it mid-session) — informs canvasUrl() so TSX iframes can pass the right
|
|
9238
9355
|
// ?designRel + ?tokens query to the canvas mount shell.
|
|
9239
|
-
|
|
9356
|
+
// `cloud` is deliberately TRI-STATE, and the third state is the useful one:
|
|
9357
|
+
// undefined — `/_config` has not answered yet; which shell this is is
|
|
9358
|
+
// UNKNOWN, and anything that would behave differently in the
|
|
9359
|
+
// two shells must wait rather than assume the desktop.
|
|
9360
|
+
// null — desktop or plain local browser.
|
|
9361
|
+
// object — a cloud tab (`{ dashboardUrl, projectName, user, role }`).
|
|
9362
|
+
//
|
|
9363
|
+
// Collapsing unknown into "not cloud" is what made the cloud studio open with
|
|
9364
|
+
// two console 404s every time: the sign-in bar and the export centre mounted
|
|
9365
|
+
// for one frame, each fired the request its shell exists to refuse, and then
|
|
9366
|
+
// unmounted. Nothing was broken and everything looked broken.
|
|
9367
|
+
const [cfg, setCfg] = useState({ designRel: '.design', cloud: undefined });
|
|
9240
9368
|
// Cloud Phase 25 C2 — viewer role, known at boot from /_config. Every
|
|
9241
9369
|
// editing affordance in the shell gates on this (absent, not hidden).
|
|
9242
9370
|
const viewerMode = !!cfg.readOnly;
|
|
@@ -9278,13 +9406,11 @@ function App() {
|
|
|
9278
9406
|
// both of these went missing and the cloud studio rendered without
|
|
9279
9407
|
// its chrome and with every canvas iframe unauthenticated.
|
|
9280
9408
|
canvasToken: data.canvasToken,
|
|
9281
|
-
// C2/C4 — `{ dashboardUrl, projectName }` when this is a
|
|
9282
|
-
// Its presence is what tells the shared client it is in a
|
|
9283
|
-
// on somebody else's machine
|
|
9409
|
+
// C2/C4 — `{ dashboardUrl, projectName, user, role }` when this is a
|
|
9410
|
+
// cloud tab. Its presence is what tells the shared client it is in a
|
|
9411
|
+
// browser tab on somebody else's machine; `user`/`role` are what let
|
|
9412
|
+
// it say WHICH account that tab is, and offer the way out.
|
|
9284
9413
|
cloud: data.cloud ?? null,
|
|
9285
|
-
// C2/C4 — `{ dashboardUrl, projectName }` when this is a cloud tab.
|
|
9286
|
-
// Its presence is what tells the shared client it is in a browser tab
|
|
9287
|
-
// on somebody else's machine.
|
|
9288
9414
|
}));
|
|
9289
9415
|
})
|
|
9290
9416
|
.catch(() => {});
|
|
@@ -9633,7 +9759,10 @@ function App() {
|
|
|
9633
9759
|
// toggles on its own and coexists with Inspector/Changes/Comments/Chat.
|
|
9634
9760
|
const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
|
|
9635
9761
|
const whatsNew = useWhatsNew(MDCC_VERSION);
|
|
9636
|
-
|
|
9762
|
+
// Same tri-state rule as the sign-in bar: the export queue is a LOCAL job
|
|
9763
|
+
// runner, `/_api/export-jobs` is one of the routes a cell refuses outright
|
|
9764
|
+
// (DDR-209 D1), and hydrating before the shell is known 404s every boot.
|
|
9765
|
+
const exportCenter = useExportCenter({ enabled: cfg.cloud === null });
|
|
9637
9766
|
// Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
|
|
9638
9767
|
// "welcome" project on first launch; we ask it whether this is a first run and, if
|
|
9639
9768
|
// so, show the wizard OVER the (empty) canvas browser. Completing any door switches
|
|
@@ -11006,6 +11135,41 @@ function App() {
|
|
|
11006
11135
|
if (path !== SYSTEM_TAB) setLoadingPath(path);
|
|
11007
11136
|
}, []);
|
|
11008
11137
|
|
|
11138
|
+
/**
|
|
11139
|
+
* First open lands on a rendered canvas — Cloud Phase 27 C3.
|
|
11140
|
+
*
|
|
11141
|
+
* A desktop user chose this project, installed the app and knows what is in
|
|
11142
|
+
* it; the empty state is a helpful "pick a screen". A teammate following a
|
|
11143
|
+
* link has done none of that, and an empty pane with an arrow pointing at a
|
|
11144
|
+
* list is the browser saying "the thing you came for is somewhere else".
|
|
11145
|
+
*
|
|
11146
|
+
* CLOUD ONLY, and once. The desktop's empty state is deliberately unchanged —
|
|
11147
|
+
* it earns its keep there (the ⌘-hover lesson, quick setup) — and re-opening
|
|
11148
|
+
* a canvas the person deliberately closed would be a UI arguing with them.
|
|
11149
|
+
*/
|
|
11150
|
+
const autoOpened = useRef(false);
|
|
11151
|
+
useEffect(() => {
|
|
11152
|
+
if (autoOpened.current) return;
|
|
11153
|
+
if (!cfg.cloud) return; // desktop / unknown-yet
|
|
11154
|
+
if (tabs.length > 0) return;
|
|
11155
|
+
if (!groups.length) return; // tree not loaded
|
|
11156
|
+
// The first canvas that is somebody's WORK — a design-system specimen is a
|
|
11157
|
+
// rendered canvas too, and not what a teammate followed a link to see.
|
|
11158
|
+
let first = null;
|
|
11159
|
+
for (const g of groups) {
|
|
11160
|
+
for (const p of g.paths || []) {
|
|
11161
|
+
if (!CANVAS_EXT_RE.test(p)) continue;
|
|
11162
|
+
if (/(^|\/)system\//.test(p)) continue;
|
|
11163
|
+
first = p;
|
|
11164
|
+
break;
|
|
11165
|
+
}
|
|
11166
|
+
if (first) break;
|
|
11167
|
+
}
|
|
11168
|
+
if (!first) return;
|
|
11169
|
+
autoOpened.current = true;
|
|
11170
|
+
openTab(first);
|
|
11171
|
+
}, [cfg.cloud, groups, tabs.length, openTab]);
|
|
11172
|
+
|
|
11009
11173
|
const openSystem = useCallback(
|
|
11010
11174
|
(dsName) => {
|
|
11011
11175
|
// DsFolderRow passes the clicked DS name → scope the System view to it so
|
|
@@ -13475,10 +13639,17 @@ function App() {
|
|
|
13475
13639
|
}
|
|
13476
13640
|
// Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
|
|
13477
13641
|
// canvas highlighter tool (same letter, different action by focus).
|
|
13478
|
-
//
|
|
13642
|
+
//
|
|
13643
|
+
// Cloud Phase 27 C1 — a VIEWER GETS THIS TOO, and the line above used to
|
|
13644
|
+
// say the opposite ("the panel is edit chrome"). C1 reversed that on
|
|
13645
|
+
// purpose: read-only means cannot CHANGE, not cannot SEE, and a reviewer
|
|
13646
|
+
// needs structure and measured values as much as anyone. The View menu
|
|
13647
|
+
// already offers it (`viewerHiddenPanels` no longer hides it); the
|
|
13648
|
+
// shortcut had been left behind, so the menu said yes and the keyboard
|
|
13649
|
+
// said no. Found by writing the parity spec that runs in both shells.
|
|
13479
13650
|
if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
|
|
13480
13651
|
e.preventDefault();
|
|
13481
|
-
|
|
13652
|
+
toggleRightPanel('inspector');
|
|
13482
13653
|
return;
|
|
13483
13654
|
}
|
|
13484
13655
|
// Phase 31 (DDR-123) — Cmd+Shift+A opens the native ACP chat sidepanel.
|
|
@@ -13807,9 +13978,15 @@ function App() {
|
|
|
13807
13978
|
// assigned to it and which one is active (the open one). Layers is only a
|
|
13808
13979
|
// dockable panel in `separate` mode; Assistant is native-only.
|
|
13809
13980
|
const panelAvailable = (id) => {
|
|
13810
|
-
//
|
|
13811
|
-
//
|
|
13812
|
-
|
|
13981
|
+
// The AGENT is genuinely absent for a viewer (it edits; Phase 25 C2).
|
|
13982
|
+
//
|
|
13983
|
+
// Inspector and Layers are NOT — Cloud Phase 27 C1 reversed that, and this
|
|
13984
|
+
// line had been left behind: `viewerHiddenPanels` stopped hiding them in
|
|
13985
|
+
// the View menu while this still refused to give them a dock slot, so the
|
|
13986
|
+
// menu offered a panel that could never appear. Read-only means cannot
|
|
13987
|
+
// CHANGE, not cannot SEE: a reviewer needs structure and measured values,
|
|
13988
|
+
// which is the whole reason C1 exists.
|
|
13989
|
+
if (viewerMode && id === 'assistant') return false;
|
|
13813
13990
|
return id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
|
|
13814
13991
|
};
|
|
13815
13992
|
const idsForSide = (side) =>
|
|
@@ -13842,13 +14019,15 @@ function App() {
|
|
|
13842
14019
|
// which owns the resizable width). Assistant is handled separately below as an
|
|
13843
14020
|
// always-mounted ChatPanel so its stream survives a tab switch.
|
|
13844
14021
|
const renderPanelBody = (id) => {
|
|
13845
|
-
//
|
|
13846
|
-
// viewer
|
|
13847
|
-
|
|
14022
|
+
// C1 again, the second half of the same gate. The panels MOUNT for a
|
|
14023
|
+
// viewer; what they must not do is offer an edit, and that is already
|
|
14024
|
+
// handled inside them (`readOnly` is threaded through every control).
|
|
14025
|
+
// Refusing to mount was a blunter instrument than the role model asks for.
|
|
14026
|
+
|
|
13848
14027
|
if (id === 'tree')
|
|
13849
14028
|
return (
|
|
13850
14029
|
<Sidebar
|
|
13851
|
-
cloud={cfg
|
|
14030
|
+
cloud={cfg.cloud}
|
|
13852
14031
|
readOnly={viewerMode}
|
|
13853
14032
|
groups={groups}
|
|
13854
14033
|
activePath={activePath}
|
|
@@ -13903,6 +14082,9 @@ function App() {
|
|
|
13903
14082
|
if (id === 'inspector' || id === 'layers')
|
|
13904
14083
|
return (
|
|
13905
14084
|
<InspectorPanel
|
|
14085
|
+
// Cloud Phase 27 E4 — the parity spec asserts these two panels exist
|
|
14086
|
+
// in BOTH shells, so each needs a name a spec can ask for.
|
|
14087
|
+
testId={id === 'layers' ? 'layers-panel' : 'inspector-panel'}
|
|
13906
14088
|
layersOnly={id === 'layers'}
|
|
13907
14089
|
hideLayersTab={layersMode === 'separate'}
|
|
13908
14090
|
cpMode={cpMode}
|
|
@@ -13983,6 +14165,7 @@ function App() {
|
|
|
13983
14165
|
onContextMenu={onShellContextMenu}
|
|
13984
14166
|
>
|
|
13985
14167
|
{firstRun && <OnboardingWizard />}
|
|
14168
|
+
<CloudRoleBanner cloud={cfg.cloud} />
|
|
13986
14169
|
<UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
|
|
13987
14170
|
<SyncBanner status={syncStatus} />
|
|
13988
14171
|
{!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
|
|
@@ -14013,7 +14196,7 @@ function App() {
|
|
|
14013
14196
|
// Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
|
|
14014
14197
|
// takes. A prop, not the whole `cfg`: the Menubar needs to know it is
|
|
14015
14198
|
// in a browser tab on somebody else's machine, and nothing else.
|
|
14016
|
-
cloud={cfg
|
|
14199
|
+
cloud={cfg.cloud}
|
|
14017
14200
|
activePath={activePath}
|
|
14018
14201
|
project={project}
|
|
14019
14202
|
tabsCount={tabs.length}
|
|
@@ -15013,7 +15196,7 @@ function App() {
|
|
|
15013
15196
|
)}
|
|
15014
15197
|
{settingsOpen && (
|
|
15015
15198
|
<SettingsPanel
|
|
15016
|
-
cloud={cfg
|
|
15199
|
+
cloud={cfg.cloud}
|
|
15017
15200
|
onClose={() => setSettingsOpen(false)}
|
|
15018
15201
|
initialTab={typeof settingsOpen === 'string' ? settingsOpen : undefined}
|
|
15019
15202
|
theme={theme}
|
|
@@ -65,7 +65,14 @@ async function saveNative(id, filename) {
|
|
|
65
65
|
return saveExport(filename || 'export', id);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* @param {{ enabled?: boolean }} [opts] — `enabled` false suppresses the
|
|
70
|
+
* hydration fetch. The cloud shell passes false: `/_api/export-jobs` is
|
|
71
|
+
* refused there by design, so asking is guaranteed noise. It stays UNDEFINED
|
|
72
|
+
* until `/_config` answers, and undefined is not "false" here — see the
|
|
73
|
+
* tri-state note on `cfg.cloud`.
|
|
74
|
+
*/
|
|
75
|
+
export function useExportCenter({ enabled = true } = {}) {
|
|
69
76
|
const [jobs, setJobs] = useState(() => new Map());
|
|
70
77
|
const [panelOpen, setPanelOpen] = useState(false);
|
|
71
78
|
const [toastQueue, setToastQueue] = useState([]);
|
|
@@ -77,6 +84,7 @@ export function useExportCenter() {
|
|
|
77
84
|
const startToastShownRef = useRef(new Set()); // "Exporting…" toast fires at most once per job
|
|
78
85
|
|
|
79
86
|
useEffect(() => {
|
|
87
|
+
if (!enabled) return undefined;
|
|
80
88
|
let alive = true;
|
|
81
89
|
fetch('/_api/export-jobs')
|
|
82
90
|
.then((r) => r.json())
|
|
@@ -99,7 +107,7 @@ export function useExportCenter() {
|
|
|
99
107
|
return () => {
|
|
100
108
|
alive = false;
|
|
101
109
|
};
|
|
102
|
-
}, []);
|
|
110
|
+
}, [enabled]);
|
|
103
111
|
|
|
104
112
|
const upsert = useCallback((job) => {
|
|
105
113
|
if (!job || typeof job.id !== 'string') return;
|
|
@@ -802,7 +802,12 @@ function AppearanceTab({ theme, onSetTheme, cpMode, onSetCpMode }) {
|
|
|
802
802
|
|
|
803
803
|
export default function SettingsPanel({
|
|
804
804
|
/** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
|
|
805
|
-
|
|
805
|
+
// Tri-state (see the note where this value is created): `undefined` until
|
|
806
|
+
// the server config answers, `null` for the desktop, an object for a cloud
|
|
807
|
+
// tab. Defaulting it to `null` here is what re-broke the boot 404 after the
|
|
808
|
+
// call sites were fixed — a default fires on `undefined`, so "not known yet"
|
|
809
|
+
// became "not cloud" one layer further in, invisibly.
|
|
810
|
+
cloud,
|
|
806
811
|
onClose,
|
|
807
812
|
initialTab,
|
|
808
813
|
theme,
|