@1agh/maude 0.60.3 → 0.60.5
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 +68 -5
- package/apps/studio/build.ts +10 -0
- package/apps/studio/canvas-lib.tsx +16 -3
- package/apps/studio/canvas-shell.tsx +34 -39
- package/apps/studio/client/app.jsx +43 -9
- package/apps/studio/client/panels/SyncPanel.jsx +27 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +707 -707
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dom-selection.ts +67 -0
- package/apps/studio/http.ts +12 -1
- package/apps/studio/input-router.tsx +26 -2
- package/apps/studio/sync/agent.ts +64 -13
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +111 -98
- package/apps/studio/sync/codec.ts +47 -0
- package/apps/studio/sync/cold-start.ts +96 -0
- package/apps/studio/sync/file-membership.ts +290 -0
- package/apps/studio/sync/file-pull.ts +330 -0
- package/apps/studio/sync/index.ts +198 -10
- package/apps/studio/sync/migrate-seed.ts +52 -7
- package/apps/studio/sync/projection.ts +10 -1
- package/apps/studio/sync/remote-docs.ts +98 -4
- package/apps/studio/sync/status.ts +25 -0
- package/apps/studio/sync/tombstone-apply.ts +131 -0
- package/apps/studio/test/canvas-meta-api.test.ts +114 -3
- package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
- package/apps/studio/test/dom-selection.test.ts +69 -1
- package/apps/studio/test/exporters/jobs.test.ts +10 -4
- package/apps/studio/test/git-cloud-posture.test.ts +11 -2
- package/apps/studio/test/input-router.test.ts +69 -0
- package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
- package/apps/studio/test/sync-asset-pull.test.ts +161 -0
- package/apps/studio/test/sync-asset-push.test.ts +52 -2
- package/apps/studio/test/sync-file-membership.test.ts +331 -0
- package/apps/studio/test/sync-file-pull.test.ts +333 -0
- package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
- package/apps/studio/test/sync-remote-docs.test.ts +118 -8
- package/apps/studio/test/sync-status.test.ts +18 -0
- package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +8 -8
package/apps/studio/api.ts
CHANGED
|
@@ -1499,11 +1499,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1499
1499
|
// shared `.meta.json` document. It lives in `_canvas-state/<slug>.view.json`
|
|
1500
1500
|
// ({ viewport }) — gitignored, swept on delete, export-excluded. DISTINCT from
|
|
1501
1501
|
// the legacy `_canvas-state/<slug>.json` ({ sections, viewport:{x,y,scale} })
|
|
1502
|
-
// store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.
|
|
1502
|
+
// store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.02–4, so
|
|
1503
1503
|
// overloading one file would let the two writers clobber each other's shape.
|
|
1504
1504
|
|
|
1505
|
-
/** Validate a candidate viewport — finite x/y, zoom clamped [0.
|
|
1506
|
-
* Phase 4 rule).
|
|
1505
|
+
/** Validate a candidate viewport — finite x/y, zoom clamped [0.02, 4] (the
|
|
1506
|
+
* Phase 4 rule). Mirrors `ZOOM_MIN` in canvas-lib.tsx (issue #91) — keep the
|
|
1507
|
+
* two floors in lockstep or a save/reload round-trip silently re-clamps a
|
|
1508
|
+
* zoom the client just let the user reach. Returns the normalized viewport,
|
|
1509
|
+
* or null when invalid. */
|
|
1507
1510
|
function normalizeViewport(v: unknown): { x: number; y: number; zoom: number } | null {
|
|
1508
1511
|
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
1509
1512
|
const vv = v as { x?: unknown; y?: unknown; zoom?: unknown };
|
|
@@ -1512,7 +1515,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1512
1515
|
Number.isFinite(vv.y as number) &&
|
|
1513
1516
|
Number.isFinite(vv.zoom as number)
|
|
1514
1517
|
) {
|
|
1515
|
-
const zoom = Math.min(4, Math.max(0.
|
|
1518
|
+
const zoom = Math.min(4, Math.max(0.02, vv.zoom as number));
|
|
1516
1519
|
return { x: vv.x as number, y: vv.y as number, zoom };
|
|
1517
1520
|
}
|
|
1518
1521
|
return null;
|
|
@@ -1571,6 +1574,41 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1571
1574
|
return [...new Set(out)];
|
|
1572
1575
|
}
|
|
1573
1576
|
|
|
1577
|
+
// Issue #91 security follow-up — `layout.artboards[]` (DDR-027 position-only
|
|
1578
|
+
// entries) is reachable from the untrusted canvas origin (DDR-054) via the
|
|
1579
|
+
// same PATCH lane as overlays/locked above, but previously had no count or
|
|
1580
|
+
// magnitude bound. `fit()`/`computeFit` (canvas-lib.tsx) now correctly frames
|
|
1581
|
+
// whatever bounding box this describes — before the zoom-floor fix, a hard
|
|
1582
|
+
// 0.1 zoom clamp accidentally masked an unbounded layout into a cropped,
|
|
1583
|
+
// mismatched view; after it, an oversized synced layout can legitimately be
|
|
1584
|
+
// framed whole, promoting every artboard in it to its own GPU layer at once
|
|
1585
|
+
// (canvas-lib.tsx `content-visibility` comment). Cap count + coordinate
|
|
1586
|
+
// magnitude the same way `normalizeOverlays`/`normalizeLocked` cap theirs.
|
|
1587
|
+
const MAX_LAYOUT_ARTBOARDS = 2000;
|
|
1588
|
+
const MAX_LAYOUT_COORD = 1_000_000;
|
|
1589
|
+
|
|
1590
|
+
function normalizeLayoutArtboards(
|
|
1591
|
+
v: unknown
|
|
1592
|
+
): Array<{ id: string; x: number; y: number }> | null {
|
|
1593
|
+
if (!Array.isArray(v) || v.length > MAX_LAYOUT_ARTBOARDS) return null;
|
|
1594
|
+
const out: Array<{ id: string; x: number; y: number }> = [];
|
|
1595
|
+
for (const entry of v) {
|
|
1596
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
|
1597
|
+
const e = entry as { id?: unknown; x?: unknown; y?: unknown };
|
|
1598
|
+
if (typeof e.id !== 'string' || e.id.length === 0 || e.id.length > 128) return null;
|
|
1599
|
+
if (
|
|
1600
|
+
!Number.isFinite(e.x as number) ||
|
|
1601
|
+
!Number.isFinite(e.y as number) ||
|
|
1602
|
+
Math.abs(e.x as number) > MAX_LAYOUT_COORD ||
|
|
1603
|
+
Math.abs(e.y as number) > MAX_LAYOUT_COORD
|
|
1604
|
+
) {
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
out.push({ id: e.id, x: e.x as number, y: e.y as number });
|
|
1608
|
+
}
|
|
1609
|
+
return out;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1574
1612
|
/** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
|
|
1575
1613
|
* read half of the read-modify-write both `saveCanvasView` and
|
|
1576
1614
|
* `saveCanvasOverlays` need so writing one lane never clobbers the other. */
|
|
@@ -1819,7 +1857,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1819
1857
|
if (patch.layout === null) {
|
|
1820
1858
|
next.layout = undefined;
|
|
1821
1859
|
} else if (typeof patch.layout === 'object' && !Array.isArray(patch.layout)) {
|
|
1822
|
-
|
|
1860
|
+
const rawLayout = patch.layout as Record<string, unknown>;
|
|
1861
|
+
if ('artboards' in rawLayout) {
|
|
1862
|
+
// Bounded — see normalizeLayoutArtboards. An oversized/malformed
|
|
1863
|
+
// `artboards` array is a silent no-op (leaves the prior persisted
|
|
1864
|
+
// layout in place), matching the overlays/locked lanes' convention.
|
|
1865
|
+
const artboards = normalizeLayoutArtboards(rawLayout.artboards);
|
|
1866
|
+
if (artboards !== null) {
|
|
1867
|
+
next.layout = { ...rawLayout, artboards };
|
|
1868
|
+
}
|
|
1869
|
+
} else {
|
|
1870
|
+
next.layout = patch.layout;
|
|
1871
|
+
}
|
|
1823
1872
|
}
|
|
1824
1873
|
// Defensive: a stale inline viewport must never persist in the versioned
|
|
1825
1874
|
// file (the camera lane owns it now). JSON.stringify drops undefined keys.
|
|
@@ -2577,6 +2626,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2577
2626
|
// via git "Get latest" — the file travels through git, this event is only a
|
|
2578
2627
|
// "refresh your list" nudge for online local tabs (loopback inspector WS).
|
|
2579
2628
|
ctx.bus.emit('canvas-list-update', { action: 'added', rel, slug });
|
|
2629
|
+
// The inverse of `canvas-deleted` (see deleteCanvas for why this is its own
|
|
2630
|
+
// event). Creating a name the project buried earlier has to lift the
|
|
2631
|
+
// tombstone, or every peer would dutifully trash the new canvas for as long
|
|
2632
|
+
// as the gravestone lives.
|
|
2633
|
+
ctx.bus.emit('canvas-created', { slug });
|
|
2580
2634
|
// designRel-prefixed path — matches the file-tree `file.path` shape so the
|
|
2581
2635
|
// client can open it directly after reloadTree().
|
|
2582
2636
|
return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
|
|
@@ -2690,6 +2744,15 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2690
2744
|
|
|
2691
2745
|
// Phase 30 — live tree refresh for other local tabs (see createCanvas).
|
|
2692
2746
|
ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
|
|
2747
|
+
// A SEPARATE EVENT, DELIBERATELY. `canvas-list-update` is emitted by this
|
|
2748
|
+
// API *and* by the filesystem watcher, so its payload can describe a file
|
|
2749
|
+
// an agent or a `git checkout` produced — which is why discovery treats it
|
|
2750
|
+
// as a nudge and re-derives the truth itself. This one is only ever emitted
|
|
2751
|
+
// HERE, from a privileged route the canvas origin cannot reach, so it
|
|
2752
|
+
// carries something the watcher's version cannot: the user meant it. Sync
|
|
2753
|
+
// needs exactly that, because "gone from this disk" and "deleted from the
|
|
2754
|
+
// project" are different claims and only the second may travel.
|
|
2755
|
+
ctx.bus.emit('canvas-deleted', { slug });
|
|
2693
2756
|
return {
|
|
2694
2757
|
ok: true,
|
|
2695
2758
|
rel,
|
package/apps/studio/build.ts
CHANGED
|
@@ -353,6 +353,16 @@ export function writeCompileEntry(target: PlatformTarget): string {
|
|
|
353
353
|
// Side-effect module: must be imported BEFORE any oxc-parser usage.
|
|
354
354
|
import bindingPath from ${JSON.stringify(bindingSpec)} with { type: 'file' };
|
|
355
355
|
process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindingPath;
|
|
356
|
+
// DDR-177 — a compiled binary must be self-contained: every runtime-spawned
|
|
357
|
+
// child (canvas-build worker, asset-sweep worker) resolves its JS runtime via
|
|
358
|
+
// resolveBunPath() = MAUDE_BUN_PATH || 'bun'. The desktop app launches this
|
|
359
|
+
// binary with a GUI PATH that has no 'bun', and NOTHING set MAUDE_BUN_PATH —
|
|
360
|
+
// so the asset sweep died with "Executable not found in $PATH" on machines
|
|
361
|
+
// without a user-installed bun. Default it to THIS binary: workerEnv() adds
|
|
362
|
+
// BUN_BE_BUN=1 when MAUDE_BUN_PATH === process.execPath, so children re-enter
|
|
363
|
+
// the sidecar as a plain JS runtime. An explicit launcher-set value (the
|
|
364
|
+
// cell's studio-child.mjs) still wins.
|
|
365
|
+
process.env.MAUDE_BUN_PATH ||= process.execPath;
|
|
356
366
|
`;
|
|
357
367
|
writeFileSync(initPath, initContent);
|
|
358
368
|
|
|
@@ -214,7 +214,15 @@ export function AIPlaceholder({
|
|
|
214
214
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
215
|
// Module constants
|
|
216
216
|
|
|
217
|
-
|
|
217
|
+
// Issue #91 — was 0.1. A board spanning more world units than ~10x the
|
|
218
|
+
// viewport could never be framed whole: `fit()` and manual zoom-out both
|
|
219
|
+
// funnel through `clampZoom` (below), so the old floor blocked "fit to
|
|
220
|
+
// screen" from ever reaching the zoom a wide board actually needs. 0.02
|
|
221
|
+
// matches the interactive floor other canvas tools (Figma, tldraw) ship.
|
|
222
|
+
// Keep in lockstep with the mirror clamp in `api.ts`'s `normalizeViewport`
|
|
223
|
+
// (the per-machine camera persisted to `_canvas-state/<slug>.view.json`,
|
|
224
|
+
// DDR-115) — that one re-clamps on save/reload independently of this file.
|
|
225
|
+
const ZOOM_MIN = 0.02;
|
|
218
226
|
const ZOOM_MAX = 4.0;
|
|
219
227
|
const ZOOM_STEP_IN = 1.2;
|
|
220
228
|
const ZOOM_STEP_OUT = 1 / 1.2;
|
|
@@ -685,7 +693,9 @@ function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
|
|
|
685
693
|
});
|
|
686
694
|
}
|
|
687
695
|
|
|
688
|
-
|
|
696
|
+
// Exported for the same reason as `clampZoom` above — lets a unit test prove
|
|
697
|
+
// the raw fit computation for a wide board isn't what's clamping the zoom.
|
|
698
|
+
export function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
|
|
689
699
|
if (rects.length === 0) return { x: 0, y: 0, zoom: 1 };
|
|
690
700
|
let xMin = Number.POSITIVE_INFINITY;
|
|
691
701
|
let yMin = Number.POSITIVE_INFINITY;
|
|
@@ -907,7 +917,10 @@ export interface ViewportControllerHandle {
|
|
|
907
917
|
isInteracting: boolean;
|
|
908
918
|
}
|
|
909
919
|
|
|
910
|
-
|
|
920
|
+
// Exported (like `revealAxisDelta` in canvas-shell.tsx) so a pure-logic unit
|
|
921
|
+
// test can pin the zoom floor without booting the DOM/host-dependent
|
|
922
|
+
// viewport controller.
|
|
923
|
+
export function clampZoom(z: number): number {
|
|
911
924
|
if (!Number.isFinite(z)) return 1;
|
|
912
925
|
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
|
|
913
926
|
}
|
|
@@ -67,15 +67,13 @@ import {
|
|
|
67
67
|
import { ContextualToolbar } from './contextual-toolbar.tsx';
|
|
68
68
|
import { CursorsOverlay } from './cursors-overlay.tsx';
|
|
69
69
|
import {
|
|
70
|
+
buildComposeSelection,
|
|
70
71
|
cssPath,
|
|
71
72
|
deriveFile,
|
|
72
|
-
domPath,
|
|
73
73
|
hoverTargetToSelection,
|
|
74
|
+
openCommentComposer,
|
|
74
75
|
realClasses,
|
|
75
76
|
resolveSelectionEl,
|
|
76
|
-
scopedCdSelector,
|
|
77
|
-
selectorIndex,
|
|
78
|
-
shortText,
|
|
79
77
|
} from './dom-selection.ts';
|
|
80
78
|
import { setElementDragActive } from './drag-state.ts';
|
|
81
79
|
import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
|
|
@@ -1378,33 +1376,17 @@ function buildRegistry(deps: {
|
|
|
1378
1376
|
});
|
|
1379
1377
|
};
|
|
1380
1378
|
|
|
1379
|
+
// issue-90 — right-click "Add comment" (element / artboard-chrome / world)
|
|
1380
|
+
// used to only postMessage the parent shell, which merely mirrors the
|
|
1381
|
+
// target for the StatusBar/sidebar; it never dispatched `cm:open-composer`,
|
|
1382
|
+
// the event `comments-overlay.tsx` actually listens on to open the
|
|
1383
|
+
// composer. Selecting the menu item did nothing. `openCommentComposer`
|
|
1384
|
+
// sends both, matching the working comment-tool-click path
|
|
1385
|
+
// (canvas-comment-mount.tsx's `dropComment`).
|
|
1381
1386
|
const postComposeForTarget = (target: ContextTarget): void => {
|
|
1382
1387
|
if (typeof window === 'undefined') return;
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1385
|
-
: cssPath(target.el);
|
|
1386
|
-
const sel: Selection | null = target.el
|
|
1387
|
-
? {
|
|
1388
|
-
file: deriveFile(),
|
|
1389
|
-
id: target.cdId ?? undefined,
|
|
1390
|
-
selector: composeSelector,
|
|
1391
|
-
artboardId: target.artboardId,
|
|
1392
|
-
index: target.cdId ? selectorIndex(document, composeSelector, target.el) : 0,
|
|
1393
|
-
tag: target.el.tagName.toLowerCase(),
|
|
1394
|
-
classes: realClasses(target.el),
|
|
1395
|
-
text: shortText(target.el, 240),
|
|
1396
|
-
dom_path: domPath(target.el),
|
|
1397
|
-
bounds: (target.el as HTMLElement).getBoundingClientRect
|
|
1398
|
-
? boundsOf(target.el as HTMLElement)
|
|
1399
|
-
: null,
|
|
1400
|
-
html: (target.el.outerHTML ?? '').slice(0, 4000),
|
|
1401
|
-
}
|
|
1402
|
-
: null;
|
|
1403
|
-
try {
|
|
1404
|
-
window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
|
|
1405
|
-
} catch {
|
|
1406
|
-
/* ignore */
|
|
1407
|
-
}
|
|
1388
|
+
const sel = buildComposeSelection(target, target.clientX, target.clientY);
|
|
1389
|
+
openCommentComposer(sel, target.clientX, target.clientY);
|
|
1408
1390
|
};
|
|
1409
1391
|
|
|
1410
1392
|
const fitItem: MenuItem = {
|
|
@@ -2141,6 +2123,18 @@ function buildRegistry(deps: {
|
|
|
2141
2123
|
],
|
|
2142
2124
|
],
|
|
2143
2125
|
'artboard-chrome': [
|
|
2126
|
+
[
|
|
2127
|
+
// issue-90 — right-click on an artboard's own label/border chrome
|
|
2128
|
+
// (outside its body, where `data-cd-id` never resolves) had no
|
|
2129
|
+
// "Add comment" entry at all; only a hit inside the body counted as
|
|
2130
|
+
// `element`. Anchors at the click point (no element under it).
|
|
2131
|
+
{
|
|
2132
|
+
id: 'add-comment',
|
|
2133
|
+
label: 'Add comment',
|
|
2134
|
+
shortcut: 'C',
|
|
2135
|
+
onSelect: postComposeForTarget,
|
|
2136
|
+
},
|
|
2137
|
+
],
|
|
2144
2138
|
[
|
|
2145
2139
|
{
|
|
2146
2140
|
id: 'fit-one',
|
|
@@ -2273,6 +2267,17 @@ function buildRegistry(deps: {
|
|
|
2273
2267
|
[exportItem('export-artboard', 'Export this artboard…', 'artboard')],
|
|
2274
2268
|
],
|
|
2275
2269
|
world: [
|
|
2270
|
+
// issue-90 — right-click on empty canvas background (no artboard, no
|
|
2271
|
+
// element) had no "Add comment" entry at all. Anchors at the click
|
|
2272
|
+
// point, same as the comment tool's own floating-pin fallback.
|
|
2273
|
+
[
|
|
2274
|
+
{
|
|
2275
|
+
id: 'add-comment',
|
|
2276
|
+
label: 'Add comment',
|
|
2277
|
+
shortcut: 'C',
|
|
2278
|
+
onSelect: postComposeForTarget,
|
|
2279
|
+
},
|
|
2280
|
+
],
|
|
2276
2281
|
[fitItem, resetItem],
|
|
2277
2282
|
[
|
|
2278
2283
|
exportItem('export-canvas', 'Export canvas as separate…', 'canvas-as-separate'),
|
|
@@ -2320,16 +2325,6 @@ function filterRegistryForReadOnly(registry: ContextRegistry): ContextRegistry {
|
|
|
2320
2325
|
return out;
|
|
2321
2326
|
}
|
|
2322
2327
|
|
|
2323
|
-
function boundsOf(el: HTMLElement) {
|
|
2324
|
-
const r = el.getBoundingClientRect();
|
|
2325
|
-
return {
|
|
2326
|
-
x: Math.round(r.left),
|
|
2327
|
-
y: Math.round(r.top),
|
|
2328
|
-
w: Math.round(r.width),
|
|
2329
|
-
h: Math.round(r.height),
|
|
2330
|
-
};
|
|
2331
|
-
}
|
|
2332
|
-
|
|
2333
2328
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2334
2329
|
// Task A1 (feature-element-editing-robustness) — camera-aware reveal.
|
|
2335
2330
|
//
|
|
@@ -4704,6 +4704,11 @@ function StatusBar({
|
|
|
4704
4704
|
onOpenSync,
|
|
4705
4705
|
changesCount = 0,
|
|
4706
4706
|
unpushed = 0,
|
|
4707
|
+
// Somebody else commits this project (a cloud cell, or the cell behind a
|
|
4708
|
+
// linked+credentialed desktop repo — DDR-218). The chip then names the
|
|
4709
|
+
// mechanism that IS saving rather than reporting a working-tree count the
|
|
4710
|
+
// user has no reason to act on. Default false = today's local-first chip.
|
|
4711
|
+
savingIsManaged = false,
|
|
4707
4712
|
changesOpen = false,
|
|
4708
4713
|
onOpenChanges,
|
|
4709
4714
|
version,
|
|
@@ -4811,11 +4816,17 @@ function StatusBar({
|
|
|
4811
4816
|
<span className="st-sb-changes-dot" aria-hidden="true" />
|
|
4812
4817
|
<span className="lbl">changes</span>
|
|
4813
4818
|
<span className="val">
|
|
4814
|
-
{
|
|
4815
|
-
?
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
+
{savingIsManaged
|
|
4820
|
+
? // Not "all saved": assets and project config are written into the
|
|
4821
|
+
// cell's checkout but never committed there, so asserting a clean
|
|
4822
|
+
// save would overstate what the cloud actually holds. Naming the
|
|
4823
|
+
// mechanism is both honest and the thing the user needs to know.
|
|
4824
|
+
'cloud saving'
|
|
4825
|
+
: changesCount > 0
|
|
4826
|
+
? `${changesCount} unsaved`
|
|
4827
|
+
: unpushed > 0
|
|
4828
|
+
? `${unpushed} to publish`
|
|
4829
|
+
: 'all saved'}
|
|
4819
4830
|
</span>
|
|
4820
4831
|
</button>
|
|
4821
4832
|
)}
|
|
@@ -11383,7 +11394,26 @@ function App() {
|
|
|
11383
11394
|
for (const f of gitStatus?.files || []) m.set(f.path, KIND[f.status]);
|
|
11384
11395
|
return m;
|
|
11385
11396
|
}, [gitStatus]);
|
|
11386
|
-
|
|
11397
|
+
// WHO IS SAVING THIS PROJECT — one expression, read by every surface that
|
|
11398
|
+
// would otherwise offer to save it (DDR-218 + the cell's `historyOnly`).
|
|
11399
|
+
//
|
|
11400
|
+
// These two flags were previously derived at the GitPanel call site alone, so
|
|
11401
|
+
// the panel withdrew correctly while the toolbar menu and the status-bar chip
|
|
11402
|
+
// kept rendering a raw dirty count from `gitStatus.files.length` — the very
|
|
11403
|
+
// "lie about work that is already saved" the withdrawal exists to delete. The
|
|
11404
|
+
// count is the claim, wherever it is drawn; hoisting the rule here is what
|
|
11405
|
+
// keeps a third surface from re-introducing it.
|
|
11406
|
+
//
|
|
11407
|
+
// PRESENTATION, NOT A CONTROL — unchanged from DDR-218. `.git` is untouched,
|
|
11408
|
+
// a terminal `git` behaves exactly as before, the server routes keep their own
|
|
11409
|
+
// gates, and Disconnect restores every surface live.
|
|
11410
|
+
const cellManaged = !!cfg.cloud;
|
|
11411
|
+
const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;
|
|
11412
|
+
const savingIsManaged = cellManaged || cloudManaged;
|
|
11413
|
+
// The raw truth stays available for surfaces that legitimately need it (the
|
|
11414
|
+
// panel's own file list, `dirtyByPath`); only the COUNT — the thing that reads
|
|
11415
|
+
// as a to-do — is withheld when this project's saving is somebody else's job.
|
|
11416
|
+
const unsavedCount = savingIsManaged ? 0 : gitStatus?.files?.length || 0;
|
|
11387
11417
|
|
|
11388
11418
|
// Phase 28 (E3) — keep remote ahead/behind fresh so the "Get latest" nudge
|
|
11389
11419
|
// surfaces on its own: probe once a repo is known, again whenever the Changes
|
|
@@ -14446,7 +14476,7 @@ function App() {
|
|
|
14446
14476
|
// mislead, it does not remove a capability. The real gates are
|
|
14447
14477
|
// server-side (`projectReadOnly`, the manifest's role matrix), and
|
|
14448
14478
|
// they are unchanged by this flag.
|
|
14449
|
-
historyOnly={
|
|
14479
|
+
historyOnly={cellManaged}
|
|
14450
14480
|
// DDR-218 (fix 8) — the DESKTOP half of the same withdrawal: a repo
|
|
14451
14481
|
// linked+credentialed to Maude Cloud is cloud-managed (the cell
|
|
14452
14482
|
// commits every edit as it lands), so the local Changes surface is
|
|
@@ -14454,7 +14484,7 @@ function App() {
|
|
|
14454
14484
|
// Live: CloudBar lifts every link change (resolve/attach/detach)
|
|
14455
14485
|
// into `cloudLinkedHub`. Same presentation-not-a-control rule as
|
|
14456
14486
|
// `historyOnly` above; Disconnect restores the panel in place.
|
|
14457
|
-
cloudManaged={
|
|
14487
|
+
cloudManaged={cloudManaged}
|
|
14458
14488
|
resizing={resizingFor('changes')}
|
|
14459
14489
|
onClose={() => setChangesOpen(false)}
|
|
14460
14490
|
onCommit={gitCommit}
|
|
@@ -15521,7 +15551,11 @@ function App() {
|
|
|
15521
15551
|
// holds (opening Sync closes whatever else the right slot shows).
|
|
15522
15552
|
onOpenSync={syncStatus ? () => toggleRightPanel('sync') : undefined}
|
|
15523
15553
|
changesCount={unsavedCount}
|
|
15524
|
-
unpushed
|
|
15554
|
+
// `unpushed` is a LOCAL-git offer ("N to publish"), and the panel has
|
|
15555
|
+
// withdrawn Publish under either managed posture — so the chip must
|
|
15556
|
+
// not keep advertising it either.
|
|
15557
|
+
unpushed={savingIsManaged ? 0 : gitStatus?.unpushed || 0}
|
|
15558
|
+
savingIsManaged={savingIsManaged}
|
|
15525
15559
|
changesOpen={changesOpen}
|
|
15526
15560
|
onOpenChanges={gitStatus?.repo ? () => setChangesOpen(true) : undefined}
|
|
15527
15561
|
version={cfg?.version}
|
|
@@ -89,6 +89,15 @@ function readAssets(raw) {
|
|
|
89
89
|
return { ...raw, failures };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** The file plane (feature-sync-file-plane), or null when unreadable/absent —
|
|
93
|
+
* absent is the norm: the payload carries `files` only once a flag-on pull
|
|
94
|
+
* has run this boot. */
|
|
95
|
+
function readFiles(raw) {
|
|
96
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
97
|
+
if (![raw.synced, raw.pulled, raw.conflicts].every(isCount)) return null;
|
|
98
|
+
return raw;
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
export default function SyncPanel({
|
|
93
102
|
status, // the live `sync:status` payload (never null while mounted)
|
|
94
103
|
project, // display name for the header sentence (hub-supplied → safeName'd)
|
|
@@ -150,6 +159,7 @@ export default function SyncPanel({
|
|
|
150
159
|
const truncated = isCount(status?.itemsTruncated) ? status.itemsTruncated : 0;
|
|
151
160
|
const assets = readAssets(status?.assets);
|
|
152
161
|
const assetFailures = assets?.failures || [];
|
|
162
|
+
const files = readFiles(status?.files);
|
|
153
163
|
|
|
154
164
|
const { attention, byGroup } = useMemo(() => {
|
|
155
165
|
const attention = items.filter((i) => i.state === 'auth-rejected');
|
|
@@ -329,6 +339,23 @@ export default function SyncPanel({
|
|
|
329
339
|
)}
|
|
330
340
|
</section>
|
|
331
341
|
)}
|
|
342
|
+
|
|
343
|
+
{files && (
|
|
344
|
+
<section aria-label="Project files" data-testid="sync-files">
|
|
345
|
+
<div className="gp-sect-label">
|
|
346
|
+
project files <span className="gp-group-count">{files.synced}</span>
|
|
347
|
+
</div>
|
|
348
|
+
<div className="sp-assets-line">
|
|
349
|
+
{`${files.synced} synced` + (files.pulled > 0 ? ` · ${files.pulled} pulled` : '')}
|
|
350
|
+
</div>
|
|
351
|
+
{files.conflicts > 0 && (
|
|
352
|
+
<div className="sp-assets-retry" data-testid="sync-files-conflicts">
|
|
353
|
+
{files.conflicts} conflict{files.conflicts === 1 ? '' : 's'} — the older copies are
|
|
354
|
+
kept in _trash/.
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
</section>
|
|
358
|
+
)}
|
|
332
359
|
</div>
|
|
333
360
|
</aside>
|
|
334
361
|
);
|
package/apps/studio/context.ts
CHANGED
|
@@ -47,6 +47,15 @@ export interface LinkedHub {
|
|
|
47
47
|
* would split peers of the same project apart. See sync/doc-name.ts.
|
|
48
48
|
*/
|
|
49
49
|
workspaceId?: string;
|
|
50
|
+
/**
|
|
51
|
+
* feature-sync-file-plane — opt-in for the manifest-driven file plane
|
|
52
|
+
* (Plane B): the downward project-file pull AND the widened upward sweep
|
|
53
|
+
* (stylesheets, docs, code modules — classifier membership, see
|
|
54
|
+
* sync/file-membership.ts). Default OFF this release; `MAUDE_SYNC_FILES=1`
|
|
55
|
+
* forces it on for a session. The flag gates ONLY the new plane — the
|
|
56
|
+
* canvas CRDT lanes and the DDR-217 asset lanes run regardless.
|
|
57
|
+
*/
|
|
58
|
+
syncFiles?: boolean;
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export interface DevServerConfig {
|