@1agh/maude 1.4.3 → 1.4.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.
Files changed (34) hide show
  1. package/apps/studio/api.ts +4 -0
  2. package/apps/studio/canvas-comment-mount.tsx +13 -1
  3. package/apps/studio/canvas-shell.tsx +62 -13
  4. package/apps/studio/canvas-source-memo.ts +39 -0
  5. package/apps/studio/canvas-text-patch.ts +117 -0
  6. package/apps/studio/client/app.jsx +105 -60
  7. package/apps/studio/client/file-tree.jsx +157 -0
  8. package/apps/studio/client/index.html +1 -1
  9. package/apps/studio/client/panels/DiffView.jsx +37 -8
  10. package/apps/studio/client/panels/GitPanel.jsx +2 -1
  11. package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
  12. package/apps/studio/client/styles/3-shell-maude.css +21 -9
  13. package/apps/studio/client/styles/4-components.css +4 -2
  14. package/apps/studio/dist/client.bundle.js +1086 -1086
  15. package/apps/studio/dist/comment-mount.js +2 -2
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/hmr-broadcast.ts +59 -3
  18. package/apps/studio/http.ts +3 -0
  19. package/apps/studio/sync/accepted-cold-start.ts +35 -3
  20. package/apps/studio/sync/accepted-link.ts +165 -29
  21. package/apps/studio/sync/file-plane.ts +24 -1
  22. package/apps/studio/sync/index.ts +87 -17
  23. package/apps/studio/sync/journal-client.ts +9 -3
  24. package/apps/studio/sync/projection.ts +114 -11
  25. package/apps/studio/sync/remote-docs.ts +3 -2
  26. package/apps/studio/sync/source-recovery.ts +67 -0
  27. package/apps/studio/sync/status.ts +11 -3
  28. package/apps/studio/sync/transaction-client.ts +49 -4
  29. package/apps/studio/use-selection-set.tsx +9 -3
  30. package/apps/studio/use-undo-stack.tsx +22 -2
  31. package/apps/studio/whats-new.json +9 -0
  32. package/cli/lib/workspace-plan.mjs +10 -0
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +21 -0
@@ -46,6 +46,7 @@ import {
46
46
  recordBrowserExport,
47
47
  } from './export-lane.js';
48
48
  import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
49
+ import { FileTree, FileTreeItem } from './file-tree.jsx';
49
50
  import { useTreeDrag } from './use-tree-drag.js';
50
51
  import ChatPanel from './panels/ChatPanel.jsx';
51
52
  import DiffView from './panels/DiffView.jsx';
@@ -2199,10 +2200,8 @@ function DirRow({ name, depth, defaultOpen, children, dirPath, drag, menu }) {
2199
2200
  </button>
2200
2201
  );
2201
2202
  return (
2202
- <Fragment>
2203
- {menu ? (
2204
- <div className="st-row-wrap" role="none">
2205
- {row}
2203
+ <FileTreeItem label={name} row={row} expanded={open} busy={isBusy}
2204
+ onToggle={() => setOpen(v => !v)} actions={menu ? (
2206
2205
  <button
2207
2206
  type="button"
2208
2207
  className="st-row-menu-btn"
@@ -2214,12 +2213,9 @@ function DirRow({ name, depth, defaultOpen, children, dirPath, drag, menu }) {
2214
2213
  >
2215
2214
  <Icon d="M12 6a1 1 0 100-2 1 1 0 000 2zM12 13a1 1 0 100-2 1 1 0 000 2zM12 20a1 1 0 100-2 1 1 0 000 2z" size={12} />
2216
2215
  </button>
2217
- </div>
2218
- ) : (
2219
- row
2220
- )}
2216
+ ) : null}>
2221
2217
  {open && children}
2222
- </Fragment>
2218
+ </FileTreeItem>
2223
2219
  );
2224
2220
  }
2225
2221
 
