@1agh/maude 1.4.6 → 1.5.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.
Files changed (100) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +534 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops-merge.ts +15 -0
  21. package/apps/studio/annotations/ops.ts +499 -0
  22. package/apps/studio/annotations/registry.ts +159 -0
  23. package/apps/studio/annotations/replica.ts +264 -0
  24. package/apps/studio/annotations/scene.ts +235 -0
  25. package/apps/studio/annotations/schema.ts +188 -0
  26. package/apps/studio/annotations/types.ts +77 -0
  27. package/apps/studio/annotations/ui/board.ts +126 -0
  28. package/apps/studio/annotations/ui/containment.ts +118 -0
  29. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  30. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  31. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  32. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  33. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  34. package/apps/studio/annotations/ui/render-model.ts +264 -0
  35. package/apps/studio/annotations/ui/scene.tsx +63 -0
  36. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  37. package/apps/studio/annotations/ui/text-session.ts +140 -0
  38. package/apps/studio/annotations/ui/text-style.ts +111 -0
  39. package/apps/studio/annotations/ui/world.ts +47 -0
  40. package/apps/studio/annotations/v1-adapter.ts +428 -0
  41. package/apps/studio/annotations-align.ts +21 -6
  42. package/apps/studio/annotations-bindings.ts +2 -0
  43. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  44. package/apps/studio/annotations-groups.ts +3 -0
  45. package/apps/studio/annotations-layer.tsx +923 -1925
  46. package/apps/studio/annotations-model.ts +65 -4
  47. package/apps/studio/annotations-sync.ts +4 -47
  48. package/apps/studio/api.ts +249 -69
  49. package/apps/studio/bin/_import-figma.mjs +22 -12
  50. package/apps/studio/bin/annotate.mjs +331 -838
  51. package/apps/studio/bin/annotate.sh +4 -4
  52. package/apps/studio/bin/perf.sh +21 -7
  53. package/apps/studio/bin/read-annotations.mjs +184 -666
  54. package/apps/studio/bin/read-annotations.sh +9 -5
  55. package/apps/studio/canvas-artifacts.ts +9 -0
  56. package/apps/studio/canvas-lib.tsx +13 -1
  57. package/apps/studio/canvas-shell.tsx +6 -0
  58. package/apps/studio/client/app.jsx +98 -35
  59. package/apps/studio/client/hmr.mjs +1 -1
  60. package/apps/studio/client/panels/git-grouping.js +2 -2
  61. package/apps/studio/client/tree-expansion.js +217 -0
  62. package/apps/studio/collab/index.ts +49 -5
  63. package/apps/studio/collab/persistence.ts +31 -10
  64. package/apps/studio/collab/registry.ts +64 -26
  65. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  66. package/apps/studio/cursors-overlay.tsx +158 -4
  67. package/apps/studio/dist/client.bundle.js +850 -850
  68. package/apps/studio/dist/comment-mount.js +2 -2
  69. package/apps/studio/figma/to-strokes.ts +31 -10
  70. package/apps/studio/git/endpoints.ts +1 -1
  71. package/apps/studio/git/service.ts +1 -1
  72. package/apps/studio/git/watch.ts +1 -1
  73. package/apps/studio/http.ts +90 -15
  74. package/apps/studio/server.ts +16 -0
  75. package/apps/studio/sync/accepted-cold-start.ts +12 -4
  76. package/apps/studio/sync/agent.ts +8 -4
  77. package/apps/studio/sync/codec.ts +96 -40
  78. package/apps/studio/sync/file-membership.ts +12 -2
  79. package/apps/studio/sync/file-plane.ts +1 -1
  80. package/apps/studio/sync/index.ts +38 -16
  81. package/apps/studio/sync/journal-client.ts +5 -0
  82. package/apps/studio/sync/limits.ts +7 -2
  83. package/apps/studio/sync/migrate-seed.ts +8 -5
  84. package/apps/studio/sync/projection.ts +7 -2
  85. package/apps/studio/sync/remote-docs.ts +34 -0
  86. package/apps/studio/sync/writer-registry.ts +10 -0
  87. package/apps/studio/text-caret.ts +11 -2
  88. package/apps/studio/tree-state.ts +45 -0
  89. package/apps/studio/undo-stack.ts +2 -2
  90. package/apps/studio/use-annotation-resize.tsx +48 -23
  91. package/apps/studio/use-annotation-selection.tsx +9 -2
  92. package/apps/studio/use-collab.tsx +76 -0
  93. package/apps/studio/whats-new.json +27 -0
  94. package/cli/lib/design-link.mjs +5 -1
  95. package/cli/lib/gitignore-block.mjs +1 -1
  96. package/cli/lib/gitignore-drift.mjs +2 -1
  97. package/package.json +9 -8
  98. package/plugins/design/templates/brief-board.tsx.template +1 -1
  99. package/apps/studio/annotation-edit-base.ts +0 -36
  100. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env bash
