@1agh/maude 0.35.0 → 0.36.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/apps/studio/canvas-shell.tsx +73 -2
- package/apps/studio/client/app.jsx +225 -11
- package/apps/studio/client/panels/GitPanel.jsx +6 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +199 -68
- package/apps/studio/client/styles/3-shell-maude.css +22 -0
- package/apps/studio/commands/edit-source-command.ts +118 -0
- package/apps/studio/dist/client.bundle.js +21 -53108
- package/apps/studio/dist/comment-mount.js +1 -2048
- package/apps/studio/dist/styles.css +1 -12220
- package/apps/studio/git/endpoints.ts +17 -0
- package/apps/studio/git/service.ts +483 -40
- package/apps/studio/github/endpoints.ts +28 -4
- package/apps/studio/github/identity-cache.ts +138 -0
- package/apps/studio/github/service.ts +40 -23
- package/apps/studio/http.ts +12 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +2 -0
- package/apps/studio/test/edit-source-command.test.ts +119 -0
- package/apps/studio/test/git-branches.test.ts +141 -3
- package/apps/studio/test/github-api.test.ts +58 -1
- package/apps/studio/test/identity-cache.test.ts +83 -0
- package/apps/studio/test/remote-ahead-behind-cache.test.ts +89 -0
- package/apps/studio/test/use-undo-stack.test.tsx +30 -0
- package/apps/studio/undo-stack.ts +10 -0
- package/apps/studio/use-undo-stack.tsx +21 -1
- package/apps/studio/whats-new.json +18 -0
- package/package.json +15 -10
|
@@ -47,6 +47,12 @@ import {
|
|
|
47
47
|
useViewportControllerContext,
|
|
48
48
|
type ViewportControllerHandle,
|
|
49
49
|
} from './canvas-lib.tsx';
|
|
50
|
+
import {
|
|
51
|
+
buildEditSourceRecord,
|
|
52
|
+
type EditSourceApplyFn,
|
|
53
|
+
type EditSourceOp,
|
|
54
|
+
type EditSourcePayload,
|
|
55
|
+
} from './commands/edit-source-command.ts';
|
|
50
56
|
import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
|
|
51
57
|
import {
|
|
52
58
|
ContextMenuProvider,
|
|
@@ -92,7 +98,7 @@ import {
|
|
|
92
98
|
useSelectionSet,
|
|
93
99
|
} from './use-selection-set.tsx';
|
|
94
100
|
import { useToolMode } from './use-tool-mode.tsx';
|
|
95
|
-
import { useUndoStack } from './use-undo-stack.tsx';
|
|
101
|
+
import { useUndoSinks, useUndoStack } from './use-undo-stack.tsx';
|
|
96
102
|
|
|
97
103
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
104
|
// Styles — halos render as `position: fixed` siblings of the canvas. Reading
|
|
@@ -1372,6 +1378,28 @@ function CanvasRouter({
|
|
|
1372
1378
|
const annotSel = useAnnotationSelection();
|
|
1373
1379
|
const ctxMenu = useContextMenu();
|
|
1374
1380
|
const undoStack = useUndoStack();
|
|
1381
|
+
const undoSinks = useUndoSinks();
|
|
1382
|
+
// Latest undo stack, read inside long-lived listeners (text-edit dblclick)
|
|
1383
|
+
// without making them a hook dependency — avoids re-binding on every undo op.
|
|
1384
|
+
const undoStackRef = useRef(undoStack);
|
|
1385
|
+
undoStackRef.current = undoStack;
|
|
1386
|
+
|
|
1387
|
+
// Phase: inline-edit undo (DDR-103/104 follow-up). Wire the `editSourceApplyFn`
|
|
1388
|
+
// sink so an `edit-source` command's do()/undo() reaches the main-origin-only
|
|
1389
|
+
// `/_api/edit-*` write — which the untrusted canvas iframe can't call (DDR-054).
|
|
1390
|
+
// It posts `dgn:'apply-edit'` to the parent shell, which performs the write.
|
|
1391
|
+
useEffect(() => {
|
|
1392
|
+
const applyFn: EditSourceApplyFn = (apply) => {
|
|
1393
|
+
try {
|
|
1394
|
+
window.parent.postMessage({ dgn: 'apply-edit', ...apply }, '*');
|
|
1395
|
+
} catch {
|
|
1396
|
+
/* detached / cross-origin teardown — nothing to apply */
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
undoSinks.setSink('editSourceApplyFn', applyFn);
|
|
1400
|
+
return () => undoSinks.setSink('editSourceApplyFn', undefined);
|
|
1401
|
+
}, [undoSinks]);
|
|
1402
|
+
|
|
1375
1403
|
// Shell View-menu zoom bridge (dgn:'zoom') — same controller the zoom pill uses.
|
|
1376
1404
|
const zoomController = useViewportControllerContext();
|
|
1377
1405
|
// Shell View-menu chrome-visibility bridge (dgn:'view-chrome') — minimap /
|
|
@@ -1520,6 +1548,35 @@ function CanvasRouter({
|
|
|
1520
1548
|
}
|
|
1521
1549
|
return;
|
|
1522
1550
|
}
|
|
1551
|
+
// Inline-edit undo (DDR-103/104 follow-up). The shell's inspector posts
|
|
1552
|
+
// this AFTER it has applied a CSS / attribute edit (`/_api/edit-css` /
|
|
1553
|
+
// `/_api/edit-attr`), so we RECORD it onto the in-canvas undo stack
|
|
1554
|
+
// (append without re-running do() — the edit already landed) and Cmd+Z
|
|
1555
|
+
// can invert it. Inline TEXT edits originate in this iframe and record
|
|
1556
|
+
// themselves directly (see commitEdit). PARENT-ORIGIN ONLY (DDR-054):
|
|
1557
|
+
// a hostile canvas self-post must not be able to plant fake undo entries.
|
|
1558
|
+
if (m.dgn === 'record-edit') {
|
|
1559
|
+
if (e.source !== window.parent) return;
|
|
1560
|
+
const p = (m as { payload?: Partial<EditSourcePayload> }).payload;
|
|
1561
|
+
if (
|
|
1562
|
+
p &&
|
|
1563
|
+
(p.op === 'css' || p.op === 'text' || p.op === 'attr') &&
|
|
1564
|
+
typeof p.canvas === 'string' &&
|
|
1565
|
+
typeof p.id === 'string'
|
|
1566
|
+
) {
|
|
1567
|
+
undoStack.record(
|
|
1568
|
+
buildEditSourceRecord({
|
|
1569
|
+
op: p.op as EditSourceOp,
|
|
1570
|
+
canvas: p.canvas,
|
|
1571
|
+
id: p.id,
|
|
1572
|
+
key: typeof p.key === 'string' ? p.key : '',
|
|
1573
|
+
before: typeof p.before === 'string' ? p.before : null,
|
|
1574
|
+
after: typeof p.after === 'string' ? p.after : null,
|
|
1575
|
+
})
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1523
1580
|
if (m.dgn === 'request-layers') {
|
|
1524
1581
|
postLayersTree((m as { artboardId?: string }).artboardId ?? null);
|
|
1525
1582
|
return;
|
|
@@ -1605,11 +1662,25 @@ function CanvasRouter({
|
|
|
1605
1662
|
if (text === original.trim()) return;
|
|
1606
1663
|
const cdId = elx.getAttribute('data-cd-id');
|
|
1607
1664
|
if (!cdId) return;
|
|
1665
|
+
const file = deriveFile();
|
|
1608
1666
|
try {
|
|
1609
|
-
window.parent.postMessage({ dgn: 'edit-text', id: cdId, file
|
|
1667
|
+
window.parent.postMessage({ dgn: 'edit-text', id: cdId, file, text }, '*');
|
|
1610
1668
|
} catch {
|
|
1611
1669
|
/* detached / cross-origin */
|
|
1612
1670
|
}
|
|
1671
|
+
// Record onto the in-canvas undo stack so Cmd+Z reverts the rewrite. The
|
|
1672
|
+
// edit already posted above (record, don't re-run do()). before/after are
|
|
1673
|
+
// the trimmed bodies the edit-text endpoint persists.
|
|
1674
|
+
undoStackRef.current.record(
|
|
1675
|
+
buildEditSourceRecord({
|
|
1676
|
+
op: 'text',
|
|
1677
|
+
canvas: file,
|
|
1678
|
+
id: cdId,
|
|
1679
|
+
key: '',
|
|
1680
|
+
before: original.trim(),
|
|
1681
|
+
after: text,
|
|
1682
|
+
})
|
|
1683
|
+
);
|
|
1613
1684
|
}
|
|
1614
1685
|
const onDbl = (e: MouseEvent): void => {
|
|
1615
1686
|
if (editing) return;
|
|
@@ -449,10 +449,11 @@ const STICONS = {
|
|
|
449
449
|
<polyline points="3 12.8 3 13.6 13 13.6 13 12.8" />
|
|
450
450
|
</>
|
|
451
451
|
),
|
|
452
|
+
// lucide `rotate-cw`, scaled from the 24px source into our 16px viewBox.
|
|
452
453
|
reload: (
|
|
453
454
|
<>
|
|
454
|
-
<path d="
|
|
455
|
-
<
|
|
455
|
+
<path d="M14 8a6 6 0 1 1-2-4.47L14 5.33" />
|
|
456
|
+
<path d="M14 2v3.33h-3.33" />
|
|
456
457
|
</>
|
|
457
458
|
),
|
|
458
459
|
help: (
|
|
@@ -1092,10 +1093,23 @@ function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kin
|
|
|
1092
1093
|
// Delete only real canvases in a deletable group (onDelete is undefined for the
|
|
1093
1094
|
// DS group + runtime files); the server enforces the rest.
|
|
1094
1095
|
const canDelete = isCanvas && typeof onDelete === 'function' && kind !== 'runtime';
|
|
1096
|
+
// Stable hook for the desktop E2E harness (data-testid convention — see the
|
|
1097
|
+
// `desktop-e2e` skill): canvas rows only, slug derived from the relative path
|
|
1098
|
+
// (e.g. `ui/Smoke.tsx` → `canvas-row-ui-smoke`).
|
|
1099
|
+
const testId = isCanvas
|
|
1100
|
+
? 'canvas-row-' +
|
|
1101
|
+
file.path
|
|
1102
|
+
.replace(/^\.[^/]+\//, '') // strip the leading designRoot dot-folder (.design/)
|
|
1103
|
+
.replace(CANVAS_EXT_RE, '')
|
|
1104
|
+
.replace(/[^a-z0-9]+/gi, '-')
|
|
1105
|
+
.toLowerCase()
|
|
1106
|
+
.replace(/^-+|-+$/g, '')
|
|
1107
|
+
: undefined;
|
|
1095
1108
|
const row = (
|
|
1096
1109
|
<button
|
|
1097
1110
|
type="button"
|
|
1098
1111
|
role="treeitem"
|
|
1112
|
+
data-testid={testId}
|
|
1099
1113
|
aria-selected={isSel}
|
|
1100
1114
|
aria-disabled={inert ? 'true' : undefined}
|
|
1101
1115
|
tabIndex={isSel ? 0 : -1}
|
|
@@ -1383,6 +1397,8 @@ function Sidebar({
|
|
|
1383
1397
|
onToggleSection,
|
|
1384
1398
|
onNewBoard,
|
|
1385
1399
|
onDeleteBoard,
|
|
1400
|
+
onRefresh,
|
|
1401
|
+
refreshing,
|
|
1386
1402
|
collapsed,
|
|
1387
1403
|
onCollapse,
|
|
1388
1404
|
width,
|
|
@@ -1457,6 +1473,19 @@ function Sidebar({
|
|
|
1457
1473
|
>
|
|
1458
1474
|
<StIcon name="plus" size={15} />
|
|
1459
1475
|
</button>
|
|
1476
|
+
{onRefresh && (
|
|
1477
|
+
<button
|
|
1478
|
+
type="button"
|
|
1479
|
+
className={'st-iconbtn st-refresh' + (refreshing ? ' is-spinning' : '')}
|
|
1480
|
+
data-tip="Refresh files · ⇧⌘R"
|
|
1481
|
+
aria-label="Refresh files"
|
|
1482
|
+
aria-busy={refreshing || undefined}
|
|
1483
|
+
disabled={refreshing}
|
|
1484
|
+
onClick={() => onRefresh()}
|
|
1485
|
+
>
|
|
1486
|
+
<StIcon name="reload" size={15} />
|
|
1487
|
+
</button>
|
|
1488
|
+
)}
|
|
1460
1489
|
<span
|
|
1461
1490
|
className="st-live"
|
|
1462
1491
|
data-tip={wsConnected ? 'live · file index synced' : 'reconnecting…'}
|
|
@@ -1553,7 +1582,7 @@ function Sidebar({
|
|
|
1553
1582
|
</div>
|
|
1554
1583
|
</div>
|
|
1555
1584
|
|
|
1556
|
-
<div className="st-tree" role="tree" aria-label="Project file tree">
|
|
1585
|
+
<div className="st-tree" role="tree" aria-label="Project file tree" data-testid="canvas-list">
|
|
1557
1586
|
{filteredGroups.map((g) => {
|
|
1558
1587
|
// Hide gitignored runtime / orphan-only project sections by default.
|
|
1559
1588
|
// Active search overrides — if the user typed a query, they want hits
|
|
@@ -2748,6 +2777,7 @@ function Viewport({
|
|
|
2748
2777
|
src={canvasUrl(t.path, cfg)}
|
|
2749
2778
|
className={t.path === activePath ? 'active' : ''}
|
|
2750
2779
|
data-path={t.path}
|
|
2780
|
+
data-testid={t.path === activePath ? 'canvas-frame' : undefined}
|
|
2751
2781
|
onLoad={() => onIframeLoad?.(t.path)}
|
|
2752
2782
|
// T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
|
|
2753
2783
|
// served cross-origin (canvasOrigin present = the split is on). In
|
|
@@ -3136,6 +3166,7 @@ function StatusBar({
|
|
|
3136
3166
|
(changesCount > 0 ? ' has-changes' : unpushed > 0 ? ' has-unpushed' : '')
|
|
3137
3167
|
}
|
|
3138
3168
|
onClick={onOpenChanges}
|
|
3169
|
+
data-testid="open-changes"
|
|
3139
3170
|
data-tip="Open Changes · ⌘⇧G"
|
|
3140
3171
|
data-tip-pos="top"
|
|
3141
3172
|
aria-label="Open Changes panel"
|
|
@@ -4125,7 +4156,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4125
4156
|
);
|
|
4126
4157
|
}
|
|
4127
4158
|
|
|
4128
|
-
function CssKnobs({ el, cfg, onOptimistic }) {
|
|
4159
|
+
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
4129
4160
|
const editable = !!el.id;
|
|
4130
4161
|
const computed = el.computed || {};
|
|
4131
4162
|
// Phase 12.3 — optimistic local overlay over the selection's authored / custom
|
|
@@ -4217,47 +4248,88 @@ function CssKnobs({ el, cfg, onOptimistic }) {
|
|
|
4217
4248
|
value,
|
|
4218
4249
|
});
|
|
4219
4250
|
};
|
|
4251
|
+
// Record an inline edit onto the canvas undo stack (Cmd+Z). The edit has
|
|
4252
|
+
// already POSTed `/_api/edit-*`; the canvas iframe APPENDS the record (no
|
|
4253
|
+
// re-run). `before`/`after` null = the prop/attr was/becomes unset.
|
|
4254
|
+
const record = (op, key, before, after) => {
|
|
4255
|
+
onRecordEdit?.({
|
|
4256
|
+
op,
|
|
4257
|
+
canvas: el.file,
|
|
4258
|
+
id: el.id,
|
|
4259
|
+
key,
|
|
4260
|
+
before: before == null || before === '' ? null : before,
|
|
4261
|
+
after: after == null || after === '' ? null : after,
|
|
4262
|
+
});
|
|
4263
|
+
};
|
|
4220
4264
|
const commit = (property, raw) => {
|
|
4221
4265
|
const value = (raw || '').trim();
|
|
4222
4266
|
if (!editable || !value) return;
|
|
4223
|
-
|
|
4267
|
+
const before = authored[property] ?? null;
|
|
4268
|
+
if (value === (before ?? '').trim()) return; // no-op
|
|
4224
4269
|
optimistic(property, value);
|
|
4225
4270
|
setA(property, value); // reflect in the panel immediately (no reload → no reselect)
|
|
4226
4271
|
post('/_api/edit-css', { canvas: el.file, id: el.id, property, value }, property);
|
|
4272
|
+
record('css', property, before, value);
|
|
4227
4273
|
};
|
|
4228
4274
|
// A custom CSS property (Advanced) — same write, but the panel surfaces it from
|
|
4229
4275
|
// the customStyles map, so overlay THERE.
|
|
4230
4276
|
const commitCustom = (property, raw) => {
|
|
4231
4277
|
const value = (raw || '').trim();
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4278
|
+
const prop = property.trim();
|
|
4279
|
+
if (!editable || !prop || !value) return;
|
|
4280
|
+
const before = customStyles[prop] ?? null;
|
|
4281
|
+
optimistic(prop, value);
|
|
4282
|
+
setC(prop, value);
|
|
4283
|
+
post('/_api/edit-css', { canvas: el.file, id: el.id, property: prop, value }, prop);
|
|
4284
|
+
record('css', prop, before, value);
|
|
4236
4285
|
};
|
|
4237
4286
|
const commitAttr = (attr, raw) => {
|
|
4238
4287
|
const a = (attr || '').trim();
|
|
4239
4288
|
const value = (raw || '').trim();
|
|
4240
4289
|
if (!editable || !a || !value) return;
|
|
4290
|
+
const before = attrs[a] ?? null;
|
|
4241
4291
|
setT(a, value);
|
|
4242
4292
|
post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
|
|
4293
|
+
record('attr', a, before, value);
|
|
4243
4294
|
};
|
|
4244
4295
|
// Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
|
|
4245
4296
|
const reset = (property) => {
|
|
4246
4297
|
if (!editable) return;
|
|
4298
|
+
const before = authored[property] ?? null;
|
|
4247
4299
|
optimistic(property, null);
|
|
4248
4300
|
setA(property, null);
|
|
4249
4301
|
post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
|
|
4302
|
+
record('css', property, before, null);
|
|
4250
4303
|
};
|
|
4251
4304
|
const resetCustom = (property) => {
|
|
4252
4305
|
if (!editable) return;
|
|
4306
|
+
const before = customStyles[property] ?? null;
|
|
4253
4307
|
optimistic(property, null);
|
|
4254
4308
|
setC(property, null);
|
|
4255
4309
|
post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
|
|
4310
|
+
record('css', property, before, null);
|
|
4256
4311
|
};
|
|
4257
4312
|
const resetAttr = (attr) => {
|
|
4258
4313
|
if (!editable) return;
|
|
4314
|
+
const before = attrs[attr] ?? null;
|
|
4259
4315
|
setT(attr, null);
|
|
4260
4316
|
post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
|
|
4317
|
+
record('attr', attr, before, null);
|
|
4318
|
+
};
|
|
4319
|
+
// Cmd+Z / Cmd+Shift+Z (or Cmd+Y) inside the inspector forwards to the canvas
|
|
4320
|
+
// undo stack — Figma-parity: a property field reverts the last DOCUMENT edit,
|
|
4321
|
+
// not field text. Without this, an edit committed with focus still in the
|
|
4322
|
+
// inspector couldn't be undone (the iframe's own keydown never sees the key).
|
|
4323
|
+
const onKnobKeyDown = (e) => {
|
|
4324
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
|
|
4325
|
+
const k = e.key.toLowerCase();
|
|
4326
|
+
if (k === 'z') {
|
|
4327
|
+
e.preventDefault();
|
|
4328
|
+
onUndoRedo?.(e.shiftKey ? 'redo' : 'undo');
|
|
4329
|
+
} else if (k === 'y') {
|
|
4330
|
+
e.preventDefault();
|
|
4331
|
+
onUndoRedo?.('redo');
|
|
4332
|
+
}
|
|
4261
4333
|
};
|
|
4262
4334
|
// Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
|
|
4263
4335
|
// change its value. Live preview via optimistic apply on every move (no source
|
|
@@ -4695,7 +4767,7 @@ function CssKnobs({ el, cfg, onOptimistic }) {
|
|
|
4695
4767
|
const attrRows = Object.entries(attrs);
|
|
4696
4768
|
|
|
4697
4769
|
return (
|
|
4698
|
-
<div className="st-cp" key={el.id} data-tour="css-panel">
|
|
4770
|
+
<div className="st-cp" key={el.id} data-tour="css-panel" onKeyDown={onKnobKeyDown}>
|
|
4699
4771
|
<div className="st-cp-id">
|
|
4700
4772
|
<span className="st-cp-idtag">
|
|
4701
4773
|
{el.tag || 'element'}
|
|
@@ -5241,6 +5313,8 @@ function InspectorPanel({
|
|
|
5241
5313
|
onHoverLayer,
|
|
5242
5314
|
cfg,
|
|
5243
5315
|
onOptimistic,
|
|
5316
|
+
onRecordEdit,
|
|
5317
|
+
onUndoRedo,
|
|
5244
5318
|
tab: tabProp,
|
|
5245
5319
|
onTabChange,
|
|
5246
5320
|
width,
|
|
@@ -5414,7 +5488,13 @@ function InspectorPanel({
|
|
|
5414
5488
|
)}
|
|
5415
5489
|
</>
|
|
5416
5490
|
) : (
|
|
5417
|
-
<CssKnobs
|
|
5491
|
+
<CssKnobs
|
|
5492
|
+
el={el}
|
|
5493
|
+
cfg={cfg}
|
|
5494
|
+
onOptimistic={onOptimistic}
|
|
5495
|
+
onRecordEdit={onRecordEdit}
|
|
5496
|
+
onUndoRedo={onUndoRedo}
|
|
5497
|
+
/>
|
|
5418
5498
|
)}
|
|
5419
5499
|
</div>
|
|
5420
5500
|
</aside>
|
|
@@ -6303,6 +6383,50 @@ function App() {
|
|
|
6303
6383
|
|
|
6304
6384
|
const reloadTree = useCallback(() => loadTree(), [loadTree]);
|
|
6305
6385
|
|
|
6386
|
+
// User-facing tree refresh with a visible spin. The header button and ⌘⇧R call
|
|
6387
|
+
// this so the reload icon spins for at least one beat — even when /_index-data
|
|
6388
|
+
// returns instantly — so the action registers visually ("something is
|
|
6389
|
+
// happening"). The min-duration race keeps the spin from flashing for one
|
|
6390
|
+
// frame on a fast read; the ref guard ignores re-entrant clicks. The passive
|
|
6391
|
+
// focus backstop below uses the plain reloadTree (no icon to animate).
|
|
6392
|
+
const [treeRefreshing, setTreeRefreshing] = useState(false);
|
|
6393
|
+
const treeRefreshingRef = useRef(false);
|
|
6394
|
+
const refreshTree = useCallback(async () => {
|
|
6395
|
+
if (treeRefreshingRef.current) return;
|
|
6396
|
+
treeRefreshingRef.current = true;
|
|
6397
|
+
setTreeRefreshing(true);
|
|
6398
|
+
try {
|
|
6399
|
+
await Promise.all([loadTree(), new Promise((r) => setTimeout(r, 550))]);
|
|
6400
|
+
} finally {
|
|
6401
|
+
treeRefreshingRef.current = false;
|
|
6402
|
+
setTreeRefreshing(false);
|
|
6403
|
+
}
|
|
6404
|
+
}, [loadTree]);
|
|
6405
|
+
|
|
6406
|
+
// Backstop for the desktop sidecar: re-list the tree whenever the window
|
|
6407
|
+
// regains focus. The fs-watch → canvas-list-update auto-refresh can drop
|
|
6408
|
+
// events in a `bun --compile` standalone binary (recursive fs.watch is
|
|
6409
|
+
// unreliable there) and across a sidecar respawn / WS reconnect, leaving a
|
|
6410
|
+
// stale tree after a canvas was created from the ACP chat or a terminal.
|
|
6411
|
+
// `/_index-data` is a cheap read and people tab away to the agent and back, so
|
|
6412
|
+
// this turns "switch projects to force a refresh" into "just come back to the
|
|
6413
|
+
// window". Debounced so a rapid blur/focus burst coalesces to one re-read.
|
|
6414
|
+
useEffect(() => {
|
|
6415
|
+
let t = null;
|
|
6416
|
+
const onFocus = () => {
|
|
6417
|
+
if (t) clearTimeout(t);
|
|
6418
|
+
t = setTimeout(() => {
|
|
6419
|
+
t = null;
|
|
6420
|
+
reloadTree();
|
|
6421
|
+
}, 150);
|
|
6422
|
+
};
|
|
6423
|
+
window.addEventListener('focus', onFocus);
|
|
6424
|
+
return () => {
|
|
6425
|
+
window.removeEventListener('focus', onFocus);
|
|
6426
|
+
if (t) clearTimeout(t);
|
|
6427
|
+
};
|
|
6428
|
+
}, [reloadTree]);
|
|
6429
|
+
|
|
6306
6430
|
// Phase 22 — create a blank brief board from the tree header. POSTs to the
|
|
6307
6431
|
// main-origin-only /_api/canvas (the untrusted canvas iframe can't reach it),
|
|
6308
6432
|
// then refreshes the tree and opens the new board so it's immediately the
|
|
@@ -6463,6 +6587,47 @@ function App() {
|
|
|
6463
6587
|
if (!j.ok) console.warn('[edit-text]', j.error || 'failed');
|
|
6464
6588
|
})
|
|
6465
6589
|
.catch(() => {});
|
|
6590
|
+
} else if (m.dgn === 'apply-edit' && m.id && (m.op === 'css' || m.op === 'text' || m.op === 'attr')) {
|
|
6591
|
+
// Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
|
|
6592
|
+
// `edit-source` command can't call the main-origin-only `/_api/edit-*`
|
|
6593
|
+
// routes (DDR-054), so it asks us to re-apply the before/after value.
|
|
6594
|
+
// `value` null = reset (remove the inline prop / attr). For CSS we also
|
|
6595
|
+
// optimistically repaint so the revert shows before the HMR reload.
|
|
6596
|
+
const op = m.op;
|
|
6597
|
+
const value = typeof m.value === 'string' ? m.value : null;
|
|
6598
|
+
let url;
|
|
6599
|
+
let body;
|
|
6600
|
+
if (op === 'css') {
|
|
6601
|
+
url = '/_api/edit-css';
|
|
6602
|
+
body =
|
|
6603
|
+
value == null
|
|
6604
|
+
? { canvas: m.canvas, id: m.id, property: m.key, reset: true }
|
|
6605
|
+
: { canvas: m.canvas, id: m.id, property: m.key, value };
|
|
6606
|
+
applyOptimisticStyle({ id: m.id, prop: m.key, value });
|
|
6607
|
+
} else if (op === 'attr') {
|
|
6608
|
+
url = '/_api/edit-attr';
|
|
6609
|
+
body =
|
|
6610
|
+
value == null
|
|
6611
|
+
? { canvas: m.canvas, id: m.id, attr: m.key, reset: true }
|
|
6612
|
+
: { canvas: m.canvas, id: m.id, attr: m.key, value };
|
|
6613
|
+
} else {
|
|
6614
|
+
url = '/_api/edit-text';
|
|
6615
|
+
body = { canvas: m.canvas, id: m.id, text: value ?? '' };
|
|
6616
|
+
}
|
|
6617
|
+
editApplyChainRef.current = editApplyChainRef.current
|
|
6618
|
+
.catch(() => {})
|
|
6619
|
+
.then(() =>
|
|
6620
|
+
fetch(url, {
|
|
6621
|
+
method: 'POST',
|
|
6622
|
+
headers: { 'content-type': 'application/json' },
|
|
6623
|
+
body: JSON.stringify(body),
|
|
6624
|
+
})
|
|
6625
|
+
.then((r) => r.json().catch(() => ({})))
|
|
6626
|
+
.then((j) => {
|
|
6627
|
+
if (!j.ok) console.warn('[apply-edit]', op, j.error || 'failed');
|
|
6628
|
+
})
|
|
6629
|
+
.catch(() => {})
|
|
6630
|
+
);
|
|
6466
6631
|
} else if (m.dgn === 'layers-tree') {
|
|
6467
6632
|
// Phase 12 Task 4 — browsable layers tree for the active artboard.
|
|
6468
6633
|
setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
|
|
@@ -6716,6 +6881,28 @@ function App() {
|
|
|
6716
6881
|
[activePath]
|
|
6717
6882
|
);
|
|
6718
6883
|
|
|
6884
|
+
// Inline-edit undo (DDR-103/104 follow-up). The inspector calls this after it
|
|
6885
|
+
// POSTs `/_api/edit-css` / `/_api/edit-attr`, so the canvas iframe records the
|
|
6886
|
+
// edit on its undo stack and Cmd+Z can invert it. The iframe gates this to
|
|
6887
|
+
// parent-origin posts (DDR-054). See `commands/edit-source-command.ts`.
|
|
6888
|
+
const recordSourceEdit = useCallback(
|
|
6889
|
+
(payload) => {
|
|
6890
|
+
if (!activePath || activePath === SYSTEM_TAB || !payload) return;
|
|
6891
|
+
const el = iframesRef.current.get(activePath);
|
|
6892
|
+
if (el && el.contentWindow) {
|
|
6893
|
+
try {
|
|
6894
|
+
el.contentWindow.postMessage({ dgn: 'record-edit', payload }, '*');
|
|
6895
|
+
} catch {}
|
|
6896
|
+
}
|
|
6897
|
+
},
|
|
6898
|
+
[activePath]
|
|
6899
|
+
);
|
|
6900
|
+
|
|
6901
|
+
// Serializes `apply-edit` source writes from canvas undo/redo so a rapid
|
|
6902
|
+
// multi-Cmd+Z on the same property lands on disk in dispatch order (the
|
|
6903
|
+
// iframe sink is fire-and-forget, so without this the POSTs could race).
|
|
6904
|
+
const editApplyChainRef = useRef(Promise.resolve());
|
|
6905
|
+
|
|
6719
6906
|
const resolveComment = useCallback((id) => {
|
|
6720
6907
|
wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
|
|
6721
6908
|
}, []);
|
|
@@ -6784,6 +6971,28 @@ function App() {
|
|
|
6784
6971
|
setPaletteOpen((v) => !v);
|
|
6785
6972
|
return;
|
|
6786
6973
|
}
|
|
6974
|
+
// Cmd+Z / Cmd+Shift+Z / Cmd+Y — forward to the active canvas's undo stack
|
|
6975
|
+
// when focus is in the shell chrome (not a text field, not the canvas
|
|
6976
|
+
// iframe). Inside the canvas iframe the canvas owns Cmd+Z; inside an editable
|
|
6977
|
+
// field native undo wins (the inspector's CssKnobs forwards on its own). This
|
|
6978
|
+
// makes inspector CSS / inline text / attr edits undoable from anywhere.
|
|
6979
|
+
if (meta && !e.altKey && (e.key === 'z' || e.key === 'Z' || e.key === 'y' || e.key === 'Y')) {
|
|
6980
|
+
if (!inEditable && !inCanvasIframe && activePath && activePath !== SYSTEM_TAB) {
|
|
6981
|
+
e.preventDefault();
|
|
6982
|
+
const redo = e.key === 'y' || e.key === 'Y' || e.shiftKey;
|
|
6983
|
+
postToActiveCanvas({ dgn: redo ? 'redo' : 'undo' });
|
|
6984
|
+
return;
|
|
6985
|
+
}
|
|
6986
|
+
}
|
|
6987
|
+
// Cmd+Shift+R — refresh the FILES tree (re-read /_index-data). The fs-watch
|
|
6988
|
+
// → canvas-list-update auto-refresh can miss events in the compiled desktop
|
|
6989
|
+
// sidecar (recursive fs.watch is unreliable in a bun --compile binary), and
|
|
6990
|
+
// ⌘R is taken by canvas-iframe reload, so this is the manual escape hatch.
|
|
6991
|
+
if (meta && e.shiftKey && (e.key === 'r' || e.key === 'R')) {
|
|
6992
|
+
e.preventDefault();
|
|
6993
|
+
refreshTree();
|
|
6994
|
+
return;
|
|
6995
|
+
}
|
|
6787
6996
|
// Cmd+R — reload active iframe (override browser reload)
|
|
6788
6997
|
if (meta && (e.key === 'r' || e.key === 'R')) {
|
|
6789
6998
|
e.preventDefault();
|
|
@@ -6933,6 +7142,7 @@ function App() {
|
|
|
6933
7142
|
return () => window.removeEventListener('keydown', onKey);
|
|
6934
7143
|
}, [
|
|
6935
7144
|
reloadActive,
|
|
7145
|
+
refreshTree,
|
|
6936
7146
|
selected,
|
|
6937
7147
|
activePath,
|
|
6938
7148
|
focusedCommentId,
|
|
@@ -7216,6 +7426,8 @@ function App() {
|
|
|
7216
7426
|
onToggleSection={toggleSection}
|
|
7217
7427
|
onNewBoard={createBoard}
|
|
7218
7428
|
onDeleteBoard={deleteBoard}
|
|
7429
|
+
onRefresh={refreshTree}
|
|
7430
|
+
refreshing={treeRefreshing}
|
|
7219
7431
|
collapsed={!sidebarOpen}
|
|
7220
7432
|
onCollapse={() => setSidebarOpen(false)}
|
|
7221
7433
|
width={sbSize.w}
|
|
@@ -7300,6 +7512,8 @@ function App() {
|
|
|
7300
7512
|
onTabChange={setInspectorTab}
|
|
7301
7513
|
onClose={() => setInspectorOpen(false)}
|
|
7302
7514
|
onOptimistic={applyOptimisticStyle}
|
|
7515
|
+
onRecordEdit={recordSourceEdit}
|
|
7516
|
+
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
7303
7517
|
layersTree={layersTree}
|
|
7304
7518
|
onSelectLayer={(n) =>
|
|
7305
7519
|
postToActiveCanvas({
|
|
@@ -411,6 +411,7 @@ export default function GitPanel({
|
|
|
411
411
|
<button
|
|
412
412
|
type="button"
|
|
413
413
|
className="btn btn--primary gp-publish"
|
|
414
|
+
data-testid="git-publish"
|
|
414
415
|
data-tour="publish"
|
|
415
416
|
disabled={!!busy}
|
|
416
417
|
onClick={() =>
|
|
@@ -435,6 +436,7 @@ export default function GitPanel({
|
|
|
435
436
|
className={'st-rpanel gp-panel' + (resizing ? ' is-resizing' : '')}
|
|
436
437
|
style={width ? { width, flexBasis: width } : undefined}
|
|
437
438
|
aria-label="Changes"
|
|
439
|
+
data-testid="git-panel"
|
|
438
440
|
>
|
|
439
441
|
<div className="gp-head">
|
|
440
442
|
<div className="gp-panel-hd">
|
|
@@ -494,6 +496,7 @@ export default function GitPanel({
|
|
|
494
496
|
<button
|
|
495
497
|
type="button"
|
|
496
498
|
className="btn btn--sm"
|
|
499
|
+
data-testid="git-get-latest"
|
|
497
500
|
disabled={!!busy}
|
|
498
501
|
onClick={() =>
|
|
499
502
|
run('getLatest', onGetLatest, {
|
|
@@ -559,6 +562,7 @@ export default function GitPanel({
|
|
|
559
562
|
<button
|
|
560
563
|
type="button"
|
|
561
564
|
className="btn btn--ghost btn--sm"
|
|
565
|
+
data-testid="git-get-latest"
|
|
562
566
|
data-tour="pull"
|
|
563
567
|
disabled={!!busy}
|
|
564
568
|
onClick={() =>
|
|
@@ -639,6 +643,7 @@ export default function GitPanel({
|
|
|
639
643
|
</label>
|
|
640
644
|
<textarea
|
|
641
645
|
className="gp-msg"
|
|
646
|
+
data-testid="git-commit-message"
|
|
642
647
|
placeholder="Describe what changed in this version…"
|
|
643
648
|
aria-label="Describe what changed in this version"
|
|
644
649
|
rows={2}
|
|
@@ -665,6 +670,7 @@ export default function GitPanel({
|
|
|
665
670
|
<button
|
|
666
671
|
type="button"
|
|
667
672
|
className="btn btn--ghost btn--sm"
|
|
673
|
+
data-testid="git-save-all"
|
|
668
674
|
disabled={!message.trim() || !!busy}
|
|
669
675
|
title="Save every change"
|
|
670
676
|
onClick={async () => {
|