@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.
- package/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +534 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops-merge.ts +15 -0
- package/apps/studio/annotations/ops.ts +499 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +65 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +249 -69
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-lib.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +6 -0
- package/apps/studio/client/app.jsx +98 -35
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +49 -5
- package/apps/studio/collab/persistence.ts +31 -10
- package/apps/studio/collab/registry.ts +64 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/cursors-overlay.tsx +158 -4
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +12 -4
- package/apps/studio/sync/agent.ts +8 -4
- package/apps/studio/sync/codec.ts +96 -40
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +1 -1
- package/apps/studio/sync/index.ts +38 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +8 -5
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +76 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- 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
|
|
3
|
-
#
|
|
4
|
-
# (DDR-062), never a raw bin path.
|
|
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> [--
|
|
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
|
-
|
|
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:
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
function sectionDefaultOpen(
|
|
337
|
-
|
|
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
|
-
|
|
2159
|
-
|
|
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,
|
|
2227
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11839
|
-
|
|
11840
|
-
|
|
11841
|
-
|
|
11842
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|