2
- # read-annotations.sh — thin wrapper around read-annotations.mjs (the headless
3
- # SVG → JSON annotation reader). Reached via `maude design read-annotations`
4
- # (DDR-062), never a raw bin path. The reader is zero-dep Node, so we exec under
5
- # `node` (always present — maude itself runs on Node), not Bun.
2
+ # read-annotations.sh — thin wrapper around read-annotations.mjs (the AI read
3
+ # verb over the v2 board). Reached via `maude design read-annotations`
4
+ # (DDR-062), never a raw bin path.
6
5
  #
7
6
  # Usage:
8
- # read-annotations.sh <rel-path> [--root <repo>] [--canvas-state <path>]
7
+ # read-annotations.sh <rel-path> [--in <id>] [--type <t,…>] [--rects <path>] [--graph] [--full]
9
8
  # (see read-annotations.mjs --help for the full contract)
10
9
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ # DDR-242 — the v2 board is read through the TS model: prefer bun (bundled with
11
+ # the app), fall back to node (≥ 22.18 strips TS types natively).
12
+ if command -v bun >/dev/null 2>&1; then
13
+ exec bun "$SCRIPT_DIR/read-annotations.mjs" "$@"
14
+ fi
11
15
  exec node "$SCRIPT_DIR/read-annotations.mjs" "$@"
@@ -113,6 +113,15 @@ export function canvasArtifacts(input: { rel: string; paths: Paths }): CanvasArt
113
113
  });