@@ -2230,12 +2226,11 @@ function DirRow({ name, depth, defaultOpen, children, dirPath, drag, menu }) {
2230
2226
  function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, children }) {
2231
2227
  const [open, setOpen] = useState(defaultOpen);
2232
2228
  return (
2233
- <Fragment>
2229
+ <FileTreeItem label={name} expanded={open} selected={active}
2230
+ onToggle={() => setOpen(v => !v)} row={
2234
2231
  <div
2235
2232
  className={'st-row st-ds-folder' + (active ? ' is-sel' : '')}
2236
2233
  style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
2237
- role="treeitem"
2238
- aria-expanded={open}
2239
2234
  >
2240
2235
  <button
2241
2236
  type="button"
@@ -2249,6 +2244,8 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
2249
2244
  <button
2250
2245
  type="button"
2251
2246
  className="st-ds-open"
2247
+ data-tree-primary=""
2248
+ tabIndex={-1}
2252
2249
  onClick={() => onOpenSystem(dsName)}
2253
2250
  aria-label={`Open ${dsName} design system view`}
2254
2251
  title="Open the design system view"
@@ -2258,9 +2255,9 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
2258
2255
  </span>
2259
2256
  <span className="st-row-name">{name}</span>
2260
2257
  </button>
2261
- </div>
2258
+ </div>}>
2262
2259
  {open && children}
2263
- </Fragment>
2260
+ </FileTreeItem>
2264
2261
  );
2265
2262
  }
2266
2263
 
