@1agh/maude 0.36.0 → 0.37.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/canvas-shell.tsx +73 -2
- package/apps/studio/client/app.jsx +138 -10
- package/apps/studio/client/github.js +6 -0
- package/apps/studio/client/panels/CreateProject.jsx +57 -23
- package/apps/studio/client/panels/GitPanel.jsx +1 -1
- package/apps/studio/client/panels/IdentityBar.jsx +43 -2
- package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
- package/apps/studio/client/tour/collab-tour.js +3 -3
- package/apps/studio/commands/edit-source-command.ts +118 -0
- package/apps/studio/dist/client.bundle.js +53617 -21
- package/apps/studio/github/endpoints.ts +42 -0
- package/apps/studio/http.ts +15 -0
- package/apps/studio/scaffold-design.ts +95 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +3 -0
- package/apps/studio/test/edit-source-command.test.ts +119 -0
- package/apps/studio/test/sync-agent.test.ts +9 -2
- 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 +11 -9
|
@@ -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;
|
|
@@ -4156,7 +4156,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
|
|
|
4156
4156
|
);
|
|
4157
4157
|
}
|
|
4158
4158
|
|
|
4159
|
-
function CssKnobs({ el, cfg, onOptimistic }) {
|
|
4159
|
+
function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onUndoRedo }) {
|
|
4160
4160
|
const editable = !!el.id;
|
|
4161
4161
|
const computed = el.computed || {};
|
|
4162
4162
|
// Phase 12.3 — optimistic local overlay over the selection's authored / custom
|
|
@@ -4248,47 +4248,88 @@ function CssKnobs({ el, cfg, onOptimistic }) {
|
|
|
4248
4248
|
value,
|
|
4249
4249
|
});
|
|
4250
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
|
+
};
|
|
4251
4264
|
const commit = (property, raw) => {
|
|
4252
4265
|
const value = (raw || '').trim();
|
|
4253
4266
|
if (!editable || !value) return;
|
|
4254
|
-
|
|
4267
|
+
const before = authored[property] ?? null;
|
|
4268
|
+
if (value === (before ?? '').trim()) return; // no-op
|
|
4255
4269
|
optimistic(property, value);
|
|
4256
4270
|
setA(property, value); // reflect in the panel immediately (no reload → no reselect)
|
|
4257
4271
|
post('/_api/edit-css', { canvas: el.file, id: el.id, property, value }, property);
|
|
4272
|
+
record('css', property, before, value);
|
|
4258
4273
|
};
|
|
4259
4274
|
// A custom CSS property (Advanced) — same write, but the panel surfaces it from
|
|
4260
4275
|
// the customStyles map, so overlay THERE.
|
|
4261
4276
|
const commitCustom = (property, raw) => {
|
|
4262
4277
|
const value = (raw || '').trim();
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
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);
|
|
4267
4285
|
};
|
|
4268
4286
|
const commitAttr = (attr, raw) => {
|
|
4269
4287
|
const a = (attr || '').trim();
|
|
4270
4288
|
const value = (raw || '').trim();
|
|
4271
4289
|
if (!editable || !a || !value) return;
|
|
4290
|
+
const before = attrs[a] ?? null;
|
|
4272
4291
|
setT(a, value);
|
|
4273
4292
|
post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
|
|
4293
|
+
record('attr', a, before, value);
|
|
4274
4294
|
};
|
|
4275
4295
|
// Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
|
|
4276
4296
|
const reset = (property) => {
|
|
4277
4297
|
if (!editable) return;
|
|
4298
|
+
const before = authored[property] ?? null;
|
|
4278
4299
|
optimistic(property, null);
|
|
4279
4300
|
setA(property, null);
|
|
4280
4301
|
post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
|
|
4302
|
+
record('css', property, before, null);
|
|
4281
4303
|
};
|
|
4282
4304
|
const resetCustom = (property) => {
|
|
4283
4305
|
if (!editable) return;
|
|
4306
|
+
const before = customStyles[property] ?? null;
|
|
4284
4307
|
optimistic(property, null);
|
|
4285
4308
|
setC(property, null);
|
|
4286
4309
|
post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
|
|
4310
|
+
record('css', property, before, null);
|
|
4287
4311
|
};
|
|
4288
4312
|
const resetAttr = (attr) => {
|
|
4289
4313
|
if (!editable) return;
|
|
4314
|
+
const before = attrs[attr] ?? null;
|
|
4290
4315
|
setT(attr, null);
|
|
4291
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
|
+
}
|
|
4292
4333
|
};
|
|
4293
4334
|
// Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
|
|
4294
4335
|
// change its value. Live preview via optimistic apply on every move (no source
|
|
@@ -4726,7 +4767,7 @@ function CssKnobs({ el, cfg, onOptimistic }) {
|
|
|
4726
4767
|
const attrRows = Object.entries(attrs);
|
|
4727
4768
|
|
|
4728
4769
|
return (
|
|
4729
|
-
<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}>
|
|
4730
4771
|
<div className="st-cp-id">
|
|
4731
4772
|
<span className="st-cp-idtag">
|
|
4732
4773
|
{el.tag || 'element'}
|
|
@@ -5272,6 +5313,8 @@ function InspectorPanel({
|
|
|
5272
5313
|
onHoverLayer,
|
|
5273
5314
|
cfg,
|
|
5274
5315
|
onOptimistic,
|
|
5316
|
+
onRecordEdit,
|
|
5317
|
+
onUndoRedo,
|
|
5275
5318
|
tab: tabProp,
|
|
5276
5319
|
onTabChange,
|
|
5277
5320
|
width,
|
|
@@ -5445,7 +5488,13 @@ function InspectorPanel({
|
|
|
5445
5488
|
)}
|
|
5446
5489
|
</>
|
|
5447
5490
|
) : (
|
|
5448
|
-
<CssKnobs
|
|
5491
|
+
<CssKnobs
|
|
5492
|
+
el={el}
|
|
5493
|
+
cfg={cfg}
|
|
5494
|
+
onOptimistic={onOptimistic}
|
|
5495
|
+
onRecordEdit={onRecordEdit}
|
|
5496
|
+
onUndoRedo={onUndoRedo}
|
|
5497
|
+
/>
|
|
5449
5498
|
)}
|
|
5450
5499
|
</div>
|
|
5451
5500
|
</aside>
|
|
@@ -6538,6 +6587,47 @@ function App() {
|
|
|
6538
6587
|
if (!j.ok) console.warn('[edit-text]', j.error || 'failed');
|
|
6539
6588
|
})
|
|
6540
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
|
+
);
|
|
6541
6631
|
} else if (m.dgn === 'layers-tree') {
|
|
6542
6632
|
// Phase 12 Task 4 — browsable layers tree for the active artboard.
|
|
6543
6633
|
setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
|
|
@@ -6791,6 +6881,28 @@ function App() {
|
|
|
6791
6881
|
[activePath]
|
|
6792
6882
|
);
|
|
6793
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
|
+
|
|
6794
6906
|
const resolveComment = useCallback((id) => {
|
|
6795
6907
|
wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
|
|
6796
6908
|
}, []);
|
|
@@ -6859,6 +6971,19 @@ function App() {
|
|
|
6859
6971
|
setPaletteOpen((v) => !v);
|
|
6860
6972
|
return;
|
|
6861
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
|
+
}
|
|
6862
6987
|
// Cmd+Shift+R — refresh the FILES tree (re-read /_index-data). The fs-watch
|
|
6863
6988
|
// → canvas-list-update auto-refresh can miss events in the compiled desktop
|
|
6864
6989
|
// sidecar (recursive fs.watch is unreliable in a bun --compile binary), and
|
|
@@ -7387,6 +7512,8 @@ function App() {
|
|
|
7387
7512
|
onTabChange={setInspectorTab}
|
|
7388
7513
|
onClose={() => setInspectorOpen(false)}
|
|
7389
7514
|
onOptimistic={applyOptimisticStyle}
|
|
7515
|
+
onRecordEdit={recordSourceEdit}
|
|
7516
|
+
onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
|
|
7390
7517
|
layersTree={layersTree}
|
|
7391
7518
|
onSelectLayer={(n) =>
|
|
7392
7519
|
postToActiveCanvas({
|
|
@@ -7496,8 +7623,9 @@ function App() {
|
|
|
7496
7623
|
loadLog={gitLoadLog}
|
|
7497
7624
|
onClose={() => setDiffTarget(null)}
|
|
7498
7625
|
onRestore={async (file) => {
|
|
7499
|
-
await gitDiscard([file]);
|
|
7500
|
-
setDiffTarget(null);
|
|
7626
|
+
const res = await gitDiscard([file]);
|
|
7627
|
+
if (res?.ok) setDiffTarget(null);
|
|
7628
|
+
else window.alert(res?.error || 'Could not restore that version. Try again.');
|
|
7501
7629
|
}}
|
|
7502
7630
|
onResolve={async (choice) => {
|
|
7503
7631
|
// phase-28 (E3): apply the chosen side via /_api/git/resolve, which
|
|
@@ -37,6 +37,10 @@ export const openVerification = () =>
|
|
|
37
37
|
invoke('github_open_verification', { url: 'https://github.com/login/device' });
|
|
38
38
|
/** Show the device code as soon as the shell has it. Returns an unlisten promise. */
|
|
39
39
|
export const onDeviceCode = (cb) => listen('github://device-code', cb);
|
|
40
|
+
/** Fire when sign-in completes (any surface) so other surfaces can flip live. cb(login). */
|
|
41
|
+
export const onSignedIn = (cb) => listen('github://signed-in', cb);
|
|
42
|
+
/** Fire when the native File ▸ New Project… menu item is chosen. Returns an unlisten promise. */
|
|
43
|
+
export const onMenuNewProject = (cb) => listen('menu://new-project', cb);
|
|
40
44
|
|
|
41
45
|
// ── dev-server endpoints ────────────────────────────────────────────────────────
|
|
42
46
|
async function api(path, opts = {}) {
|
|
@@ -63,6 +67,8 @@ export const createRepo = (body) => api('/_api/github/create-repo', { method: 'P
|
|
|
63
67
|
export const invite = (username) => api('/_api/github/invite', { method: 'POST', body: JSON.stringify({ username }) });
|
|
64
68
|
export const cloneRepo = (body) => api('/_api/github/clone', { method: 'POST', body: JSON.stringify(body) });
|
|
65
69
|
export const createProject = (body) => api('/_api/github/create-project', { method: 'POST', body: JSON.stringify(body) });
|
|
70
|
+
/** Create a local-only project (git init + .design scaffold, no GitHub remote). body: { name, parentDir }. */
|
|
71
|
+
export const createLocalProject = (body) => api('/_api/project/create-local', { method: 'POST', body: JSON.stringify(body) });
|
|
66
72
|
export const initDesign = (dir) => api('/_api/design/init', { method: 'POST', body: JSON.stringify({ dir }) });
|
|
67
73
|
/** Phase 29 (E4) Door C — connect to a team hub (saves the global hub credential). */
|
|
68
74
|
export const hubLink = (body) => api('/_api/hub/link', { method: 'POST', body: JSON.stringify(body) });
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { useEffect, useState } from 'react';
|
|
14
14
|
|
|
15
|
-
import { cloneRepo, createProject, initDesign, invite, listRepos, openLocalProject, pickDirectory } from '../github.js';
|
|
15
|
+
import { cloneRepo, createLocalProject, createProject, initDesign, invite, listRepos, openLocalProject, pickDirectory } from '../github.js';
|
|
16
16
|
|
|
17
17
|
function Icon({ name, size = 16 }) {
|
|
18
18
|
const p = {
|
|
@@ -57,6 +57,12 @@ function Icon({ name, size = 16 }) {
|
|
|
57
57
|
</>
|
|
58
58
|
),
|
|
59
59
|
spinner: <path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8" />,
|
|
60
|
+
laptop: (
|
|
61
|
+
<>
|
|
62
|
+
<rect x="3" y="3.5" width="10" height="7" rx="1" />
|
|
63
|
+
<path d="M1.5 13h13l-1.2-1.8H2.7z" />
|
|
64
|
+
</>
|
|
65
|
+
),
|
|
60
66
|
}[name];
|
|
61
67
|
return (
|
|
62
68
|
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={name === 'spinner' ? 'cp-spin' : undefined}>
|
|
@@ -66,12 +72,12 @@ function Icon({ name, size = 16 }) {
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
const TITLES = {
|
|
69
|
-
new: ['Create a new project', '
|
|
75
|
+
new: ['Create a new project', 'On GitHub, or just locally on your computer.'],
|
|
70
76
|
get: ['Pull a local copy', 'Pick a project, choose where to save it, and open it here.'],
|
|
71
77
|
share: ['Share this project', 'Invite a teammate by their GitHub username.'],
|
|
72
78
|
};
|
|
73
79
|
|
|
74
|
-
export default function CreateProject({ view, identity, onClose }) {
|
|
80
|
+
export default function CreateProject({ view, identity, signedIn, onClose }) {
|
|
75
81
|
const [title, sub] = TITLES[view] || TITLES.new;
|
|
76
82
|
return (
|
|
77
83
|
<div className="cp-modal" role="dialog" aria-modal="true" aria-label={title} onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
|
@@ -86,7 +92,7 @@ export default function CreateProject({ view, identity, onClose }) {
|
|
|
86
92
|
<Icon name="x" size={15} />
|
|
87
93
|
</button>
|
|
88
94
|
</div>
|
|
89
|
-
{view === 'new' && <NewView identity={identity} onClose={onClose} />}
|
|
95
|
+
{view === 'new' && <NewView identity={identity} signedIn={signedIn} onClose={onClose} />}
|
|
90
96
|
{view === 'get' && <GetView />}
|
|
91
97
|
{view === 'share' && <ShareView onClose={onClose} />}
|
|
92
98
|
</div>
|
|
@@ -94,20 +100,24 @@ export default function CreateProject({ view, identity, onClose }) {
|
|
|
94
100
|
);
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
function NewView({ identity, onClose }) {
|
|
103
|
+
function NewView({ identity, signedIn, onClose }) {
|
|
98
104
|
const [name, setName] = useState('');
|
|
105
|
+
// 'github' = new repo on GitHub (needs sign-in); 'local' = just a local git repo,
|
|
106
|
+
// no remote. Default to local when signed out (GitHub isn't available then).
|
|
107
|
+
const [where, setWhere] = useState(signedIn ? 'github' : 'local');
|
|
99
108
|
const [isPrivate, setIsPrivate] = useState(true);
|
|
100
109
|
const [desc, setDesc] = useState('');
|
|
101
110
|
const [busy, setBusy] = useState(false);
|
|
102
111
|
const [step, setStep] = useState('');
|
|
103
112
|
const [err, setErr] = useState('');
|
|
104
113
|
|
|
114
|
+
const local = where === 'local';
|
|
105
115
|
const slug = name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
106
116
|
const owner = identity?.login || 'you';
|
|
107
117
|
|
|
108
|
-
// New project =
|
|
109
|
-
//
|
|
110
|
-
// and Publish when ready.
|
|
118
|
+
// New project = a ready-to-design local project, then open it. GitHub mode also
|
|
119
|
+
// creates the remote repo + sets origin; local mode is git init only (publish later).
|
|
120
|
+
// You pick where to save it, then design and Publish when ready.
|
|
111
121
|
async function submit() {
|
|
112
122
|
setErr('');
|
|
113
123
|
setBusy(true);
|
|
@@ -115,8 +125,14 @@ function NewView({ identity, onClose }) {
|
|
|
115
125
|
setStep('Choose where to save it…');
|
|
116
126
|
const parentDir = await pickDirectory();
|
|
117
127
|
if (!parentDir) { setBusy(false); setStep(''); return; } // cancelled
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
let r;
|
|
129
|
+
if (local) {
|
|
130
|
+
setStep('Setting up your local project…');
|
|
131
|
+
r = await createLocalProject({ name, parentDir });
|
|
132
|
+
} else {
|
|
133
|
+
setStep('Creating your project on GitHub…');
|
|
134
|
+
r = await createProject({ name, private: isPrivate, description: desc, parentDir });
|
|
135
|
+
}
|
|
120
136
|
if (!(r.ok && r.json?.ok)) {
|
|
121
137
|
setErr(r.json?.error || 'Couldn’t create the project. Try again.');
|
|
122
138
|
setBusy(false);
|
|
@@ -135,30 +151,48 @@ function NewView({ identity, onClose }) {
|
|
|
135
151
|
return (
|
|
136
152
|
<>
|
|
137
153
|
<div className="cp-body">
|
|
138
|
-
<label className="cp-field">
|
|
139
|
-
<span className="cp-field-label">Project name</span>
|
|
140
|
-
<input className="input cp-input" type="text" value={name} placeholder="Acme Rebrand" aria-label="Project name" onChange={(e) => setName(e.target.value)} />
|
|
141
|
-
{slug && <span className="cp-field-help">Creates <b>github.com/{owner}/{slug}</b></span>}
|
|
142
|
-
</label>
|
|
143
154
|
<div className="cp-field">
|
|
144
|
-
<span className="cp-field-label">
|
|
145
|
-
<div className="seg cp-seg" role="group" aria-label="
|
|
146
|
-
<button type="button" aria-pressed={
|
|
147
|
-
<button type="button" aria-pressed={
|
|
155
|
+
<span className="cp-field-label">Where</span>
|
|
156
|
+
<div className="seg cp-seg" role="group" aria-label="Where to create the project">
|
|
157
|
+
<button type="button" aria-pressed={!local} disabled={!signedIn} title={signedIn ? undefined : 'Sign in with GitHub first'} onClick={() => setWhere('github')}><Icon name="globe" size={14} /> GitHub</button>
|
|
158
|
+
<button type="button" aria-pressed={local} onClick={() => setWhere('local')}><Icon name="laptop" size={14} /> This computer only</button>
|
|
148
159
|
</div>
|
|
149
|
-
<span className="cp-field-help">
|
|
160
|
+
<span className="cp-field-help">
|
|
161
|
+
{local
|
|
162
|
+
? 'A local git repo on your computer — no GitHub, no remote. You can publish it later.'
|
|
163
|
+
: signedIn
|
|
164
|
+
? 'A repo on your GitHub account, cloned to your computer.'
|
|
165
|
+
: 'Sign in with GitHub (bottom-left) to publish. For now you can create a local project.'}
|
|
166
|
+
</span>
|
|
150
167
|
</div>
|
|
151
168
|
<label className="cp-field">
|
|
152
|
-
<span className="cp-field-label">
|
|
153
|
-
<
|
|
169
|
+
<span className="cp-field-label">Project name</span>
|
|
170
|
+
<input className="input cp-input" type="text" value={name} placeholder="Acme Rebrand" aria-label="Project name" onChange={(e) => setName(e.target.value)} />
|
|
171
|
+
{slug && <span className="cp-field-help">{local ? <>Creates a project folder <b>{slug}</b></> : <>Creates <b>github.com/{owner}/{slug}</b></>}</span>}
|
|
154
172
|
</label>
|
|
173
|
+
{!local && (
|
|
174
|
+
<>
|
|
175
|
+
<div className="cp-field">
|
|
176
|
+
<span className="cp-field-label">Who can see it</span>
|
|
177
|
+
<div className="seg cp-seg" role="group" aria-label="Project visibility">
|
|
178
|
+
<button type="button" aria-pressed={isPrivate} onClick={() => setIsPrivate(true)}><Icon name="lock" size={14} /> Private</button>
|
|
179
|
+
<button type="button" aria-pressed={!isPrivate} onClick={() => setIsPrivate(false)}><Icon name="globe" size={14} /> Public</button>
|
|
180
|
+
</div>
|
|
181
|
+
<span className="cp-field-help">{isPrivate ? 'Only you and people you invite. The safe default.' : 'Anyone on the internet can see this project.'}</span>
|
|
182
|
+
</div>
|
|
183
|
+
<label className="cp-field">
|
|
184
|
+
<span className="cp-field-label">Description <span className="cp-optional">optional</span></span>
|
|
185
|
+
<textarea className="textarea cp-textarea" rows={2} value={desc} placeholder="What is this project for?" aria-label="Project description" onChange={(e) => setDesc(e.target.value)} />
|
|
186
|
+
</label>
|
|
187
|
+
</>
|
|
188
|
+
)}
|
|
155
189
|
{err && <div className="callout callout--error"><span className="cp-cl-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
|
|
156
190
|
</div>
|
|
157
191
|
<div className="cp-ft">
|
|
158
192
|
<span className="cp-spacer" />
|
|
159
193
|
<button type="button" className="btn btn--ghost" onClick={onClose}>Cancel</button>
|
|
160
194
|
<button type="button" className="btn btn--primary" onClick={submit} disabled={busy || !slug}>
|
|
161
|
-
<Icon name="plus" size={15} /> {busy ? step || 'Creating…' : 'Create project'}
|
|
195
|
+
<Icon name="plus" size={15} /> {busy ? step || 'Creating…' : local ? 'Create local project' : 'Create project'}
|
|
162
196
|
</button>
|
|
163
197
|
</div>
|
|
164
198
|
</>
|
|
@@ -343,7 +343,7 @@ export default function GitPanel({
|
|
|
343
343
|
if (res.authRequired)
|
|
344
344
|
setBanner({
|
|
345
345
|
variant: 'info',
|
|
346
|
-
text: res.error || 'Sign in with GitHub to publish
|
|
346
|
+
text: res.error || 'Sign in with GitHub to publish.',
|
|
347
347
|
});
|
|
348
348
|
else if (res.conflict)
|
|
349
349
|
// A push conflict (non-fast-forward) prompts a Get-latest; a Get-latest
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
isNativeApp,
|
|
16
16
|
isSignedIn,
|
|
17
17
|
onDeviceCode,
|
|
18
|
+
onMenuNewProject,
|
|
19
|
+
onSignedIn,
|
|
18
20
|
openVerification,
|
|
19
21
|
signIn,
|
|
20
22
|
signOut,
|
|
@@ -107,6 +109,8 @@ export default function IdentityBar() {
|
|
|
107
109
|
const [copied, setCopied] = useState(false);
|
|
108
110
|
const unlistenRef = useRef(null);
|
|
109
111
|
const railRef = useRef(null);
|
|
112
|
+
const stateRef = useRef(state);
|
|
113
|
+
stateRef.current = state; // keep current for the (subscribe-once) menu listener
|
|
110
114
|
|
|
111
115
|
useEffect(() => {
|
|
112
116
|
let alive = true;
|
|
@@ -116,7 +120,14 @@ export default function IdentityBar() {
|
|
|
116
120
|
const signed = await isSignedIn();
|
|
117
121
|
if (!alive) return;
|
|
118
122
|
if (signed) {
|
|
119
|
-
|
|
123
|
+
let r = await fetchIdentity();
|
|
124
|
+
// A valid keychain token can still hit a transient identity-fetch failure at
|
|
125
|
+
// mount (bridge not ready / GitHub 5xx / rate-limit) — retry before falling back
|
|
126
|
+
// to the signed-out CTA so we don't flash "Sign in" at a signed-in user.
|
|
127
|
+
for (let i = 0; i < 2 && alive && !(r.ok && r.json?.ok); i += 1) {
|
|
128
|
+
await new Promise((res) => setTimeout(res, 800));
|
|
129
|
+
r = await fetchIdentity();
|
|
130
|
+
}
|
|
120
131
|
if (!alive) return;
|
|
121
132
|
if (r.ok && r.json?.ok) {
|
|
122
133
|
setIdentity({ login: r.json.login, name: r.json.name, avatar_url: r.json.avatar_url });
|
|
@@ -133,6 +144,36 @@ export default function IdentityBar() {
|
|
|
133
144
|
};
|
|
134
145
|
}, [native]);
|
|
135
146
|
|
|
147
|
+
// Live-update when sign-in completes in ANOTHER surface (the wizard's Door A emits
|
|
148
|
+
// `github://signed-in`). Without this the rail can sit on "Sign in" with a valid token
|
|
149
|
+
// until a full reload. Flip to signed-in off the event's login, then enrich the profile.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!native) return undefined;
|
|
152
|
+
const p = onSignedIn(async (login) => {
|
|
153
|
+
setIdentity((cur) => cur || { login, name: null, avatar_url: null });
|
|
154
|
+
setState('in');
|
|
155
|
+
const r = await fetchIdentity();
|
|
156
|
+
if (r.ok && r.json?.ok)
|
|
157
|
+
setIdentity({ login: r.json.login, name: r.json.name, avatar_url: r.json.avatar_url });
|
|
158
|
+
});
|
|
159
|
+
return () => {
|
|
160
|
+
p?.then?.((fn) => fn?.());
|
|
161
|
+
};
|
|
162
|
+
}, [native]);
|
|
163
|
+
|
|
164
|
+
// Native File ▸ New Project… (menu.rs emits `menu://new-project`). Open the create
|
|
165
|
+
// dialog once the GitHub check settled — even when signed out, since it offers a
|
|
166
|
+
// local-only project (no GitHub needed); the dialog gates the GitHub option itself.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!native) return undefined;
|
|
169
|
+
const p = onMenuNewProject(() => {
|
|
170
|
+
if (stateRef.current !== 'loading') setView('new');
|
|
171
|
+
});
|
|
172
|
+
return () => {
|
|
173
|
+
p?.then?.((fn) => fn?.());
|
|
174
|
+
};
|
|
175
|
+
}, [native]);
|
|
176
|
+
|
|
136
177
|
useEffect(() => {
|
|
137
178
|
if (!menuOpen) return;
|
|
138
179
|
const onDoc = (e) => {
|
|
@@ -288,7 +329,7 @@ export default function IdentityBar() {
|
|
|
288
329
|
</div>
|
|
289
330
|
)}
|
|
290
331
|
|
|
291
|
-
{view && <CreateProject view={view} identity={identity} onClose={() => setView(null)} />}
|
|
332
|
+
{view && <CreateProject view={view} identity={identity} signedIn={state === 'in'} onClose={() => setView(null)} />}
|
|
292
333
|
</div>
|
|
293
334
|
);
|
|
294
335
|
}
|