114
114
  out.push({
115
115
  // Versioned — a real git rename on move, which is correct and expected.
116
+ // DDR-242: the annotations board.
117
+ abs: path.join(paths.designRoot, `${slug}.annotations.json`),
118
+ kind: 'slug-keyed',
119
+ rekey: true,
120
+ versioned: true,
121
+ });
122
+ out.push({
123
+ // A not-yet-migrated v1 sidecar travels with its canvas until the boot
124
+ // migration converts it (annotations/migrate-boot.ts).
116
125
  abs: path.join(paths.designRoot, `${slug}.annotations.svg`),
117
126
  kind: 'slug-keyed',
118
127
  rekey: true,
@@ -265,6 +265,8 @@ const IS_WEBKIT =
265
265
  type DcPerfCounters = {
266
266
  artboardRenders: number;
267
267
  annotationRenders: number;
268
+ /** Annotation element nodes (re-)rendered — a drag should touch only what moves. */
269
+ annotationNodeRenders?: number;
268
270
  instrumented?: boolean;
269
271
  };
270
272
 
@@ -1428,7 +1430,17 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
1428
1430
  // receives events natively.
1429
1431
  const onPointerEnter = () => {
1430
1432
  try {
1431
- if (typeof window !== 'undefined' && document.activeElement !== host) {
1433
+ const active = document.activeElement as HTMLElement | null;
1434
+ // Never steal focus from something being TYPED into. An editor that
1435
+ // mounts under a stationary pointer (double-click on a standalone
1436
+ // text) makes the engine fire pointerenter on the host; focusing the
1437
+ // host then blurred the editor, and blur commits — so the editor
1438
+ // closed ~5 ms after it opened and a double-click never entered edit.
1439
+ const typing =
1440
+ !!active &&
1441
+ active !== host &&
1442
+ (active.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName));
1443
+ if (typeof window !== 'undefined' && active !== host && !typing) {
1432
1444
  host.focus({ preventScroll: true });
1433
1445
  }
1434
1446
  } catch {
@@ -829,6 +829,12 @@ function CanvasCore({
829
829
  return;
830
830
  }
831
831
  if (t.closest('[data-dc-screen]')) return; // any part of an artboard
832
+ // Inside anything editable a double-click selects a WORD — the text
833
+ // editors of stickies / shape labels / texts sit over the world, not over
834
+ // an artboard, so this used to fit() and throw the view to the top-left
835
+ // mid-edit (user report 2026-09-30).
836
+ if (t.closest('[data-annot-editor], input, textarea, select')) return;
837
+ if ((t as HTMLElement).isContentEditable) return;
832
838
  e.preventDefault();
833
839
  controller.fit();
834
840
  };
@@ -130,6 +130,16 @@ import { acceptCanvasNotice } from '../canvas-notice-message.ts';
130
130
  import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
131
131
  import { ReportBugDialog } from './report-bug.jsx';
132
132
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
133
+ import {
134
+ collectDirPaths,
135
+ isDirOpen,
136
+ pruneDirs,
137
+ remapDirPrefix,
138
+ revealPath,
139
+ setDirOpen,
140
+ toggleSection as toggleSectionState,
141
+ useTreeExpansion,
142
+ } from './tree-expansion.js';
133
143
 
134
144
  const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
135
145
  // Phase 29 (E4) — the collab "rychlý kurz" is offered once after onboarding.
@@ -138,7 +148,6 @@ const COLLAB_TOUR_STORE = 'mdcc-collab-tour-seen';
138
148
  const SYSTEM_TAB = '__system__';
139
149
  const THEME_STORE = 'mdcc-theme';
140
150
  const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
141
- const SECTIONS_STORE = 'mdcc-sections-expanded';
142
151
  // DDR-171 — CSS panel vocabulary mode ('advanced' | 'designer'), read inside
143
152
  // CssKnobs.
144
153
  const CP_MODE_STORE = 'maude-cp-mode';
@@ -330,14 +339,14 @@ function readJsonStore(key, fallback) {
330
339
  }
331
340
  }
332
341
 
333
- // Section default-open: working sections (project + non-DS canvas groups)
334
- // open; meta sections (DS + runtime) collapsed. Users can override per-section
335
- // via the chevron; overrides persist in localStorage.
336
- function sectionDefaultOpen(g) {
337
- if (g.kind === 'runtime') return false;
338
- if (g.label === 'Design system') return false;
339
- return true;
342
+ // Section default-open: EVERY section starts collapsed (issue #124 — "default
343
+ // should be collapsed"). The user's per-section choice is remembered per
344
+ // project by useTreeExpansion (tree-expansion.js), next to the folder state.
345
+ function sectionDefaultOpen() {
346
+ return false;
340
347
  }
348
+ // Stable empty list for the Sidebar while the tree state hydrates.
349
+ const EMPTY_GROUPS = [];
341
350
 
342
351
  // ---------- Utility ----------
343
352
 
@@ -2155,8 +2164,17 @@ function ExportDialog({
2155
2164
  const TREE_INDENT_BASE = 12;
2156
2165
  const TREE_INDENT_STEP = 16;
2157
2166
 
2158
- function DirRow({ name, depth, defaultOpen, children, dirPath, drag, menu }) {
2159
- const [open, setOpen] = useState(defaultOpen);
2167
+ // Issue #124 — disclosure is CONTROLLED: `expansion` is App's tree state (see
2168
+ // tree-expansion.js), so a collapse outlives this row's unmount (dock-tab
2169
+ // switch, section toggle, reload). `forceOpen` = an active search; it shows
2170
+ // the hits without recording anything.
2171
+ function DirRow({ name, depth, children, dirPath, drag, menu, expansion, forceOpen }) {
2172
+ const open = !!forceOpen || !!expansion?.isOpen(dirPath);
2173
+ const setOpen = (v) => {
2174
+ if (forceOpen) return; // search is showing hits — don't record a choice
2175
+ const next = typeof v === 'function' ? v(open) : v;
2176
+ if (next !== open) expansion?.setOpen(dirPath, next);
2177
+ };
2160
2178
  // feature-file-tree-drag-drop-folders (Task 8) — a folder row IS the drop
2161
2179
  // target. `drag` is undefined for groups that can't accept a move (the
2162
2180
  // design-system group) — no handlers attach there, so the browser's default
@@ -2223,8 +2241,13 @@ function DirRow({ name, depth, defaultOpen, children, dirPath, drag, menu }) {
2223
2241
  // Split target: chevron toggles disclosure of the folder's contents; clicking
2224
2242
  // the folder name opens the SystemView focused on that DS (single SystemView
2225
2243
  // for now; the dsName is plumbed through so a future per-DS view can use it).
2226
- function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, children }) {
2227
- const [open, setOpen] = useState(defaultOpen);
2244
+ function DsFolderRow({ name, dsName, dirPath, depth, active, onOpenSystem, children, expansion, forceOpen }) {
2245
+ const open = !!forceOpen || !!expansion?.isOpen(dirPath);
2246
+ const setOpen = (v) => {
2247
+ if (forceOpen) return; // search is showing hits — don't record a choice
2248
+ const next = typeof v === 'function' ? v(open) : v;
2249
+ if (next !== open) expansion?.setOpen(dirPath, next);
2250
+ };
2228
2251
  return (
2229
2252
  <FileTreeItem label={name} expanded={open} selected={active}
2230
2253
  onToggle={() => setOpen(v => !v)} row={
@@ -2613,6 +2636,8 @@ function Tree({
2613
2636
  // feature-file-tree-drag-drop-folders (Task 9) — the shared row-menu
2614
2637
  // instance (useRowMenu()), undefined for groups that can't participate.
2615
2638
  menu,
2639
+ // Issue #124 — App-level folder disclosure `{ isOpen, setOpen }`.
2640
+ expansion,
2616
2641
  }) {
2617
2642
  const dirs = Object.keys(node)
2618
2643
  .filter((k) => k !== '_files')
@@ -2728,6 +2753,7 @@ function Tree({
2728
2753
  dirPath={childPath}
2729
2754
  drag={drag}
2730
2755
  menu={menu}
2756
+ expansion={expansion}
2731
2757
  />
2732
2758
  );
2733
2759
  if (dsMatch && onOpenSystem) {
@@ -2736,8 +2762,10 @@ function Tree({
2736
2762
  key={d}
2737
2763
  name={d}
2738
2764
  dsName={dsMatch.name}
2765
+ dirPath={childPath}
2739
2766
  depth={depth}
2740
- defaultOpen={true}
2767
+ expansion={expansion}
2768
+ forceOpen={hasSearch}
2741
2769
  active={activePath === SYSTEM_TAB && dsMatch.name === activeDsName}
2742
2770
  onOpenSystem={onOpenSystem}
2743
2771
  >
@@ -2750,8 +2778,9 @@ function Tree({
2750
2778
  key={d}
2751
2779
  name={d}
2752
2780
  depth={depth}
2753
- defaultOpen={true}
2754
2781
  dirPath={childPath}
2782
+ expansion={expansion}
2783
+ forceOpen={hasSearch}
2755
2784
  drag={drag}
2756
2785
  menu={drag ? menu : undefined}
2757
2786
  >
@@ -2809,6 +2838,8 @@ function Sidebar({
2809
2838
  showHidden,
2810
2839
  sectionsExpanded,
2811
2840
  onToggleSection,
2841
+ // Issue #124 — App-level folder disclosure `{ isOpen, setOpen }`.
2842
+ treeExpansion,
2812
2843
  onNewBoard,
2813
2844
  onDeleteBoard,
2814
2845
  onRefresh,
@@ -3264,6 +3295,7 @@ function Sidebar({
3264
3295
  dirPath={g.fullPath}
3265
3296
  drag={!isDs && g.kind === 'canvas' && !readOnly ? treeDrag : undefined}
3266
3297
  menu={rowMenu}
3298
+ expansion={treeExpansion}
3267
3299
  />
3268
3300
  ) : (
3269
3301
  <div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
@@ -10412,7 +10444,11 @@ function App() {
10412
10444
  const [openMenu, setOpenMenu] = useState(null);
10413
10445
  const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
10414
10446
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
10415
- const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
10447
+ // Issue #124 — folder + section disclosure of the Files panel, remembered
10448
+ // per project on disk (tree-expansion.js). Absent ⇒ closed.
10449
+ const treeExp = useTreeExpansion();
10450
+ const updateTreeExp = treeExp.update;
10451
+ const sectionsExpanded = treeExp.state.sections;
10416
10452
  const [helpOpen, setHelpOpen] = useState(false);
10417
10453
  const [reportBugOpen, setReportBugOpen] = useState(false);
10418
10454
 
@@ -11748,11 +11784,6 @@ function App() {
11748
11784
  localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0');
11749
11785
  } catch {}
11750
11786
  }, [showHidden]);
11751
- useEffect(() => {
11752
- try {
11753
- localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
11754
- } catch {}
11755
- }, [sectionsExpanded]);
11756
11787
  useEffect(() => {
11757
11788
  try {
11758
11789
  localStorage.setItem(MINIMAP_STORE, minimapVisible ? '1' : '0');
@@ -11835,13 +11866,28 @@ function App() {
11835
11866
  } catch {}
11836
11867
  }, [layersMode]);
11837
11868
 
11838
- const toggleSection = useCallback((label, defaultOpen) => {
11839
- setSectionsExpanded((prev) => {
11840
- const cur = prev[label];
11841
- const isOpen = cur === undefined ? defaultOpen : cur;
11842
- return { ...prev, [label]: !isOpen };
11843
- });
11844
- }, []);
11869
+ // #124 — forget folders that no longer exist (deleted, or moved outside
11870
+ // Maude). ONCE per session, on the first loaded tree: pruning on every tree
11871
+ // change would race a move's remap against the reload that follows it.
11872
+ const treePruned = useRef(false);
11873
+ useEffect(() => {
11874
+ if (treePruned.current || !treeExp.ready || !treeLoaded || !groups.length) return;
11875
+ treePruned.current = true;
11876
+ const known = collectDirPaths(groups);
11877
+ updateTreeExp((st) => pruneDirs(st, known));
11878
+ }, [treeExp.ready, treeLoaded, groups, updateTreeExp]);
11879
+
11880
+ const toggleSection = useCallback(
11881
+ (label) => updateTreeExp((st) => toggleSectionState(st, label)),
11882
+ [updateTreeExp]
11883
+ );
11884
+ const treeExpansion = useMemo(
11885
+ () => ({
11886
+ isOpen: (dirPath) => isDirOpen(treeExp.state, dirPath),
11887
+ setOpen: (dirPath, open) => updateTreeExp((st) => setDirOpen(st, dirPath, open)),
11888
+ }),
11889
+ [treeExp.state, updateTreeExp]
11890
+ );
11845
11891
 
11846
11892
  const toggleTheme = useCallback(() => {
11847
11893
  setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
@@ -12354,7 +12400,12 @@ function App() {
12354
12400
  // The `tabs` state stays as a 0-or-1 array so the rest of the plumbing
12355
12401
  // (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
12356
12402
  // ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
12357
- const openTab = useCallback((path) => {
12403
+ // `reveal: false` for opens the USER didn't just ask for (URL restore on
12404
+ // boot, the cloud first-canvas auto-open): nothing expands itself on launch
12405
+ // (#124). Every other open reveals the canvas's row in the Files tree.
12406
+ const openTab = useCallback((path, { reveal = true } = {}) => {
12407
+ if (reveal && path && path !== SYSTEM_TAB)
12408
+ updateTreeExp((st) => revealPath(st, groups, path));
12358
12409
  addressMode.current = 'push';
12359
12410
  setFocusedCommentId(null);
12360
12411
  setPreviewPath(null);
@@ -12372,12 +12423,12 @@ function App() {
12372
12423
  // Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
12373
12424
  // the onLoad fallback timer (legacy .html), or a hard 15s cap.
12374
12425
  if (path !== SYSTEM_TAB) setLoadingPath(path);
12375
- }, [activePath]);
12426
+ }, [activePath, groups, updateTreeExp]);
12376
12427
 
12377
12428
  // Resolve URL identities against the loaded tree, including non-canvas previews.
12378
12429
  const openLinkedFile = useCallback((rel, mode = 'push') => {
12379
12430
  const path = groups.flatMap((g) => g.paths || []).find((p) => normalizeOpenPath(p, cfg.designRel) === rel);
12380
- if (path && CANVAS_EXT_RE.test(path)) openTab(path);
12431
+ if (path && CANVAS_EXT_RE.test(path)) openTab(path, { reveal: false });
12381
12432
  else if (path && previewKind(basename(path))) onPreview(path);
12382
12433
  else {
12383
12434
  setTabs([]);
@@ -12482,7 +12533,7 @@ function App() {
12482
12533
  }
12483
12534
  if (!first) return;
12484
12535
  autoOpened.current = true;
12485
- openTab(first);
12536
+ openTab(first, { reveal: false });
12486
12537
  }, [cfg.cloud, groups, tabs.length, openTab]);
12487
12538
 
12488
12539
  const openSystem = useCallback(
@@ -12849,6 +12900,9 @@ function App() {
12849
12900
  );
12850
12901
  const fromFile = `${designRel}/${j.fromRel}`;
12851
12902
  const toFile = `${designRel}/${j.toRel}`;
12903
+ // #124 — a moved folder keeps its (and its subfolders') open state, and
12904
+ // the destination opens so the moved row is visible.
12905
+ updateTreeExp((st) => revealPath(remapDirPrefix(st, fromFile, toFile), groups, toFile));
12852
12906
  await loadTree();
12853
12907
  setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
12854
12908
  setActivePath((prev) => (prev === fromFile ? toFile : prev));
@@ -12865,7 +12919,7 @@ function App() {
12865
12919
  return { ok: false, error: 'network error' };
12866
12920
  }
12867
12921
  },
12868
- [loadTree, cfg]
12922
+ [loadTree, cfg, groups, updateTreeExp]
12869
12923
  );
12870
12924
 
12871
12925
  // feature-file-tree-drag-drop-folders (Task 4/9/12) — create a folder under
@@ -12887,6 +12941,8 @@ function App() {
12887
12941
  shellToast(`Could not create folder: ${error}`);
12888
12942
  return { ok: false, error };
12889
12943
  }
12944
+ // #124 — open the parent so the new folder is visible (it starts closed).
12945
+ updateTreeExp((st) => revealPath(st, groups, parentDir, { includeSelf: true }));
12890
12946
  await loadTree();
12891
12947
  return { ok: true, dir: j.dir };
12892
12948
  } catch (e) {
@@ -12895,7 +12951,7 @@ function App() {
12895
12951
  return { ok: false, error };
12896
12952
  }
12897
12953
  },
12898
- [loadTree]
12954
+ [loadTree, groups, updateTreeExp]
12899
12955
  );
12900
12956
 
12901
12957
  // feature-file-tree-drag-drop-folders (dogfood follow-up) — delete a
@@ -12943,12 +12999,16 @@ function App() {
12943
12999
  shellToast(`Could not rename folder: ${j.error || `error ${r.status}`}`);
12944
13000
  return;
12945
13001
  }
13002
+ // #124 — the renamed folder keeps its open state (and its subfolders').
13003
+ const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
13004
+ const toDir = typeof j.toRel === 'string' ? `${designRel}/${j.toRel}` : `${parent}/${name}`;
13005
+ updateTreeExp((st) => remapDirPrefix(st, dirPath, toDir));
12946
13006
  await loadTree();
12947
13007
  } catch (e) {
12948
13008
  shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
12949
13009
  }
12950
13010
  },
12951
- [loadTree]
13011
+ [loadTree, cfg, updateTreeExp]
12952
13012
  );
12953
13013
 
12954
13014
  // Plan T25/L04 — rename a canvas in place (its sidecars follow; an open tab
@@ -15674,7 +15734,9 @@ function App() {
15674
15734
  <Sidebar
15675
15735
  cloud={cfg.cloud}
15676
15736
  readOnly={viewerMode}
15677
- groups={groups}
15737
+ // Held back until the remembered disclosure is read, so the first
15738
+ // paint is the user's tree rather than an all-closed flash (#124).
15739
+ groups={treeExp.ready ? groups : EMPTY_GROUPS}
15678
15740
  activePath={activePath}
15679
15741
  previewPath={previewPath}
15680
15742
  activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
@@ -15691,6 +15753,7 @@ function App() {
15691
15753
  showHidden={showHidden}
15692
15754
  sectionsExpanded={sectionsExpanded}
15693
15755
  onToggleSection={toggleSection}
15756
+ treeExpansion={treeExpansion}
15694
15757
  onNewBoard={createBoard}
15695
15758
  onDeleteBoard={deleteBoard}
15696
15759
  onMoveCanvas={moveCanvasReq}
@@ -69,7 +69,7 @@ function handle(evt) {
69
69
  }
70
70
  if (evt.type === 'fs:json' && evt.file) {
71
71
  // Phase 20 (DDR-049) — external edits to canvas `.meta.json` or
72
- // `.annotations.svg` invalidate the iframe's in-memory undo stack
72
+ // `.annotations.json` invalidate the iframe's in-memory undo stack
73
73
  // (stale before/after snapshots would otherwise restore wrong state).
74
74
  // The iframe's UndoStackProvider self-echo-dampens our own PATCH-es
75
75
  // via window.__maude_last_meta_self_write_at, so we forward
@@ -1,12 +1,12 @@
1
1
  // Pure grouping helpers for the Changes panel (GitPanel.jsx) — framework-free so
2
2
  // they unit-test without a DOM. DDR-115 follow-up: group changed files by parent
3
3
  // canvas + its supporting sidecars (same-stem `.meta.json`, slug-matched
4
- // `.annotations.svg`), mirroring the server's `expandSidecars` notion of "travels
4
+ // `.annotations.json`), mirroring the server's `expandSidecars` notion of "travels
5
5
  // with the canvas" so the visual grouping is honest with what a unit commits.
6
6
 
7
7
  export const CANVAS_RE = /\.(tsx|html)$/i;
8
8
  export const META_RE = /\.meta\.json$/i;
9
- export const ANNOT_RE = /\.annotations\.svg$/i;
9
+ export const ANNOT_RE = /\.annotations\.(?:svg|json)$/i;
10
10
 
11
11
  /** Display name for a file (last path segment, sidecar/canvas extension stripped). */
12
12
  export function baseName(p) {
@@ -0,0 +1,217 @@
1
+ // tree-expansion.js — which folders + sections of the Files panel are open
2
+ // (issue #124).
3
+ //
4
+ // Before this, every folder row owned its own `useState(defaultOpen=true)`:
5
+ // the tree started fully expanded and forgot every collapse the moment the row
6
+ // unmounted — a dock-tab switch, a section toggle, a reload. The state now
7
+ // lives ONCE at App level (so it outlives the rows) and is persisted per
8
+ // project through `/_api/tree-state` (so it outlives the app).
9
+ //
10
+ // Rule: anything not recorded is CLOSED — folders and sections alike. Nothing
11
+ // expands itself on boot; only the user does (a click, or explicitly opening a
12
+ // canvas, which reveals its row). Search force-opens without recording.
13
+ //
14
+ // The helpers below are pure (state in → state out) so they are unit-testable
15
+ // without a DOM; `useTreeExpansion` is the thin React wrapper App uses.
16
+
17
+ import { useCallback, useEffect, useRef, useState } from 'react';
18
+
19
+ export const EMPTY_TREE_STATE = Object.freeze({ dirs: [], sections: {} });
20
+
21
+ // Pre-#124 section overrides lived only in origin-scoped localStorage. Read
22
+ // once as a seed so an existing user's choices survive the move to disk.
23
+ export const LEGACY_SECTIONS_STORE = 'mdcc-sections-expanded';
24
+
25
+ const PERSIST_DEBOUNCE_MS = 250;
26
+ const HYDRATE_TIMEOUT_MS = 3000;
27
+
28
+ export function isDirOpen(state, dirPath) {
29
+ return !!dirPath && state.dirs.includes(dirPath);
30
+ }
31
+
32
+ export function isSectionOpen(state, label) {
33
+ return state.sections[label] === true;
34
+ }
35
+
36
+ export function setDirOpen(state, dirPath, open) {
37
+ if (!dirPath) return state;
38
+ const has = state.dirs.includes(dirPath);
39
+ if (has === !!open) return state;
40
+ const dirs = open ? [...state.dirs, dirPath].sort() : state.dirs.filter((d) => d !== dirPath);
41
+ return { ...state, dirs };
42
+ }
43
+
44
+ export function toggleDir(state, dirPath) {
45
+ return setDirOpen(state, dirPath, !isDirOpen(state, dirPath));
46
+ }
47
+
48
+ export function setSectionOpen(state, label, open) {
49
+ if (!label || state.sections[label] === !!open) return state;
50
+ return { ...state, sections: { ...state.sections, [label]: !!open } };
51
+ }
52
+
53
+ export function toggleSection(state, label) {
54
+ return setSectionOpen(state, label, !isSectionOpen(state, label));
55
+ }
56
+
57
+ /** The group (section) a repo-relative path lives in — longest `fullPath` wins. */
58
+ export function groupForPath(groups, path) {
59
+ let best = null;
60
+ for (const g of groups || []) {
61
+ const root = g.fullPath;
62
+ if (!root || !(path === root || path.startsWith(root + '/'))) continue;
63
+ if (!best || root.length > best.fullPath.length) best = g;
64
+ }
65
+ return best;
66
+ }
67
+
68
+ /**
69
+ * Open everything needed to SEE `path`: its section, and every folder between
70
+ * the section root and the path. `includeSelf` also opens `path` itself (for a
71
+ * folder target — a new folder's parent, a move destination).
72
+ */
73
+ export function revealPath(state, groups, path, { includeSelf = false } = {}) {
74
+ if (!path) return state;
75
+ const g = groupForPath(groups, path);
76
+ if (!g) return state;
77
+ let next = setSectionOpen(state, g.label, true);
78
+ const rest = path.slice(g.fullPath.length + 1);
79
+ if (!rest) return next;
80
+ const parts = rest.split('/');
81
+ const upto = includeSelf ? parts.length : parts.length - 1;
82
+ let cur = g.fullPath;
83
+ for (let i = 0; i < upto; i++) {
84
+ cur = `${cur}/${parts[i]}`;
85
+ next = setDirOpen(next, cur, true);
86
+ }
87
+ return next;
88
+ }
89
+
90
+ /** A folder moved/renamed `from` → `to`: carry its (and its subfolders') keys. */
91
+ export function remapDirPrefix(state, from, to) {
92
+ if (!from || !to || from === to) return state;
93
+ let changed = false;
94
+ const dirs = state.dirs.map((d) => {
95
+ if (d === from) {
96
+ changed = true;
97
+ return to;
98
+ }
99
+ if (d.startsWith(from + '/')) {
100
+ changed = true;
101
+ return to + d.slice(from.length);
102
+ }
103
+ return d;
104
+ });
105
+ return changed ? { ...state, dirs: [...new Set(dirs)].sort() } : state;
106
+ }
107
+
108
+ /** Every folder path present in the loaded groups' trees. */
109
+ export function collectDirPaths(groups) {
110
+ const out = new Set();
111
+ const walk = (node, base) => {
112
+ for (const k of Object.keys(node || {})) {
113
+ if (k === '_files') continue;
114
+ const p = `${base}/${k}`;
115
+ out.add(p);
116
+ walk(node[k], p);
117
+ }
118
+ };
119
+ for (const g of groups || []) if (g.fullPath) walk(g.tree, g.fullPath);
120
+ return out;
121
+ }
122
+
123
+ /** Drop keys for folders that no longer exist (deleted, moved outside Maude). */
124
+ export function pruneDirs(state, known) {
125
+ const dirs = state.dirs.filter((d) => known.has(d));
126
+ return dirs.length === state.dirs.length ? state : { ...state, dirs };
127
+ }
128
+
129
+ export function normalizeClientState(raw) {
130
+ const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
131
+ const dirs = Array.isArray(o.dirs) ? [...new Set(o.dirs.filter((d) => typeof d === 'string' && d))].sort() : [];
132
+ const sections = {};
133
+ if (o.sections && typeof o.sections === 'object' && !Array.isArray(o.sections)) {
134
+ for (const [k, v] of Object.entries(o.sections)) if (typeof v === 'boolean') sections[k] = v;
135
+ }
136
+ return { dirs, sections };
137
+ }
138
+
139
+ function readLegacySections() {
140
+ try {
141
+ const v = JSON.parse(localStorage.getItem(LEGACY_SECTIONS_STORE) || 'null');
142
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * App-level owner of the tree state. `ready` flips once the stored state has
150
+ * been read (or the read failed) — App holds the tree back until then so the
151
+ * first paint is the remembered tree, not an all-closed flash. Writes are
152
+ * debounced and only start after hydration, so the initial empty state can
153
+ * never overwrite what is on disk.
154
+ */
155
+ export function useTreeExpansion({ fetchImpl } = {}) {
156
+ const [state, setState] = useState(EMPTY_TREE_STATE);
157
+ const [ready, setReady] = useState(false);
158
+ const doFetch = fetchImpl || ((...a) => fetch(...a));
159
+ const fetchRef = useRef(doFetch);
160
+ fetchRef.current = doFetch;
161
+
162
+ useEffect(() => {
163
+ let cancelled = false;
164
+ // A stalled read (cell cold start, a hung proxy) must not keep the Files
165
+ // panel empty: after HYDRATE_TIMEOUT_MS the tree renders all-closed.
166
+ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
167
+ const timer = setTimeout(() => ctrl?.abort(), HYDRATE_TIMEOUT_MS);
168
+ fetchRef
169
+ .current('/_api/tree-state', ctrl ? { signal: ctrl.signal } : undefined)
170
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
171
+ .then((raw) => {
172
+ if (cancelled) return;
173
+ let next = normalizeClientState(raw);
174
+ const stored = raw && typeof raw === 'object' && raw.sections && Object.keys(raw.sections).length;
175
+ if (!stored) {
176
+ const legacy = readLegacySections();
177
+ if (legacy) next = normalizeClientState({ ...next, sections: legacy });
178
+ }
179
+ setState(next);
180
+ })
181
+ .catch(() => {})
182
+ .finally(() => {
183
+ clearTimeout(timer);
184
+ if (!cancelled) setReady(true);
185
+ });
186
+ return () => {
187
+ cancelled = true;
188
+ clearTimeout(timer);
189
+ };
190
+ }, []);
191
+
192
+ const skipFirst = useRef(true);
193
+ useEffect(() => {
194
+ if (!ready) return;
195
+ // The hydrating setState lands in the same commit as `ready`; that value
196
+ // came FROM disk, so writing it back is pointless.
197
+ if (skipFirst.current) {
198
+ skipFirst.current = false;
199
+ return;
200
+ }
201
+ const t = setTimeout(() => {
202
+ try {
203
+ fetchRef
204
+ .current('/_api/tree-state', {
205
+ method: 'POST',
206
+ headers: { 'content-type': 'text/plain' },
207
+ body: JSON.stringify(state),
208
+ })
209
+ .catch(() => {});
210
+ } catch {}
211
+ }, PERSIST_DEBOUNCE_MS);
212
+ return () => clearTimeout(t);
213
+ }, [ready, state]);
214
+
215
+ const update = useCallback((fn) => setState((s) => fn(s)), []);
216
+ return { state, ready, update };
217
+ }