@@ -2392,12 +2389,10 @@ function FileRow({
2392
2389
  {oc > 0 && <span className="st-row-badge">{oc}</span>}
2393
2390
  </button>
2394
2391
  );
2395
- if (!canDelete && !canShare) return row;
2396
- // Sibling menu/delete buttons (can't nest a button in the row button). The
2397
- // wrapper is presentational so the treeitem stays the tree's child for a11y.
2392
+ // The named treeitem owns the primary action and its independent buttons.
2398
2393
  return (
2399
- <div className="st-row-wrap" role="none">
2400
- {row}
2394
+ <FileTreeItem label={label} row={row} selected={isSel} disabled={inert} busy={isBusy}
2395
+ actions={<>
2401
2396
  {canShare && (
2402
2397
  <button
2403
2398
  type="button"
@@ -2434,7 +2429,7 @@ function FileRow({
2434
2429
  <Icon d="M3 6h18 M8 6V4h8v2 M6 6l1 14h10l1-14 M10 11v6 M14 11v6" size={12} />
2435
2430
  </button>
2436
2431
  )}
2437
- </div>
2432
+ </>} />
2438
2433
  );
2439
2434
  }
2440
2435
 
@@ -2557,10 +2552,9 @@ function CanvasRow({
2557
2552
  </button>
2558
2553
  );
2559
2554
  return (
2560
- <Fragment>
2561
- {canShare ? (
2562
- <div className="st-row-wrap" role="none">
2563
- {row}
2555
+ <FileTreeItem label={displayName(primary.name)} row={row} selected={isSel}
2556
+ expanded={open} busy={isBusy} onToggle={() => setOpenState(v => !v)}
2557
+ actions={canShare ? (
2564
2558
  <button
2565
2559
  type="button"
2566
2560
  className="st-row-menu-btn"
@@ -2572,10 +2566,7 @@ function CanvasRow({
2572
2566
  >
2573
2567
  <Icon d="M12 6a1 1 0 100-2 1 1 0 000 2zM12 13a1 1 0 100-2 1 1 0 000 2zM12 20a1 1 0 100-2 1 1 0 000 2z" size={12} />
2574
2568
  </button>
2575
- </div>
2576
- ) : (
2577
- row
2578
- )}
2569
+ ) : null}>
2579
2570
  {open &&
2580
2571
  sidecars.map((sc) => (
2581
2572
  <FileRow
@@ -2592,7 +2583,7 @@ function CanvasRow({
2592
2583
  sidecar
2593
2584
  />
2594
2585
  ))}
2595
- </Fragment>
2586
+ </FileTreeItem>
2596
2587
  );
2597
2588
  }
2598
2589
 
@@ -3201,7 +3192,7 @@ function Sidebar({
3201
3192
  </div>
3202
3193
  </div>
3203
3194
 
3204
- <div className="st-tree" role="tree" aria-label="Project file tree" data-testid="canvas-list">
3195
+ <FileTree aria-label="Project file tree" data-testid="canvas-list">
3205
3196
  {filteredGroups.map((g) => {
3206
3197
  // Hide gitignored runtime / orphan-only project sections by default.
3207
3198
  // Active search overrides — if the user typed a query, they want hits
@@ -3235,7 +3226,8 @@ function Sidebar({
3235
3226
  const rootDropHandlers = canDropOnRoot ? treeDrag.dropProps(g.fullPath, true) : {};
3236
3227
  const isRootOver = treeDrag.overDir === g.fullPath;
3237
3228
  return (
3238
- <div className="st-tree-section" key={g.label}>
3229
+ <FileTreeItem className="st-tree-section" key={g.label} label={meta.title}
3230
+ expanded={sectionOpen} onToggle={() => onToggleSection(g.label, defaultOpen)} row={
3239
3231
  <button
3240
3232
  type="button"
3241
3233
  className={'st-tree-sec-hd' + (isRootOver ? ' is-drop-target' : '')}
@@ -3249,7 +3241,7 @@ function Sidebar({
3249
3241
  <StIcon name="chevron-right" className={'st-chev' + (sectionOpen ? ' is-open' : '')} size={13} />
3250
3242
  <span className="st-sec-name">{meta.title}</span>
3251
3243
  {pill && <span className="st-pill">{pill}</span>}
3252
- </button>
3244
+ </button>}>
3253
3245
  {sectionOpen &&
3254
3246
  (hasItems ? (
3255
3247
  <Tree
@@ -3276,10 +3268,10 @@ function Sidebar({
3276
3268
  ) : (
3277
3269
  <div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
3278
3270
  ))}
3279
- </div>
3271
+ </FileTreeItem>
3280
3272
  );
3281
3273
  })}
3282
- </div>
3274
+ </FileTree>
3283
3275
  <TreeRowMenu
3284
3276
  state={rowMenu.state}
3285
3277
  onClose={rowMenu.close}
@@ -4386,7 +4378,6 @@ function Menubar({
4386
4378
  return (
4387
4379
  <header
4388
4380
  className="st-menubar"
4389
- role="menubar"
4390
4381
  aria-label="Application menubar"
4391
4382
  data-testid="menubar"
4392
4383
  >
@@ -4428,7 +4419,7 @@ function Menubar({
4428
4419
  ) : null}
4429
4420
  </span>
4430
4421
  ) : null}
4431
- <nav className="st-menus" aria-label="Application menus" data-tour="menus">
4422
+ <nav className="st-menus" role="menubar" aria-label="Application menus" data-tour="menus">
4432
4423
  {MENU_NAMES.map((name) => {
4433
4424
  const key = name.toLowerCase();
4434
4425
  const hasDropdown = DROPDOWN_MENUS.includes(key);
@@ -4794,6 +4785,7 @@ function Viewport({
4794
4785
  key={`${t.path}#${canvasReloadNonce}`}
4795
4786
  ref={(el) => registerIframe(t.path, el)}
4796
4787
  src={stableSrc(t.path)}
4788
+ title={`Canvas: ${t.path}`}
4797
4789
  className={t.path === activePath ? 'active' : ''}
4798
4790
  data-path={t.path}
4799
4791
  data-testid={t.path === activePath ? 'canvas-frame' : undefined}
@@ -6589,6 +6581,25 @@ function GridTracksEditor({ label, tracks, editable, onChange }) {
6589
6581
  );
6590
6582
  }
6591
6583
 
6584
+ // Writes whose success ends in a `record-edit` post to the canvas. The canvas
6585
+ // asks (`undo-barrier`) before Cmd+Z so it never undoes past an edit whose
6586
+ // record is still on its way (see afterShellRecords in canvas-shell.tsx).
6587
+ const recordableWrites = { pending: 0, waiters: [] };
6588
+ function trackRecordableWrite(promise) {
6589
+ recordableWrites.pending += 1;
6590
+ const settle = () => {
6591
+ recordableWrites.pending -= 1;
6592
+ if (recordableWrites.pending === 0) for (const w of recordableWrites.waiters.splice(0)) w();
6593
+ };
6594
+ promise.then(settle, settle);
6595
+ return promise;
6596
+ }
6597
+ function afterRecordableWrites() {
6598
+ return recordableWrites.pending === 0
6599
+ ? Promise.resolve()
6600
+ : new Promise((resolve) => recordableWrites.waiters.push(resolve));
6601
+ }
6602
+
6592
6603
  function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo, mode, onSetMode }) {
6593
6604
  const editable = !!el.id;
6594
6605
  const computed = el.computed || {};
@@ -6704,13 +6715,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6704
6715
  // Serialized so the stack keeps edit order; a failed write records nothing.
6705
6716
  const writeChainRef = useRef(Promise.resolve());
6706
6717
  const writeAndRecord = (url, payload, key, op, prop, fallbackBefore, after) => {
6707
- writeChainRef.current = writeChainRef.current.then(async () => {
6708
- const j = await post(url, payload, key);
6709
- if (!j?.ok) return;
6710
- const before = Object.hasOwn(j, 'previous') ? j.previous : fallbackBefore;
6711
- if ((before ?? null) === (after ?? null)) return; // nothing changed to undo
6712
- record(op, prop, before, after);
6713
- });
6718
+ writeChainRef.current = trackRecordableWrite(
6719
+ writeChainRef.current.then(async () => {
6720
+ const j = await post(url, payload, key);
6721
+ if (!j?.ok) return;
6722
+ const before = Object.hasOwn(j, 'previous') ? j.previous : fallbackBefore;
6723
+ if ((before ?? null) === (after ?? null)) return; // nothing changed to undo
6724
+ record(op, prop, before, after);
6725
+ })
6726
+ );
6714
6727
  };
6715
6728
  // Optimistic preview: nudge the live element so the change shows before the
6716
6729
  // edit → HMR reload lands. `value` null = remove (reset path). No-op when the
@@ -9277,7 +9290,7 @@ function InspectorPanel({
9277
9290
  // HMR reload re-posts the tree with the new label.
9278
9291
  const renameLayer = (node, value) => {
9279
9292
  if (!canvasFile || !node?.id) return;
9280
- fetch('/_api/edit-attr', {
9293
+ trackRecordableWrite(fetch('/_api/edit-attr', {
9281
9294
  method: 'POST',
9282
9295
  headers: { 'content-type': 'application/json' },
9283
9296
  body: JSON.stringify({ canvas: canvasFile, id: node.id, attr: 'data-dc-element', value }),
@@ -9294,7 +9307,7 @@ function InspectorPanel({
9294
9307
  after: value,
9295
9308
  });
9296
9309
  })
9297
- .catch(() => {});
9310
+ .catch(() => {}));
9298
9311
  };
9299
9312
  const handleReorder = onReorderLayer
9300
9313
  ? (dragged, ref, position) => {
@@ -10779,7 +10792,7 @@ function App() {
10779
10792
  openPanelExclusive('tree');
10780
10793
  setTimeout(() => {
10781
10794
  try {
10782
- document.querySelector('.st-sidebar [role="treeitem"]')?.click();
10795
+ document.querySelector('.st-sidebar [data-testid^="canvas-row-"]')?.click();
10783
10796
  } catch {}
10784
10797
  }, 80);
10785
10798
  }
@@ -12245,6 +12258,7 @@ function App() {
12245
12258
  // actions (who did what, when), not Git commits; a row previews as `r<rev>`
12246
12259
  // through the same version preview. `'legacy'` means "use Git history".
12247
12260
  const [projectHistoryOn, setProjectHistoryOn] = useState(false);
12261
+ const [projectHistoryRefresh, setProjectHistoryRefresh] = useState(0);
12248
12262
  const loadAcceptedLog = useCallback(async (path) => {
12249
12263
  try {
12250
12264
  const qs =
@@ -12273,6 +12287,31 @@ function App() {
12273
12287
  }
12274
12288
  }, []);
12275
12289
 
12290
+ // An accepted preview must stay on project history even if loading fails:
12291
+ // switching to Git would turn Restore into an unrelated discard operation.
12292
+ const acceptedDiff = /^r\d+$/.test(diffTarget?.beforeSha || '');
12293
+ const loadDiffLog = useCallback(async (path) => {
12294
+ if (!acceptedDiff) return gitLoadLog(path);
12295
+ const entries = await loadAcceptedLog(path);
12296
+ return Array.isArray(entries) ? entries : [];
12297
+ }, [acceptedDiff, gitLoadLog, loadAcceptedLog]);
12298
+ const restoreProjectVersion = useCallback(async (path, revision) => {
12299
+ if (!path || !Number.isSafeInteger(revision) || revision < 0) {
12300
+ return { ok: false, error: 'Choose a saved project version to restore.' };
12301
+ }
12302
+ const r = await fetch('/_api/project/restore', {
12303
+ method: 'POST',
12304
+ headers: { 'Content-Type': 'application/json' },
12305
+ body: JSON.stringify({ path, revision }),
12306
+ }).catch(() => null);
12307
+ const result = r ? await r.json().catch(() => null) : null;
12308
+ if (r?.ok && result?.ok) {
12309
+ setProjectHistoryRefresh((version) => version + 1);
12310
+ return result;
12311
+ }
12312
+ return { ok: false, error: result?.error };
12313
+ }, []);
12314
+
12276
12315
  // Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
12277
12316
  // and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
12278
12317
  // live with the WS broadcast.
@@ -13097,7 +13136,8 @@ function App() {
13097
13136
  m.dgn === 'select-set' ||
13098
13137
  m.dgn === 'clear-select' ||
13099
13138
  m.dgn === 'edit-text' ||
13100
- m.dgn === 'apply-edit'
13139
+ m.dgn === 'apply-edit' ||
13140
+ m.dgn === 'undo-barrier'
13101
13141
  ) {
13102
13142
  const activeWin =
13103
13143
  activePath && activePath !== SYSTEM_TAB
@@ -13175,6 +13215,16 @@ function App() {
13175
13215
  if (!j.ok) revert(j.error || "this element can't be edited inline");
13176
13216
  })
13177
13217
  .catch(() => revert('network error'));
13218
+ } else if (m.dgn === 'undo-barrier' && typeof m.requestId === 'string') {
13219
+ // Answer once every write that will post a `record-edit` has settled
13220
+ // (and so posted it — window messages arrive in order), so the canvas's
13221
+ // Cmd+Z sees the edit that is on screen. Read-only; nothing is written.
13222
+ const source = e.source;
13223
+ Promise.all([editApplyChainRef.current.catch(() => {}), afterRecordableWrites()]).then(() => {
13224
+ try {
13225
+ source?.postMessage({ dgn: 'undo-barrier-ok', requestId: m.requestId }, '*');
13226
+ } catch {}
13227
+ });
13178
13228
  } else if (m.dgn === 'apply-edit' && m.id && (m.op === 'css' || m.op === 'text' || m.op === 'attr')) {
13179
13229
  // Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
13180
13230
  // `edit-source` command can't call the main-origin-only `/_api/edit-*`
@@ -15709,15 +15759,8 @@ function App() {
15709
15759
  return (cloudManaged ? gitLoadCloudLog : gitLoadLog)(path);
15710
15760
  }}
15711
15761
  historySource={projectHistoryOn ? 'project' : cloudManaged ? 'cloud' : 'local'}
15712
- onRestoreVersion={async (revision) => {
15713
- const r = await fetch('/_api/project/restore', {
15714
- method: 'POST',
15715
- headers: { 'Content-Type': 'application/json' },
15716
- body: JSON.stringify({ path: activePath, revision }),
15717
- }).catch(() => null);
15718
- const j = r ? await r.json().catch(() => null) : null;
15719
- return !!j?.ok;
15720
- }}
15762
+ historyRefresh={`${syncStatus?.appliedRevision ?? 0}:${projectHistoryRefresh}`}
15763
+ onRestoreVersion={(revision) => restoreProjectVersion(activePath, revision).then((r) => !!r.ok)}
15721
15764
  // Not through a cell's door: every browser editor proposes under the
15722
15765
  // studio's one credential there, so "your own action" is not
15723
15766
  // knowable and the hub refuses the route (studio-manifest.mjs).
@@ -16823,7 +16866,7 @@ function App() {
16823
16866
  // and was simply unreachable here. Found running S20 against a live
16824
16867
  // deployment; asserted in `team-project.e2e.ts` step 4b.
16825
16868
  onOpenChanges={
16826
- gitStatus?.repo || savingIsManaged ? () => setChangesOpen(true) : undefined
16869
+ gitStatus?.repo || savingIsManaged ? () => openRightPanel('changes') : undefined
16827
16870
  }
16828
16871
  version={cfg?.version}
16829
16872
  />
@@ -16966,10 +17009,12 @@ function App() {
16966
17009
  <DiffView
16967
17010
  target={diffTarget}
16968
17011
  cfg={cfg}
16969
- loadLog={gitLoadLog}
17012
+ loadLog={loadDiffLog}
16970
17013
  onClose={() => setDiffTarget(null)}
16971
- onRestore={async (file) => {
16972
- const res = await gitDiscard([file]);
17014
+ onRestore={async (file, version) => {
17015
+ const res = acceptedDiff
17016
+ ? await restoreProjectVersion(file, /^r\d+$/.test(version || '') ? Number(version.slice(1)) : NaN)
17017
+ : await gitDiscard([file]);
16973
17018
  if (res?.ok) setDiffTarget(null);
16974
17019
  else window.alert(res?.error || 'Could not restore that version. Try again.');
16975
17020
  }}
@@ -0,0 +1,157 @@
1
+ import { cloneElement, useLayoutEffect, useRef } from 'react';
2
+
3
+ const ITEM = '[role="treeitem"]';
4
+
5
+ /** One tree tab stop. Arrow focus does not open files or change selection. */
6
+ export function FileTree({ children, ...props }) {
7
+ const ref = useRef(null);
8
+ useLayoutEffect(() => {
9
+ const tree = ref.current;
10
+ const items = () => Array.from(tree.querySelectorAll(ITEM));
11
+ const focusItem = (item, focus = true) => {
12
+ if (!item) return;
13
+ for (const row of items()) row.tabIndex = row === item ? 0 : -1;
14
+ if (focus) item.focus();
15
+ };
16
+ const repairTabStop = () => {
17
+ const rows = items();
18
+ const current = rows.find((row) => row.tabIndex === 0);
19
+ focusItem(
20
+ current || rows.find((row) => row.getAttribute('aria-selected') === 'true') || rows[0],
21
+ tree.ownerDocument.activeElement === tree
22
+ );
23
+ tree.tabIndex = rows.length ? -1 : 0;
24
+ };
25
+ const onFocus = (event) => {
26
+ const item = event.target.closest?.(ITEM);
27
+ if (item && tree.contains(item)) focusItem(item, false);
28
+ };
29
+ const onKey = (event) => {
30
+ const item = event.target.closest?.(ITEM);
31
+ if (!item || !tree.contains(item) || event.metaKey || event.ctrlKey || event.altKey) return;
32
+ const row = item.querySelector(':scope > .st-row-wrap');
33
+ const primary = row?.querySelector('[data-tree-primary]');
34
+ // Extra buttons keep their native Enter/Space/Tab behavior. Escape
35
+ // returns from a row action to the tree; open menus own their own keys.
36
+ if (event.target !== item && event.target !== primary) {
37
+ if (event.key === 'Escape') {
38
+ event.preventDefault();
39
+ focusItem(item);
40
+ }
41
+ return;
42
+ }
43
+ const rows = items();
44
+ const index = rows.indexOf(item);
45
+ const expanded = item.getAttribute('aria-expanded');
46
+ const toggle = () => item.dispatchEvent(new Event('tree-toggle'));
47
+ switch (event.key) {
48
+ case 'ArrowDown':
49
+ focusItem(rows[Math.min(index + 1, rows.length - 1)]);
50
+ break;
51
+ case 'ArrowUp':
52
+ focusItem(rows[Math.max(index - 1, 0)]);
53
+ break;
54
+ case 'Home':
55
+ focusItem(rows[0]);
56
+ break;
57
+ case 'End':
58
+ focusItem(rows.at(-1));
59
+ break;
60
+ case 'ArrowRight':
61
+ if (expanded === 'false') toggle();
62
+ else if (expanded === 'true')
63
+ focusItem(item.querySelector(':scope > [role="group"] ' + ITEM));
64
+ break;
65
+ case 'ArrowLeft':
66
+ if (expanded === 'true') toggle();
67
+ else focusItem(item.parentElement?.closest(ITEM));
68
+ break;
69
+ case 'Enter':
70
+ case ' ':
71
+ if (event.target !== item) return;
72
+ if (item.getAttribute('aria-disabled') !== 'true') primary?.click();
73
+ break;
74
+ default: {
75
+ if (event.key.length !== 1 || event.shiftKey) return;
76
+ const ordered = [...rows.slice(index + 1), ...rows.slice(0, index + 1)];
77
+ focusItem(
78
+ ordered.find((candidate) =>
79
+ candidate
80
+ .getAttribute('aria-label')
81
+ ?.toLocaleLowerCase()
82
+ .startsWith(event.key.toLocaleLowerCase())
83
+ )
84
+ );
85
+ }
86
+ }
87
+ event.preventDefault();
88
+ event.stopPropagation();
89
+ };
90
+ repairTabStop();
91
+ const observer = new MutationObserver(repairTabStop);
92
+ observer.observe(tree, { childList: true, subtree: true });
93
+ tree.addEventListener('focusin', onFocus);
94
+ tree.addEventListener('keydown', onKey);
95
+ return () => {
96
+ observer.disconnect();
97
+ tree.removeEventListener('focusin', onFocus);
98
+ tree.removeEventListener('keydown', onKey);
99
+ };
100
+ }, []);
101
+ return (
102
+ <div {...props} ref={ref} className="st-tree" role="tree">
103
+ {children}
104
+ </div>
105
+ );
106
+ }
107
+
108
+ /** Row actions belong to the item; descendants belong to its named group.
109
+ * Native buttons remain the activation targets, never nested in a button. */
110
+ export function FileTreeItem({
111
+ label,
112
+ row,
113
+ actions,
114
+ expanded,
115
+ selected,
116
+ disabled,
117
+ busy,
118
+ onToggle,
119
+ children,
120
+ className = '',
121
+ }) {
122
+ const ref = useRef(null);
123
+ useLayoutEffect(() => {
124
+ const item = ref.current;
125
+ const toggle = () => onToggle?.();
126
+ item.addEventListener('tree-toggle', toggle);
127
+ return () => item.removeEventListener('tree-toggle', toggle);
128
+ }, [onToggle]);
129
+ const primary =
130
+ row.type === 'button'
131
+ ? cloneElement(row, {
132
+ role: undefined,
133
+ 'aria-selected': undefined,
134
+ tabIndex: -1,
135
+ 'data-tree-primary': '',
136
+ })
137
+ : row;
138
+ return (
139
+ <div
140
+ ref={ref}
141
+ className={`st-tree-item ${className}`}
142
+ role="treeitem"
143
+ tabIndex={-1}
144
+ aria-label={label}
145
+ aria-expanded={expanded}
146
+ aria-selected={selected}
147
+ aria-disabled={disabled || undefined}
148
+ aria-busy={busy || undefined}
149
+ >
150
+ <div className="st-row-wrap">
151
+ {primary}
152
+ {actions}
153
+ </div>
154
+ {children && <div role="group">{children}</div>}
155
+ </div>
156
+ );
157
+ }
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html data-theme="dark">
2
+ <html lang="en" data-theme="dark">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <title>maude · studio</title>
@@ -170,7 +170,9 @@ function CanvasView({ src, view, setView, label }) {
170
170
  className="dv-frame-wrap"
171
171
  style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}
172
172
  >
173
- <iframe className="dv-frame" src={src} title={label} />
173
+ {/* A read-only preview: out of the tab order, so the modal's focus
174
+ never falls into a document whose keys the shell cannot see. */}
175
+ <iframe className="dv-frame" src={src} title={label} tabIndex={-1} />
174
176
  </div>
175
177
  ) : (
176
178
  <div className="dv-thumb-note">No saved version of this canvas to compare yet.</div>
@@ -236,6 +238,7 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
236
238
  // HEAD), then re-pointable in-place via the "Saved version" picker.
237
239
  const tgtFile = target?.file;
238
240
  const tgtBeforeSha = target?.beforeSha;
241
+ const acceptedPreview = /^r\d+$/.test(tgtBeforeSha || '');
239
242
  const [beforeSha, setBeforeSha] = useState(tgtBeforeSha || 'HEAD');
240
243
  const [versions, setVersions] = useState(null); // per-file saved versions, or null while loading
241
244
 
@@ -253,7 +256,7 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
253
256
  let cancelled = false;
254
257
  (async () => {
255
258
  const entries = (await loadLog(tgtFile)) || [];
256
- if (!cancelled) setVersions(entries);
259
+ if (!cancelled) setVersions(Array.isArray(entries) ? entries : []);
257
260
  })();
258
261
  return () => {
259
262
  cancelled = true;
@@ -263,16 +266,38 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
263
266
  useEffect(() => {
264
267
  if (!target) return;
265
268
  const opener = document.activeElement;
269
+ // A modal keeps the keyboard inside it (S19): Tab / Shift+Tab cycle the
270
+ // sheet's own controls, and Escape is taken before anything behind it.
271
+ const controls = () =>
272
+ [
273
+ ...(sheetRef.current?.querySelectorAll(
274
+ 'button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])'
275
+ ) ?? []),
276
+ ].filter((el) => !el.closest('[hidden]'));
266
277
  const onKey = (e) => {
267
278
  if (e.key === 'Escape') {
268
279
  e.preventDefault();
280
+ e.stopImmediatePropagation();
269
281
  onClose();
282
+ } else if (e.key === 'Tab') {
283
+ const items = controls();
284
+ if (!items.length) return;
285
+ const inside = sheetRef.current?.contains(document.activeElement);
286
+ const first = items[0];
287
+ const last = items[items.length - 1];
288
+ if (e.shiftKey && (!inside || document.activeElement === first || document.activeElement === sheetRef.current)) {
289
+ e.preventDefault();
290
+ last.focus();
291
+ } else if (!e.shiftKey && (!inside || document.activeElement === last)) {
292
+ e.preventDefault();
293
+ first.focus();
294
+ }
270
295
  }
271
296
  };
272
- window.addEventListener('keydown', onKey);
297
+ document.addEventListener('keydown', onKey, true);
273
298
  sheetRef.current?.focus();
274
299
  return () => {
275
- window.removeEventListener('keydown', onKey);
300
+ document.removeEventListener('keydown', onKey, true);
276
301
  if (opener instanceof HTMLElement) opener.focus();
277
302
  };
278
303
  }, [target, onClose]);
@@ -335,12 +360,13 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
335
360
  <label className="dv-verpick">
336
361
  <span className="dv-verpick-lbl">Compare against</span>
337
362
  <select
363
+ data-testid="history-preview-version"
338
364
  className="dv-verpick-sel"
339
365
  value={beforeSha}
340
366
  onChange={(e) => setBeforeSha(e.target.value)}
341
367
  aria-label={`Saved version of ${baseName(file)} to compare against`}
342
368
  >
343
- <option value="HEAD">Last saved</option>
369
+ {!acceptedPreview && <option value="HEAD">Last saved</option>}
344
370
  {versions.map((v) => (
345
371
  <option key={v.sha} value={v.sha}>
346
372
  {`${v.message || 'Saved version'} · ${timeAgo(v.date)}`}
@@ -506,7 +532,7 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
506
532
  <div className="dv-col">
507
533
  <div className="dv-col-hd">
508
534
  <span className="dv-col-tag is-after">Your version</span>
509
- <span className="dv-col-who">now · unsaved</span>
535
+ <span className="dv-col-who">{acceptedPreview ? 'now · working copy' : 'now · unsaved'}</span>
510
536
  </div>
511
537
  <div className="dv-thumb is-after">
512
538
  <CanvasView
@@ -559,17 +585,20 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
559
585
  <button
560
586
  type="button"
561
587
  className="btn btn--ghost btn--sm"
588
+ data-testid="history-preview-restore"
562
589
  disabled={busy}
563
590
  onClick={async () => {
564
591
  if (
565
592
  !window.confirm(
566
- `Restore the saved version of “${baseName(file)}”? Your unsaved changes to it are discarded.`
593
+ acceptedPreview
594
+ ? `Restore version ${beforeSha.slice(1)} of “${baseName(file)}” as a new saved version?`
595
+ : `Restore the saved version of “${baseName(file)}”? Your unsaved changes to it are discarded.`
567
596
  )
568
597
  )
569
598
  return;
570
599
  setBusy(true);
571
600
  try {
572
- await onRestore(file);
601
+ await onRestore(file, beforeSha);
573
602
  } finally {
574
603
  setBusy(false);
575
604
  }
@@ -182,6 +182,7 @@ export default function GitPanel({
182
182
  // is swapped at the same call site; this prop is only how the panel knows
183
183
  // which repo the rows it just received describe, so it can name it.
184
184
  historySource = 'local',
185
+ historyRefresh = 0, // applied project revisions and restores invalidate this list
185
186
  // `{ branch, project, hubHost }` — what the cell reported alongside the log.
186
187
  // Null until the first successful cloud load.
187
188
  cloudHistory = null,
@@ -465,7 +466,7 @@ export default function GitPanel({
465
466
  useEffect(() => {
466
467
  setLogFailed(false);
467
468
  setLogScope(undefined);
468
- }, [historySource]);
469
+ }, [historySource, historyRefresh]);
469
470
 
470
471
  function toggleAll() {
471
472
  setUnchecked((prev) => (prev.size === 0 ? new Set(files.map((f) => f.path)) : new Set()));