@1agh/maude 1.4.4 → 1.4.6

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 (47) hide show
  1. package/apps/studio/annotations-model.ts +33 -1
  2. package/apps/studio/api.ts +19 -0
  3. package/apps/studio/canvas-comment-mount.tsx +48 -3
  4. package/apps/studio/canvas-lib.tsx +5 -0
  5. package/apps/studio/canvas-shell.tsx +110 -14
  6. package/apps/studio/canvas-source-memo.ts +39 -0
  7. package/apps/studio/canvas-text-patch.ts +117 -0
  8. package/apps/studio/client/app.jsx +108 -60
  9. package/apps/studio/client/comments-overlay.css +10 -0
  10. package/apps/studio/client/file-tree.jsx +157 -0
  11. package/apps/studio/client/index.html +1 -1
  12. package/apps/studio/client/panels/DiffView.jsx +37 -8
  13. package/apps/studio/client/panels/GitPanel.jsx +2 -1
  14. package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
  15. package/apps/studio/client/styles/3-shell-maude.css +21 -9
  16. package/apps/studio/client/styles/4-components.css +4 -2
  17. package/apps/studio/collab/index.ts +9 -0
  18. package/apps/studio/collab/persistence.ts +49 -1
  19. package/apps/studio/comment-anchor.ts +116 -0
  20. package/apps/studio/comments-overlay.tsx +78 -35
  21. package/apps/studio/context.ts +1 -0
  22. package/apps/studio/cursors-overlay.tsx +181 -107
  23. package/apps/studio/dist/client.bundle.js +1086 -1086
  24. package/apps/studio/dist/comment-mount.js +2 -2
  25. package/apps/studio/dist/styles.css +1 -1
  26. package/apps/studio/hmr-broadcast.ts +59 -3
  27. package/apps/studio/http.ts +3 -0
  28. package/apps/studio/sync/accepted-cold-start.ts +95 -7
  29. package/apps/studio/sync/accepted-link.ts +165 -29
  30. package/apps/studio/sync/agent.ts +28 -2
  31. package/apps/studio/sync/comment-ledger.ts +229 -0
  32. package/apps/studio/sync/file-plane.ts +101 -2
  33. package/apps/studio/sync/index.ts +124 -17
  34. package/apps/studio/sync/journal-client.ts +9 -3
  35. package/apps/studio/sync/migrate-seed.ts +9 -2
  36. package/apps/studio/sync/projection.ts +114 -11
  37. package/apps/studio/sync/remote-docs.ts +3 -2
  38. package/apps/studio/sync/source-recovery.ts +67 -0
  39. package/apps/studio/sync/status.ts +11 -3
  40. package/apps/studio/sync/transaction-client.ts +49 -4
  41. package/apps/studio/use-collab.tsx +28 -1
  42. package/apps/studio/use-selection-set.tsx +13 -3
  43. package/apps/studio/use-undo-stack.tsx +22 -2
  44. package/apps/studio/whats-new.json +45 -0
  45. package/cli/lib/workspace-plan.mjs +10 -0
  46. package/package.json +8 -8
  47. 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-*`
@@ -13661,6 +13711,9 @@ function App() {
13661
13711
  classes: p.classes,
13662
13712
  bounds: p.bounds,
13663
13713
  html_excerpt: p.html_excerpt,
13714
+ // #134/#136 anchors — shape-checked server-side (api.ts commentsAdd).
13715
+ annotationId: p.annotationId,
13716
+ world: p.world,
13664
13717
  text: txt,
13665
13718
  },
13666
13719
  });
@@ -15709,15 +15762,8 @@ function App() {
15709
15762
  return (cloudManaged ? gitLoadCloudLog : gitLoadLog)(path);
15710
15763
  }}
15711
15764
  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
- }}
15765
+ historyRefresh={`${syncStatus?.appliedRevision ?? 0}:${projectHistoryRefresh}`}
15766
+ onRestoreVersion={(revision) => restoreProjectVersion(activePath, revision).then((r) => !!r.ok)}
15721
15767
  // Not through a cell's door: every browser editor proposes under the
15722
15768
  // studio's one credential there, so "your own action" is not
15723
15769
  // knowable and the hub refuses the route (studio-manifest.mjs).
@@ -16823,7 +16869,7 @@ function App() {
16823
16869
  // and was simply unreachable here. Found running S20 against a live
16824
16870
  // deployment; asserted in `team-project.e2e.ts` step 4b.
16825
16871
  onOpenChanges={
16826
- gitStatus?.repo || savingIsManaged ? () => setChangesOpen(true) : undefined
16872
+ gitStatus?.repo || savingIsManaged ? () => openRightPanel('changes') : undefined
16827
16873
  }
16828
16874
  version={cfg?.version}
16829
16875
  />
@@ -16966,10 +17012,12 @@ function App() {
16966
17012
  <DiffView
16967
17013
  target={diffTarget}
16968
17014
  cfg={cfg}
16969
- loadLog={gitLoadLog}
17015
+ loadLog={loadDiffLog}
16970
17016
  onClose={() => setDiffTarget(null)}
16971
- onRestore={async (file) => {
16972
- const res = await gitDiscard([file]);
17017
+ onRestore={async (file, version) => {
17018
+ const res = acceptedDiff
17019
+ ? await restoreProjectVersion(file, /^r\d+$/.test(version || '') ? Number(version.slice(1)) : NaN)
17020
+ : await gitDiscard([file]);
16973
17021
  if (res?.ok) setDiffTarget(null);
16974
17022
  else window.alert(res?.error || 'Could not restore that version. Try again.');
16975
17023
  }}
@@ -65,6 +65,16 @@
65
65
  opacity: 0.6;
66
66
  }
67
67
 
68
+ /* Detached — the comment's element or annotation is gone from this canvas. The
69
+ comment is kept at its last known place (never deleted by software, #134/#136).
70
+ A hollow, dashed pin: the state reads from the shape, not from colour alone,
71
+ and the number keeps full contrast. The aria-label says "detached" too. */
72
+ .cm-pin[data-detached='true'] {
73
+ background: var(--maude-chrome-bg-1, #faf6ef);
74
+ color: var(--maude-chrome-fg-0, #2a2520);
75
+ border: 1.5px dashed var(--maude-hud-accent, #6f63ef);
76
+ }
77
+
68
78
  .cm-pin[data-focused='true'] {
69
79
  /* Subtle ring — DS hard-edges discipline: no glow, just a hairline outline. */
70
80
  outline: 1px solid var(--maude-chrome-fg-0, #2a2520);
@@ -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>