@1agh/maude 0.49.1 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/api.ts +751 -52
- package/apps/studio/canvas-artifacts.ts +153 -0
- package/apps/studio/canvas-create.ts +23 -0
- package/apps/studio/canvas-slug.ts +30 -0
- package/apps/studio/client/app.jsx +547 -90
- package/apps/studio/client/panels/CloudBar.jsx +284 -0
- package/apps/studio/client/styles/3-shell-maude.css +26 -0
- package/apps/studio/client/tree-row-menu.jsx +132 -0
- package/apps/studio/client/use-tree-drag.js +120 -0
- package/apps/studio/cloud/endpoints.ts +236 -0
- package/apps/studio/collab/registry.ts +23 -0
- package/apps/studio/dist/client.bundle.js +960 -958
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +113 -0
- package/apps/studio/inspect.ts +49 -0
- package/apps/studio/server.ts +15 -0
- package/apps/studio/test/canvas-artifacts.test.ts +125 -0
- package/apps/studio/test/canvas-create-api.test.ts +159 -1
- package/apps/studio/test/canvas-move-api.test.ts +556 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
- package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
- package/apps/studio/whats-new.json +18 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +294 -7
- package/cli/commands/init.mjs +33 -2
- package/cli/commands/kg.mjs +129 -4
- package/cli/commands/share.mjs +184 -0
- package/cli/lib/ddr-to-kgai.mjs +310 -82
- package/cli/lib/ddr-to-kgai.test.mjs +20 -0
- package/cli/lib/share-plan.mjs +96 -0
- package/cli/lib/share-plan.test.mjs +57 -0
- package/cli/lib/workspace-plan.mjs +130 -12
- package/cli/lib/workspace-plan.test.mjs +160 -0
- package/package.json +8 -8
|
@@ -22,9 +22,12 @@ import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-
|
|
|
22
22
|
import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
|
|
23
23
|
import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
24
24
|
import { canvasUrl } from './canvas-url.js';
|
|
25
|
+
import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
|
|
26
|
+
import { useTreeDrag } from './use-tree-drag.js';
|
|
25
27
|
import ChatPanel from './panels/ChatPanel.jsx';
|
|
26
28
|
import DiffView from './panels/DiffView.jsx';
|
|
27
29
|
import GitPanel from './panels/GitPanel.jsx';
|
|
30
|
+
import CloudBar from './panels/CloudBar.jsx';
|
|
28
31
|
import IdentityBar from './panels/IdentityBar.jsx';
|
|
29
32
|
import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
30
33
|
import { ReadinessDialog } from './panels/ReadinessList.jsx';
|
|
@@ -178,6 +181,18 @@ function DockSlot({ side, width, open, ids, activeId, onPick, children }) {
|
|
|
178
181
|
);
|
|
179
182
|
}
|
|
180
183
|
const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
|
|
184
|
+
// Shared testid-slug derivation (desktop-e2e skill convention: kebab-case,
|
|
185
|
+
// designRoot-stripped, extension-stripped) — mirrors FileRow's inline
|
|
186
|
+
// `canvas-row-<slug>` computation so DirRow / the row-menu trigger use the
|
|
187
|
+
// SAME shape for `tree-folder-<slug>` / `tree-row-menu-<slug>`.
|
|
188
|
+
function pathTestIdSlug(p) {
|
|
189
|
+
return p
|
|
190
|
+
.replace(/^\.[^/]+\//, '')
|
|
191
|
+
.replace(CANVAS_EXT_RE, '')
|
|
192
|
+
.replace(/[^a-z0-9]+/gi, '-')
|
|
193
|
+
.toLowerCase()
|
|
194
|
+
.replace(/^-+|-+$/g, '');
|
|
195
|
+
}
|
|
181
196
|
// Bun's `define` substitutes this at build time (see build.ts); falls back when
|
|
182
197
|
// the bundle is consumed in a context that hasn't run the build.
|
|
183
198
|
const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
|
|
@@ -252,7 +267,11 @@ function basename(p) {
|
|
|
252
267
|
// The clip ops used to console.warn their failures (a 422 "series clip can't
|
|
253
268
|
// move" looked like "the button does nothing"). Self-contained DOM (no React
|
|
254
269
|
// state), mirrors the canvas-side showCanvasToast.
|
|
255
|
-
|
|
270
|
+
// `action` (feature-file-tree-drag-drop-folders, Task 10) — optional
|
|
271
|
+
// `{ label, onClick }` for an inline undo affordance (the move toast). Absent
|
|
272
|
+
// for every other caller, so their toast stays the plain auto-dismissing text
|
|
273
|
+
// bubble this always was.
|
|
274
|
+
function shellToast(message, ok = false, action) {
|
|
256
275
|
if (typeof document === 'undefined') return;
|
|
257
276
|
let el = document.getElementById('st-op-toast');
|
|
258
277
|
if (!el) {
|
|
@@ -261,18 +280,41 @@ function shellToast(message, ok = false) {
|
|
|
261
280
|
el.setAttribute('role', 'status');
|
|
262
281
|
el.style.cssText =
|
|
263
282
|
'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:80;' +
|
|
264
|
-
'max-width:440px;padding:8px 14px;
|
|
283
|
+
'display:flex;align-items:center;gap:10px;max-width:440px;padding:8px 14px;' +
|
|
284
|
+
'border-radius:8px;font:12px/1.45 var(--font-mono,monospace);' +
|
|
265
285
|
'box-shadow:0 8px 28px rgba(0,0,0,.34);pointer-events:none;opacity:0;transition:opacity 140ms ease;';
|
|
266
286
|
document.body.appendChild(el);
|
|
267
287
|
}
|
|
268
288
|
el.style.background = ok ? '#1d3524' : '#3a1d1d';
|
|
269
289
|
el.style.color = ok ? '#b7e4c0' : '#f1b8b8';
|
|
270
|
-
el.textContent =
|
|
290
|
+
el.textContent = '';
|
|
291
|
+
const text = document.createElement('span');
|
|
292
|
+
text.textContent = message;
|
|
293
|
+
el.appendChild(text);
|
|
294
|
+
if (action) {
|
|
295
|
+
const btn = document.createElement('button');
|
|
296
|
+
btn.type = 'button';
|
|
297
|
+
btn.textContent = action.label;
|
|
298
|
+
btn.style.cssText =
|
|
299
|
+
'pointer-events:auto;background:none;border:0;text-decoration:underline;' +
|
|
300
|
+
'color:inherit;font:inherit;cursor:pointer;padding:0;';
|
|
301
|
+
btn.onclick = () => {
|
|
302
|
+
action.onClick();
|
|
303
|
+
el.style.opacity = '0';
|
|
304
|
+
el.style.pointerEvents = 'none';
|
|
305
|
+
};
|
|
306
|
+
el.appendChild(btn);
|
|
307
|
+
}
|
|
271
308
|
el.style.opacity = '1';
|
|
309
|
+
el.style.pointerEvents = action ? 'auto' : 'none';
|
|
272
310
|
clearTimeout(shellToast._t);
|
|
273
|
-
shellToast._t = setTimeout(
|
|
274
|
-
|
|
275
|
-
|
|
311
|
+
shellToast._t = setTimeout(
|
|
312
|
+
() => {
|
|
313
|
+
el.style.opacity = '0';
|
|
314
|
+
el.style.pointerEvents = 'none';
|
|
315
|
+
},
|
|
316
|
+
action ? 6000 : 3200
|
|
317
|
+
);
|
|
276
318
|
}
|
|
277
319
|
|
|
278
320
|
// feature-4 (browse/move split, DDR-187) — one-time first-run teaching hint.
|
|
@@ -389,8 +431,20 @@ function groupBySidecar(files) {
|
|
|
389
431
|
};
|
|
390
432
|
}
|
|
391
433
|
|
|
392
|
-
|
|
434
|
+
// `dirs` (feature-file-tree-drag-drop-folders, Task 7) — group-relative dir
|
|
435
|
+
// paths from the server's /_index-data payload, same shape as `paths`. Seeded
|
|
436
|
+
// FIRST so an empty folder (no files inside yet) still gets a node — without
|
|
437
|
+
// this a freshly `mkdir`'d folder would be invisible until something landed
|
|
438
|
+
// inside it, since the tree is otherwise built entirely from file paths.
|
|
439
|
+
function buildTree(paths, stripPrefix, dirs) {
|
|
393
440
|
const root = {};
|
|
441
|
+
for (const d of dirs || []) {
|
|
442
|
+
const stripped = d.startsWith(stripPrefix) ? d.slice(stripPrefix.length).replace(/^\/+/, '') : d;
|
|
443
|
+
const parts = stripped.split('/').filter(Boolean);
|
|
444
|
+
let node = root;
|
|
445
|
+
for (const key of parts) node = node[key] = node[key] || {};
|
|
446
|
+
node._files = node._files || [];
|
|
447
|
+
}
|
|
394
448
|
for (const p of paths) {
|
|
395
449
|
const stripped = p.startsWith(stripPrefix)
|
|
396
450
|
? p.slice(stripPrefix.length).replace(/^\/+/, '')
|
|
@@ -506,6 +560,14 @@ const STICONS = {
|
|
|
506
560
|
</>
|
|
507
561
|
),
|
|
508
562
|
folder: <path d="M2 4.5h4l1.3 1.5H14V13H2z" />,
|
|
563
|
+
// feature-file-tree-drag-drop-folders (Task 9/12) — folder outline + a "+"
|
|
564
|
+
// mark, single-stroke family matching `folder`/`file`/`panel-left`.
|
|
565
|
+
'folder-plus': (
|
|
566
|
+
<>
|
|
567
|
+
<path d="M2 4.5h4l1.3 1.5H14V13H2z" />
|
|
568
|
+
<path d="M8 7.5v3.5M6.25 9.25h3.5" />
|
|
569
|
+
</>
|
|
570
|
+
),
|
|
509
571
|
// feature-4 T7b — Layers padlock (closed / open).
|
|
510
572
|
lock: (
|
|
511
573
|
<>
|
|
@@ -1784,24 +1846,70 @@ function ExportDialog({
|
|
|
1784
1846
|
const TREE_INDENT_BASE = 12;
|
|
1785
1847
|
const TREE_INDENT_STEP = 16;
|
|
1786
1848
|
|
|
1787
|
-
function DirRow({ name, depth, defaultOpen, children }) {
|
|
1849
|
+
function DirRow({ name, depth, defaultOpen, children, dirPath, drag, menu }) {
|
|
1788
1850
|
const [open, setOpen] = useState(defaultOpen);
|
|
1851
|
+
// feature-file-tree-drag-drop-folders (Task 8) — a folder row IS the drop
|
|
1852
|
+
// target. `drag` is undefined for groups that can't accept a move (the
|
|
1853
|
+
// design-system group) — no handlers attach there, so the browser's default
|
|
1854
|
+
// "no drop" cursor applies with zero extra code. Spring-load reopens a
|
|
1855
|
+
// closed folder after a sustained hover; `setOpen` is this row's OWN local
|
|
1856
|
+
// state, which is why the callback lives here rather than in the hook.
|
|
1857
|
+
const dropHandlers = drag ? drag.dropProps(dirPath, true, () => setOpen(true)) : {};
|
|
1858
|
+
const isOver = drag?.overDir === dirPath;
|
|
1859
|
+
// feature-file-tree-drag-drop-folders (Task 11) — a folder is ALSO a drag
|
|
1860
|
+
// SOURCE (drag folder onto folder). No client-side branching needed beyond
|
|
1861
|
+
// this: the payload is just `dirPath` (no `.tsx` suffix), and the server's
|
|
1862
|
+
// moveCanvas auto-detects a non-.tsx source as a folder move.
|
|
1863
|
+
const dragHandlers = drag ? drag.dragProps(dirPath, true) : {};
|
|
1864
|
+
const isDragging = drag?.draggedPath === dirPath;
|
|
1865
|
+
const isBusy = drag?.busyPath === dirPath;
|
|
1866
|
+
const row = (
|
|
1867
|
+
<button
|
|
1868
|
+
type="button"
|
|
1869
|
+
role="treeitem"
|
|
1870
|
+
data-testid={`tree-folder-${pathTestIdSlug(dirPath)}`}
|
|
1871
|
+
aria-expanded={open}
|
|
1872
|
+
aria-dropeffect={drag ? 'move' : undefined}
|
|
1873
|
+
aria-busy={isBusy || undefined}
|
|
1874
|
+
tabIndex={-1}
|
|
1875
|
+
className={
|
|
1876
|
+
'st-row' +
|
|
1877
|
+
(isOver ? ' is-drop-target' : '') +
|
|
1878
|
+
(isDragging ? ' is-dragging' : '') +
|
|
1879
|
+
(isBusy ? ' is-busy' : '')
|
|
1880
|
+
}
|
|
1881
|
+
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
1882
|
+
onClick={() => setOpen((v) => !v)}
|
|
1883
|
+
onContextMenu={menu ? (e) => menu.openAt(e, { kind: 'dir', dirPath }) : undefined}
|
|
1884
|
+
{...dragHandlers}
|
|
1885
|
+
{...dropHandlers}
|
|
1886
|
+
>
|
|
1887
|
+
<span className="st-row-glyph">
|
|
1888
|
+
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
1889
|
+
</span>
|
|
1890
|
+
<span className="st-row-name">{name}</span>
|
|
1891
|
+
</button>
|
|
1892
|
+
);
|
|
1789
1893
|
return (
|
|
1790
1894
|
<Fragment>
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1895
|
+
{menu ? (
|
|
1896
|
+
<div className="st-row-wrap" role="none">
|
|
1897
|
+
{row}
|
|
1898
|
+
<button
|
|
1899
|
+
type="button"
|
|
1900
|
+
className="st-row-menu-btn"
|
|
1901
|
+
data-testid={`tree-row-menu-${pathTestIdSlug(dirPath)}`}
|
|
1902
|
+
title={`Folder actions — ${name}`}
|
|
1903
|
+
aria-label={`Folder actions for ${name}`}
|
|
1904
|
+
aria-haspopup="menu"
|
|
1905
|
+
onClick={(e) => menu.openAt(e, { kind: 'dir', dirPath })}
|
|
1906
|
+
>
|
|
1907
|
+
<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} />
|
|
1908
|
+
</button>
|
|
1909
|
+
</div>
|
|
1910
|
+
) : (
|
|
1911
|
+
row
|
|
1912
|
+
)}
|
|
1805
1913
|
{open && children}
|
|
1806
1914
|
</Fragment>
|
|
1807
1915
|
);
|
|
@@ -1859,6 +1967,8 @@ function FileRow({
|
|
|
1859
1967
|
sidecar,
|
|
1860
1968
|
dirty,
|
|
1861
1969
|
experimentalKind,
|
|
1970
|
+
drag,
|
|
1971
|
+
menu,
|
|
1862
1972
|
}) {
|
|
1863
1973
|
const isSel = file.path === activePath;
|
|
1864
1974
|
const isCanvas = CANVAS_EXT_RE.test(file.name);
|
|
@@ -1870,6 +1980,17 @@ function FileRow({
|
|
|
1870
1980
|
// Delete only real canvases in a deletable group (onDelete is undefined for the
|
|
1871
1981
|
// DS group + runtime files); the server enforces the rest.
|
|
1872
1982
|
const canDelete = isCanvas && typeof onDelete === 'function' && kind !== 'runtime';
|
|
1983
|
+
// feature-file-tree-drag-drop-folders (Task 8) — only real canvas primaries
|
|
1984
|
+
// (never sidecars, never runtime rows) are draggable; `drag` is undefined
|
|
1985
|
+
// for groups that can't be a move source (the design-system group).
|
|
1986
|
+
const draggableRow = isCanvas && !sidecar && kind !== 'runtime' && typeof drag !== 'undefined';
|
|
1987
|
+
const dragHandlers = draggableRow ? drag.dragProps(file.path, true) : {};
|
|
1988
|
+
const isDragging = drag?.draggedPath === file.path;
|
|
1989
|
+
const isBusy = drag?.busyPath === file.path;
|
|
1990
|
+
// feature-file-tree-drag-drop-folders (Task 9) — the KEYBOARD path for
|
|
1991
|
+
// "Move to…" (drag-only fails WCAG 2.1.1). Same eligibility as dragging.
|
|
1992
|
+
const canMove = draggableRow && typeof menu !== 'undefined';
|
|
1993
|
+
const fileDir = file.path.split('/').slice(0, -1).join('/');
|
|
1873
1994
|
// Stable hook for the desktop E2E harness (data-testid convention — see the
|
|
1874
1995
|
// `desktop-e2e` skill): canvas rows only, slug derived from the relative path
|
|
1875
1996
|
// (e.g. `ui/Smoke.tsx` → `canvas-row-ui-smoke`).
|
|
@@ -1889,15 +2010,24 @@ function FileRow({
|
|
|
1889
2010
|
data-testid={testId}
|
|
1890
2011
|
aria-selected={isSel}
|
|
1891
2012
|
aria-disabled={inert ? 'true' : undefined}
|
|
2013
|
+
aria-busy={isBusy || undefined}
|
|
1892
2014
|
tabIndex={isSel ? 0 : -1}
|
|
1893
2015
|
className={
|
|
1894
|
-
'st-row' +
|
|
2016
|
+
'st-row' +
|
|
2017
|
+
(isSel ? ' is-sel' : '') +
|
|
2018
|
+
(kind === 'runtime' ? ' is-muted' : '') +
|
|
2019
|
+
(isDragging ? ' is-dragging' : '') +
|
|
2020
|
+
(isBusy ? ' is-busy' : '')
|
|
1895
2021
|
}
|
|
1896
2022
|
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
1897
2023
|
title={file.path + (oc ? ` — ${oc} open` : inert ? ' (file index only)' : '')}
|
|
1898
2024
|
onClick={() => {
|
|
1899
2025
|
if (!inert) onOpen(file.path);
|
|
1900
2026
|
}}
|
|
2027
|
+
onContextMenu={
|
|
2028
|
+
canMove ? (e) => menu.openAt(e, { kind: 'file', path: file.path, dir: fileDir }) : undefined
|
|
2029
|
+
}
|
|
2030
|
+
{...dragHandlers}
|
|
1901
2031
|
>
|
|
1902
2032
|
<span className="st-row-glyph">
|
|
1903
2033
|
<StIcon name="file" size={13} />
|
|
@@ -1920,24 +2050,39 @@ function FileRow({
|
|
|
1920
2050
|
{oc > 0 && <span className="st-row-badge">{oc}</span>}
|
|
1921
2051
|
</button>
|
|
1922
2052
|
);
|
|
1923
|
-
if (!canDelete) return row;
|
|
1924
|
-
//
|
|
1925
|
-
// is presentational so the treeitem stays the tree's child for a11y.
|
|
2053
|
+
if (!canDelete && !canMove) return row;
|
|
2054
|
+
// Sibling menu/delete buttons (can't nest a button in the row button). The
|
|
2055
|
+
// wrapper is presentational so the treeitem stays the tree's child for a11y.
|
|
1926
2056
|
return (
|
|
1927
2057
|
<div className="st-row-wrap" role="none">
|
|
1928
2058
|
{row}
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
2059
|
+
{canMove && (
|
|
2060
|
+
<button
|
|
2061
|
+
type="button"
|
|
2062
|
+
className={'st-row-menu-btn' + (canDelete ? ' has-delete-sibling' : '')}
|
|
2063
|
+
data-testid={`tree-row-menu-${pathTestIdSlug(file.path)}`}
|
|
2064
|
+
title={`Move ${label}…`}
|
|
2065
|
+
aria-label={`Actions for ${label}`}
|
|
2066
|
+
aria-haspopup="menu"
|
|
2067
|
+
onClick={(e) => menu.openAt(e, { kind: 'file', path: file.path, dir: fileDir })}
|
|
2068
|
+
>
|
|
2069
|
+
<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} />
|
|
2070
|
+
</button>
|
|
2071
|
+
)}
|
|
2072
|
+
{canDelete && (
|
|
2073
|
+
<button
|
|
2074
|
+
type="button"
|
|
2075
|
+
className="st-row-del"
|
|
2076
|
+
title={`Delete ${label}`}
|
|
2077
|
+
aria-label={`Delete canvas ${label}`}
|
|
2078
|
+
onClick={(e) => {
|
|
2079
|
+
e.stopPropagation();
|
|
2080
|
+
onDelete(file.path, label);
|
|
2081
|
+
}}
|
|
2082
|
+
>
|
|
2083
|
+
<Icon d="M3 6h18 M8 6V4h8v2 M6 6l1 14h10l1-14 M10 11v6 M14 11v6" size={12} />
|
|
2084
|
+
</button>
|
|
2085
|
+
)}
|
|
1941
2086
|
</div>
|
|
1942
2087
|
);
|
|
1943
2088
|
}
|
|
@@ -1955,6 +2100,8 @@ function CanvasRow({
|
|
|
1955
2100
|
forceOpen,
|
|
1956
2101
|
dirtyByPath,
|
|
1957
2102
|
experimentalKind,
|
|
2103
|
+
drag,
|
|
2104
|
+
menu,
|
|
1958
2105
|
}) {
|
|
1959
2106
|
const dirty = dirtyByPath?.get(primary.path);
|
|
1960
2107
|
const hasSidecars = sidecars.length > 0;
|
|
@@ -1977,55 +2124,93 @@ function CanvasRow({
|
|
|
1977
2124
|
kind={kind}
|
|
1978
2125
|
dirty={dirty}
|
|
1979
2126
|
experimentalKind={experimentalKind}
|
|
2127
|
+
drag={drag}
|
|
2128
|
+
menu={menu}
|
|
1980
2129
|
/>
|
|
1981
2130
|
);
|
|
1982
2131
|
}
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
2132
|
+
const draggableRow = typeof drag !== 'undefined';
|
|
2133
|
+
const dragHandlers = draggableRow ? drag.dragProps(primary.path, true) : {};
|
|
2134
|
+
const isDragging = drag?.draggedPath === primary.path;
|
|
2135
|
+
const isBusy = drag?.busyPath === primary.path;
|
|
2136
|
+
const canMove = draggableRow && typeof menu !== 'undefined';
|
|
2137
|
+
const primaryDir = primary.path.split('/').slice(0, -1).join('/');
|
|
2138
|
+
const row = (
|
|
2139
|
+
<button
|
|
2140
|
+
type="button"
|
|
2141
|
+
role="treeitem"
|
|
2142
|
+
aria-selected={isSel}
|
|
2143
|
+
aria-expanded={open}
|
|
2144
|
+
aria-busy={isBusy || undefined}
|
|
2145
|
+
tabIndex={isSel ? 0 : -1}
|
|
2146
|
+
className={
|
|
2147
|
+
'st-row st-canvas-row' +
|
|
2148
|
+
(isSel ? ' is-sel' : '') +
|
|
2149
|
+
(isDragging ? ' is-dragging' : '') +
|
|
2150
|
+
(isBusy ? ' is-busy' : '')
|
|
2151
|
+
}
|
|
2152
|
+
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
2153
|
+
title={primary.path}
|
|
2154
|
+
onClick={(e) => {
|
|
2155
|
+
// Click the chevron region → toggle disclosure. Click anywhere else → open canvas.
|
|
2156
|
+
if (e.target.closest('.st-canvas-chev')) {
|
|
2157
|
+
setOpenState((v) => !v);
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
onOpen(primary.path);
|
|
2161
|
+
}}
|
|
2162
|
+
onContextMenu={
|
|
2163
|
+
canMove ? (e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir }) : undefined
|
|
2164
|
+
}
|
|
2165
|
+
{...dragHandlers}
|
|
2166
|
+
>
|
|
2167
|
+
<span
|
|
2168
|
+
className="st-row-glyph st-canvas-chev"
|
|
1994
2169
|
onClick={(e) => {
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
setOpenState((v) => !v);
|
|
1998
|
-
return;
|
|
1999
|
-
}
|
|
2000
|
-
onOpen(primary.path);
|
|
2170
|
+
e.stopPropagation();
|
|
2171
|
+
setOpenState((v) => !v);
|
|
2001
2172
|
}}
|
|
2002
2173
|
>
|
|
2174
|
+
<StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
|
|
2175
|
+
</span>
|
|
2176
|
+
<span className="st-row-name">{displayName(primary.name)}</span>
|
|
2177
|
+
{experimentalKind === 'reconstructed-experimental' && (
|
|
2003
2178
|
<span
|
|
2004
|
-
className="st-row-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
setOpenState((v) => !v);
|
|
2008
|
-
}}
|
|
2179
|
+
className="st-row-exp-badge"
|
|
2180
|
+
title="Reconstructed from an image via /design:import --reconstruct — experimental, lossy, review before trusting (DDR-174)"
|
|
2181
|
+
aria-label="Reconstructed, experimental"
|
|
2009
2182
|
>
|
|
2010
|
-
|
|
2183
|
+
exp
|
|
2011
2184
|
</span>
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2185
|
+
)}
|
|
2186
|
+
{dirty && (
|
|
2187
|
+
<span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
|
|
2188
|
+
{dirty}
|
|
2189
|
+
</span>
|
|
2190
|
+
)}
|
|
2191
|
+
{oc > 0 && <span className="st-row-badge">{oc}</span>}
|
|
2192
|
+
</button>
|
|
2193
|
+
);
|
|
2194
|
+
return (
|
|
2195
|
+
<Fragment>
|
|
2196
|
+
{canMove ? (
|
|
2197
|
+
<div className="st-row-wrap" role="none">
|
|
2198
|
+
{row}
|
|
2199
|
+
<button
|
|
2200
|
+
type="button"
|
|
2201
|
+
className="st-row-menu-btn"
|
|
2202
|
+
data-testid={`tree-row-menu-${pathTestIdSlug(primary.path)}`}
|
|
2203
|
+
title={`Move ${displayName(primary.name)}…`}
|
|
2204
|
+
aria-label={`Actions for ${displayName(primary.name)}`}
|
|
2205
|
+
aria-haspopup="menu"
|
|
2206
|
+
onClick={(e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir })}
|
|
2018
2207
|
>
|
|
2019
|
-
|
|
2020
|
-
</
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
</span>
|
|
2026
|
-
)}
|
|
2027
|
-
{oc > 0 && <span className="st-row-badge">{oc}</span>}
|
|
2028
|
-
</button>
|
|
2208
|
+
<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} />
|
|
2209
|
+
</button>
|
|
2210
|
+
</div>
|
|
2211
|
+
) : (
|
|
2212
|
+
row
|
|
2213
|
+
)}
|
|
2029
2214
|
{open &&
|
|
2030
2215
|
sidecars.map((sc) => (
|
|
2031
2216
|
<FileRow
|
|
@@ -2058,6 +2243,15 @@ function Tree({
|
|
|
2058
2243
|
onDelete,
|
|
2059
2244
|
dirtyByPath,
|
|
2060
2245
|
canvasKinds,
|
|
2246
|
+
// feature-file-tree-drag-drop-folders (Task 8) — `dirPath` accumulates this
|
|
2247
|
+
// node's full path (starts at the group's `fullPath`) so a DirRow knows
|
|
2248
|
+
// where to fs-move a drop TO; `drag` is the shared drag-bookkeeping bundle
|
|
2249
|
+
// from useTreeDrag, undefined for groups that can't participate (DS).
|
|
2250
|
+
dirPath = '',
|
|
2251
|
+
drag,
|
|
2252
|
+
// feature-file-tree-drag-drop-folders (Task 9) — the shared row-menu
|
|
2253
|
+
// instance (useRowMenu()), undefined for groups that can't participate.
|
|
2254
|
+
menu,
|
|
2061
2255
|
}) {
|
|
2062
2256
|
const dirs = Object.keys(node)
|
|
2063
2257
|
.filter((k) => k !== '_files')
|
|
@@ -2101,6 +2295,8 @@ function Tree({
|
|
|
2101
2295
|
forceOpen={forceOpen}
|
|
2102
2296
|
dirtyByPath={dirtyByPath}
|
|
2103
2297
|
experimentalKind={canvasKinds?.[entry.primary.path]}
|
|
2298
|
+
drag={drag}
|
|
2299
|
+
menu={menu}
|
|
2104
2300
|
/>
|
|
2105
2301
|
);
|
|
2106
2302
|
})}
|
|
@@ -2116,9 +2312,10 @@ function Tree({
|
|
|
2116
2312
|
kind={kind}
|
|
2117
2313
|
/>
|
|
2118
2314
|
))}
|
|
2119
|
-
{/* orphans are sidecars/loose files — no canvas to delete, so no onDelete */}
|
|
2315
|
+
{/* orphans are sidecars/loose files — no canvas to delete, so no onDelete/drag */}
|
|
2120
2316
|
{dirs.map((d) => {
|
|
2121
2317
|
const dsMatch = dsFolderByName?.get(d);
|
|
2318
|
+
const childPath = dirPath ? `${dirPath}/${d}` : d;
|
|
2122
2319
|
const childTree = (
|
|
2123
2320
|
<Tree
|
|
2124
2321
|
node={node[d]}
|
|
@@ -2134,6 +2331,9 @@ function Tree({
|
|
|
2134
2331
|
onDelete={onDelete}
|
|
2135
2332
|
dirtyByPath={dirtyByPath}
|
|
2136
2333
|
canvasKinds={canvasKinds}
|
|
2334
|
+
dirPath={childPath}
|
|
2335
|
+
drag={drag}
|
|
2336
|
+
menu={menu}
|
|
2137
2337
|
/>
|
|
2138
2338
|
);
|
|
2139
2339
|
if (dsMatch && onOpenSystem) {
|
|
@@ -2152,7 +2352,15 @@ function Tree({
|
|
|
2152
2352
|
);
|
|
2153
2353
|
}
|
|
2154
2354
|
return (
|
|
2155
|
-
<DirRow
|
|
2355
|
+
<DirRow
|
|
2356
|
+
key={d}
|
|
2357
|
+
name={d}
|
|
2358
|
+
depth={depth}
|
|
2359
|
+
defaultOpen={true}
|
|
2360
|
+
dirPath={childPath}
|
|
2361
|
+
drag={drag}
|
|
2362
|
+
menu={menu}
|
|
2363
|
+
>
|
|
2156
2364
|
{childTree}
|
|
2157
2365
|
</DirRow>
|
|
2158
2366
|
);
|
|
@@ -2209,25 +2417,109 @@ function Sidebar({
|
|
|
2209
2417
|
remoteSync,
|
|
2210
2418
|
onGetLatest,
|
|
2211
2419
|
canvasKinds,
|
|
2420
|
+
onMoveCanvas,
|
|
2421
|
+
onNewFolder,
|
|
2422
|
+
onDeleteFolder,
|
|
2212
2423
|
}) {
|
|
2213
2424
|
const filteredGroups = useMemo(() => {
|
|
2214
2425
|
if (!search) return groups;
|
|
2215
2426
|
return groups.map((g) => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
|
|
2216
2427
|
}, [groups, search]);
|
|
2217
2428
|
|
|
2429
|
+
// feature-file-tree-drag-drop-folders (Task 8) — one drag-bookkeeping
|
|
2430
|
+
// instance shared by every group's Tree; `onMoveCanvas` does the actual
|
|
2431
|
+
// fetch + tree refresh (App-level, alongside createBoard/deleteBoard).
|
|
2432
|
+
const treeDrag = useTreeDrag(onMoveCanvas);
|
|
2433
|
+
|
|
2434
|
+
// feature-file-tree-drag-drop-folders (Task 9) — the keyboard path. One
|
|
2435
|
+
// shared row-menu instance (only one row's menu is ever open); every
|
|
2436
|
+
// non-DS canvas group folder (incl. each group's own root) is a valid
|
|
2437
|
+
// "Move to…" destination.
|
|
2438
|
+
const rowMenu = useRowMenu();
|
|
2439
|
+
const destinations = useMemo(() => {
|
|
2440
|
+
const out = [];
|
|
2441
|
+
for (const g of groups) {
|
|
2442
|
+
if (g.label === 'Design system' || g.kind !== 'canvas') continue;
|
|
2443
|
+
const rootLabel = g.fullPath.replace(/^\.[^/]+\//, '') || g.fullPath;
|
|
2444
|
+
out.push({ path: g.fullPath, label: rootLabel });
|
|
2445
|
+
for (const d of g.dirs || []) {
|
|
2446
|
+
out.push({ path: d, label: d.replace(/^\.[^/]+\//, '') });
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
return out;
|
|
2450
|
+
}, [groups]);
|
|
2451
|
+
const menuExtra = rowMenu.state?.extra;
|
|
2452
|
+
const rowMenuRootItems =
|
|
2453
|
+
menuExtra?.kind === 'file'
|
|
2454
|
+
? [{ id: 'move-to', label: 'Move to…', onSelect: () => rowMenu.showMoveTo() }]
|
|
2455
|
+
: menuExtra?.kind === 'dir'
|
|
2456
|
+
? [
|
|
2457
|
+
{
|
|
2458
|
+
id: 'new-folder-here',
|
|
2459
|
+
label: 'New folder here',
|
|
2460
|
+
onSelect: () => {
|
|
2461
|
+
rowMenu.close();
|
|
2462
|
+
const name = window.prompt('New folder name:');
|
|
2463
|
+
if (name?.trim()) onNewFolder(menuExtra.dirPath, name.trim());
|
|
2464
|
+
},
|
|
2465
|
+
},
|
|
2466
|
+
{
|
|
2467
|
+
id: 'delete-folder',
|
|
2468
|
+
label: 'Delete folder',
|
|
2469
|
+
destructive: true,
|
|
2470
|
+
onSelect: () => {
|
|
2471
|
+
rowMenu.close();
|
|
2472
|
+
onDeleteFolder(menuExtra.dirPath, menuExtra.dirPath.split('/').pop());
|
|
2473
|
+
},
|
|
2474
|
+
},
|
|
2475
|
+
]
|
|
2476
|
+
: [];
|
|
2477
|
+
const rowMenuDestinations =
|
|
2478
|
+
menuExtra?.kind === 'file'
|
|
2479
|
+
? destinations.filter((d) => d.path !== menuExtra.dir)
|
|
2480
|
+
: [];
|
|
2481
|
+
|
|
2218
2482
|
// Phase 22 — inline "new brief board" composer in the tree header. Click +,
|
|
2219
2483
|
// type a name, Enter to create (Esc cancels). The board opens active so it's
|
|
2220
2484
|
// ready to annotate; generation (ingest) still goes through /design:new.
|
|
2485
|
+
// feature-file-tree-drag-drop-folders (Task 12) — the SAME composer, in
|
|
2486
|
+
// 'folder' mode, backs the header "New folder" button — the plan's
|
|
2487
|
+
// InlineComposer intent (one composer, two submit paths) without a full
|
|
2488
|
+
// component extraction: `composerMode` picks the placeholder + handler.
|
|
2221
2489
|
const [creating, setCreating] = useState(false);
|
|
2490
|
+
const [composerMode, setComposerMode] = useState('board');
|
|
2222
2491
|
const [newName, setNewName] = useState('');
|
|
2223
2492
|
const [newErr, setNewErr] = useState('');
|
|
2224
2493
|
const [newBusy, setNewBusy] = useState(false);
|
|
2225
2494
|
|
|
2226
|
-
|
|
2495
|
+
// Default folder-creation target: the first non-DS canvas group's root
|
|
2496
|
+
// (mirrors the server's own `newCanvasDir` default for board creation).
|
|
2497
|
+
const defaultFolderParent = useMemo(
|
|
2498
|
+
() => groups.find((g) => g.kind === 'canvas' && g.label !== 'Design system')?.fullPath,
|
|
2499
|
+
[groups]
|
|
2500
|
+
);
|
|
2501
|
+
|
|
2502
|
+
const submitComposer = useCallback(async () => {
|
|
2227
2503
|
const name = newName.trim();
|
|
2228
2504
|
if (!name || newBusy) return;
|
|
2229
2505
|
setNewBusy(true);
|
|
2230
2506
|
setNewErr('');
|
|
2507
|
+
if (composerMode === 'folder') {
|
|
2508
|
+
if (!defaultFolderParent) {
|
|
2509
|
+
setNewBusy(false);
|
|
2510
|
+
setNewErr('no canvas group to create a folder in');
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
const res = await onNewFolder(defaultFolderParent, name);
|
|
2514
|
+
setNewBusy(false);
|
|
2515
|
+
if (res?.ok) {
|
|
2516
|
+
setCreating(false);
|
|
2517
|
+
setNewName('');
|
|
2518
|
+
} else {
|
|
2519
|
+
setNewErr(res?.error || 'could not create folder');
|
|
2520
|
+
}
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2231
2523
|
const res = await onNewBoard(name);
|
|
2232
2524
|
setNewBusy(false);
|
|
2233
2525
|
if (res?.ok) {
|
|
@@ -2236,7 +2528,7 @@ function Sidebar({
|
|
|
2236
2528
|
} else {
|
|
2237
2529
|
setNewErr(res?.error || 'could not create board');
|
|
2238
2530
|
}
|
|
2239
|
-
}, [newName, newBusy, onNewBoard]);
|
|
2531
|
+
}, [newName, newBusy, onNewBoard, onNewFolder, composerMode, defaultFolderParent]);
|
|
2240
2532
|
|
|
2241
2533
|
// Mock uses `42 / 42` — total openable canvases, not every listed file.
|
|
2242
2534
|
// We count canvas files (TSX Phase 3.6+ default, HTML legacy) so the counter
|
|
@@ -2268,14 +2560,32 @@ function Sidebar({
|
|
|
2268
2560
|
className="st-iconbtn"
|
|
2269
2561
|
data-tip="New blank brief board"
|
|
2270
2562
|
aria-label="New blank brief board"
|
|
2271
|
-
aria-expanded={creating}
|
|
2563
|
+
aria-expanded={creating && composerMode === 'board'}
|
|
2272
2564
|
onClick={() => {
|
|
2273
2565
|
setNewErr('');
|
|
2274
|
-
|
|
2566
|
+
setComposerMode('board');
|
|
2567
|
+
setCreating((v) => (composerMode === 'board' ? !v : true));
|
|
2275
2568
|
}}
|
|
2276
2569
|
>
|
|
2277
2570
|
<StIcon name="plus" size={15} />
|
|
2278
2571
|
</button>
|
|
2572
|
+
{defaultFolderParent && (
|
|
2573
|
+
<button
|
|
2574
|
+
type="button"
|
|
2575
|
+
className="st-iconbtn"
|
|
2576
|
+
data-tip="New folder"
|
|
2577
|
+
aria-label="New folder"
|
|
2578
|
+
data-testid="tree-new-folder"
|
|
2579
|
+
aria-expanded={creating && composerMode === 'folder'}
|
|
2580
|
+
onClick={() => {
|
|
2581
|
+
setNewErr('');
|
|
2582
|
+
setComposerMode('folder');
|
|
2583
|
+
setCreating((v) => (composerMode === 'folder' ? !v : true));
|
|
2584
|
+
}}
|
|
2585
|
+
>
|
|
2586
|
+
<StIcon name="folder-plus" size={15} />
|
|
2587
|
+
</button>
|
|
2588
|
+
)}
|
|
2279
2589
|
{onRefresh && (
|
|
2280
2590
|
<button
|
|
2281
2591
|
type="button"
|
|
@@ -2316,16 +2626,16 @@ function Sidebar({
|
|
|
2316
2626
|
type="text"
|
|
2317
2627
|
// biome-ignore lint/a11y/noAutofocus: deliberate — the composer opens on an explicit click.
|
|
2318
2628
|
autoFocus
|
|
2319
|
-
placeholder=
|
|
2629
|
+
placeholder={composerMode === 'folder' ? 'folder name…' : 'brief board name…'}
|
|
2320
2630
|
value={newName}
|
|
2321
2631
|
maxLength={60}
|
|
2322
2632
|
disabled={newBusy}
|
|
2323
|
-
aria-label=
|
|
2633
|
+
aria-label={composerMode === 'folder' ? 'New folder name' : 'New brief board name'}
|
|
2324
2634
|
onChange={(e) => setNewName(e.target.value)}
|
|
2325
2635
|
onKeyDown={(e) => {
|
|
2326
2636
|
if (e.key === 'Enter') {
|
|
2327
2637
|
e.preventDefault();
|
|
2328
|
-
|
|
2638
|
+
submitComposer();
|
|
2329
2639
|
} else if (e.key === 'Escape') {
|
|
2330
2640
|
e.preventDefault();
|
|
2331
2641
|
setCreating(false);
|
|
@@ -2339,8 +2649,8 @@ function Sidebar({
|
|
|
2339
2649
|
className="st-newboard-go"
|
|
2340
2650
|
disabled={newBusy || !newName.trim()}
|
|
2341
2651
|
data-tip="Create · Enter"
|
|
2342
|
-
aria-label=
|
|
2343
|
-
onClick={
|
|
2652
|
+
aria-label={composerMode === 'folder' ? 'Create folder' : 'Create brief board'}
|
|
2653
|
+
onClick={submitComposer}
|
|
2344
2654
|
>
|
|
2345
2655
|
{newBusy ? '…' : '↵'}
|
|
2346
2656
|
</button>
|
|
@@ -2410,14 +2720,24 @@ function Sidebar({
|
|
|
2410
2720
|
const explicit = sectionsExpanded[g.label];
|
|
2411
2721
|
// Active search forces every section open so hits aren't hidden.
|
|
2412
2722
|
const sectionOpen = !!search || (explicit === undefined ? defaultOpen : explicit);
|
|
2723
|
+
// feature-file-tree-drag-drop-folders (dogfood follow-up) — the
|
|
2724
|
+
// section header IS the drop target for "move back to this group's
|
|
2725
|
+
// root". Without this a canvas inside a folder could never return
|
|
2726
|
+
// to the top level via drag & drop — DirRow only covers actual
|
|
2727
|
+
// subfolders, never the group root itself.
|
|
2728
|
+
const canDropOnRoot = !isDs && g.kind === 'canvas';
|
|
2729
|
+
const rootDropHandlers = canDropOnRoot ? treeDrag.dropProps(g.fullPath, true) : {};
|
|
2730
|
+
const isRootOver = treeDrag.overDir === g.fullPath;
|
|
2413
2731
|
return (
|
|
2414
2732
|
<div className="st-tree-section" key={g.label}>
|
|
2415
2733
|
<button
|
|
2416
2734
|
type="button"
|
|
2417
|
-
className=
|
|
2735
|
+
className={'st-tree-sec-hd' + (isRootOver ? ' is-drop-target' : '')}
|
|
2418
2736
|
onClick={() => onToggleSection(g.label, defaultOpen)}
|
|
2419
2737
|
aria-expanded={sectionOpen}
|
|
2738
|
+
aria-dropeffect={canDropOnRoot ? 'move' : undefined}
|
|
2420
2739
|
title={sectionOpen ? 'Collapse section' : 'Expand section'}
|
|
2740
|
+
{...rootDropHandlers}
|
|
2421
2741
|
>
|
|
2422
2742
|
<StIcon name="chevron-right" className={'st-chev' + (sectionOpen ? ' is-open' : '')} size={13} />
|
|
2423
2743
|
<span className="st-sec-name">{meta.title}</span>
|
|
@@ -2440,6 +2760,9 @@ function Sidebar({
|
|
|
2440
2760
|
onDelete={isDs ? undefined : onDeleteBoard}
|
|
2441
2761
|
dirtyByPath={dirtyByPath}
|
|
2442
2762
|
canvasKinds={canvasKinds}
|
|
2763
|
+
dirPath={g.fullPath}
|
|
2764
|
+
drag={!isDs && g.kind === 'canvas' ? treeDrag : undefined}
|
|
2765
|
+
menu={!isDs && g.kind === 'canvas' ? rowMenu : undefined}
|
|
2443
2766
|
/>
|
|
2444
2767
|
) : (
|
|
2445
2768
|
<div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
|
|
@@ -2448,10 +2771,21 @@ function Sidebar({
|
|
|
2448
2771
|
);
|
|
2449
2772
|
})}
|
|
2450
2773
|
</div>
|
|
2774
|
+
<TreeRowMenu
|
|
2775
|
+
state={rowMenu.state}
|
|
2776
|
+
onClose={rowMenu.close}
|
|
2777
|
+
rootItems={rowMenuRootItems}
|
|
2778
|
+
destinations={rowMenuDestinations}
|
|
2779
|
+
onPickDestination={(dest) => onMoveCanvas(menuExtra.path, dest)}
|
|
2780
|
+
/>
|
|
2451
2781
|
{/* Phase 29 (E4) — the project + draft switcher: a compact one-line dock that
|
|
2452
2782
|
opens UPWARD, sitting directly above the GitHub identity avatar so the two
|
|
2453
2783
|
form one bottom dock. Renders nothing until the project is a git repo. */}
|
|
2454
2784
|
<RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} />
|
|
2785
|
+
{/* Cloud Phase 23 C3 — Maude Cloud sign-in + remote-project attach, docked
|
|
2786
|
+
above the GitHub identity. Dev-server-backed, so it works in the desktop
|
|
2787
|
+
shell AND a plain browser. */}
|
|
2788
|
+
<CloudBar />
|
|
2455
2789
|
{/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
|
|
2456
2790
|
sign in, connected account + New/Pull/Share, sign out. Self-contained
|
|
2457
2791
|
(owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
|
|
@@ -9865,7 +10199,7 @@ function App() {
|
|
|
9865
10199
|
setProject(data.project || 'Design');
|
|
9866
10200
|
const built = data.groups.map((g) => ({
|
|
9867
10201
|
...g,
|
|
9868
|
-
tree: buildTree(g.paths, g.stripPrefix),
|
|
10202
|
+
tree: buildTree(g.paths, g.stripPrefix, g.dirs),
|
|
9869
10203
|
}));
|
|
9870
10204
|
setGroups(built);
|
|
9871
10205
|
// DDR-093 — fold the server-resolved per-canvas DS map into cfg so
|
|
@@ -10017,6 +10351,22 @@ function App() {
|
|
|
10017
10351
|
// the branch-scoped tree so other open tabs reflect it without a
|
|
10018
10352
|
// reload. Cross-machine peers get a new canvas via git "Get latest".
|
|
10019
10353
|
loadTree();
|
|
10354
|
+
// feature-file-tree-drag-drop-folders (Task 10) — a canvas moved.
|
|
10355
|
+
// moveCanvasReq already retargets the INITIATING tab locally (no
|
|
10356
|
+
// need to wait for this broadcast to round-trip); this branch is
|
|
10357
|
+
// for every OTHER open tab on the same dev-server, so a canvas
|
|
10358
|
+
// that was open there doesn't go dead pointing at a path that no
|
|
10359
|
+
// longer exists.
|
|
10360
|
+
if (m.payload?.action === 'moved' && m.payload.fromRel && m.payload.rel) {
|
|
10361
|
+
const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
|
|
10362
|
+
/^\/+|\/+$/g,
|
|
10363
|
+
''
|
|
10364
|
+
);
|
|
10365
|
+
const fromFile = `${designRel}/${m.payload.fromRel}`;
|
|
10366
|
+
const toFile = `${designRel}/${m.payload.rel}`;
|
|
10367
|
+
setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
|
|
10368
|
+
setActivePath((prev) => (prev === fromFile ? toFile : prev));
|
|
10369
|
+
}
|
|
10020
10370
|
} else if (m.type === 'config-updated') {
|
|
10021
10371
|
// Server hot-reloaded .design/config.json (/design:setup-ds rewrote
|
|
10022
10372
|
// it) — refetch /_config so designSystems / tokensCssRel / groups
|
|
@@ -10505,6 +10855,110 @@ function App() {
|
|
|
10505
10855
|
[loadTree, activePath]
|
|
10506
10856
|
);
|
|
10507
10857
|
|
|
10858
|
+
// feature-file-tree-drag-drop-folders (Task 8/10) — drag-drop AND the
|
|
10859
|
+
// context-menu "Move to…" both funnel through this one function. POSTs the
|
|
10860
|
+
// main-origin-only /_api/fs-move, then (only AFTER the server ack — no
|
|
10861
|
+
// optimistic UI, per rca/issue-canvas-hmr-optimistic-update-consistency)
|
|
10862
|
+
// reloads the tree and retargets any open tab / the active path so a
|
|
10863
|
+
// dragged-and-open canvas doesn't go dead. Toasts with an inverse-move
|
|
10864
|
+
// Undo. Named function expression so the Undo click can call itself.
|
|
10865
|
+
const moveCanvasReq = useCallback(
|
|
10866
|
+
async function moveCanvasReq(fromPath, toDir) {
|
|
10867
|
+
try {
|
|
10868
|
+
const r = await fetch('/_api/fs-move', {
|
|
10869
|
+
method: 'POST',
|
|
10870
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10871
|
+
body: JSON.stringify({ file: fromPath, toDir }),
|
|
10872
|
+
});
|
|
10873
|
+
const j = await r.json().catch(() => ({}));
|
|
10874
|
+
if (!r.ok || !j.ok) {
|
|
10875
|
+
shellToast(`Could not move: ${j.error || `error ${r.status}`}`);
|
|
10876
|
+
return { ok: false, error: j.error };
|
|
10877
|
+
}
|
|
10878
|
+
const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
|
|
10879
|
+
/^\/+|\/+$/g,
|
|
10880
|
+
''
|
|
10881
|
+
);
|
|
10882
|
+
const fromFile = `${designRel}/${j.fromRel}`;
|
|
10883
|
+
const toFile = `${designRel}/${j.toRel}`;
|
|
10884
|
+
await loadTree();
|
|
10885
|
+
setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
|
|
10886
|
+
setActivePath((prev) => (prev === fromFile ? toFile : prev));
|
|
10887
|
+
const fromDir = fromFile.split('/').slice(0, -1).join('/');
|
|
10888
|
+
shellToast(`Moved to ${j.toRel.split('/').slice(0, -1).join('/') || '.'}`, true, {
|
|
10889
|
+
label: 'Undo',
|
|
10890
|
+
onClick: () => {
|
|
10891
|
+
moveCanvasReq(toFile, fromDir);
|
|
10892
|
+
},
|
|
10893
|
+
});
|
|
10894
|
+
return { ok: true };
|
|
10895
|
+
} catch (e) {
|
|
10896
|
+
shellToast(`Move failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
10897
|
+
return { ok: false, error: 'network error' };
|
|
10898
|
+
}
|
|
10899
|
+
},
|
|
10900
|
+
[loadTree, cfg]
|
|
10901
|
+
);
|
|
10902
|
+
|
|
10903
|
+
// feature-file-tree-drag-drop-folders (Task 4/9/12) — create a folder under
|
|
10904
|
+
// `parentDir`. Two callers: the sidebar header composer (name from its own
|
|
10905
|
+
// input state) and the tree-row context menu's "New folder here" (name via
|
|
10906
|
+
// `window.prompt` — a native, fully keyboard/screen-reader-operable input,
|
|
10907
|
+
// so that path doesn't need its own inline composer instance).
|
|
10908
|
+
const newFolderReq = useCallback(
|
|
10909
|
+
async (parentDir, name) => {
|
|
10910
|
+
try {
|
|
10911
|
+
const r = await fetch('/_api/fs-mkdir', {
|
|
10912
|
+
method: 'POST',
|
|
10913
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10914
|
+
body: JSON.stringify({ parent: parentDir, name }),
|
|
10915
|
+
});
|
|
10916
|
+
const j = await r.json().catch(() => ({}));
|
|
10917
|
+
if (!r.ok || !j.ok) {
|
|
10918
|
+
const error = j.error || `error ${r.status}`;
|
|
10919
|
+
shellToast(`Could not create folder: ${error}`);
|
|
10920
|
+
return { ok: false, error };
|
|
10921
|
+
}
|
|
10922
|
+
await loadTree();
|
|
10923
|
+
return { ok: true, dir: j.dir };
|
|
10924
|
+
} catch (e) {
|
|
10925
|
+
const error = e instanceof Error ? e.message : 'network error';
|
|
10926
|
+
shellToast(`Create folder failed: ${error}`);
|
|
10927
|
+
return { ok: false, error };
|
|
10928
|
+
}
|
|
10929
|
+
},
|
|
10930
|
+
[loadTree]
|
|
10931
|
+
);
|
|
10932
|
+
|
|
10933
|
+
// feature-file-tree-drag-drop-folders (dogfood follow-up) — delete a
|
|
10934
|
+
// folder from the tree-row context menu. Reuses DELETE /_api/canvas (the
|
|
10935
|
+
// route now auto-detects a non-.tsx target as a folder delete); every
|
|
10936
|
+
// canvas inside is trashed the same recoverable way as a single-canvas
|
|
10937
|
+
// delete (`.design/_trash/<stamp>__<slug>/`).
|
|
10938
|
+
const deleteFolderReq = useCallback(
|
|
10939
|
+
async (dirPath, label) => {
|
|
10940
|
+
const ok = window.confirm(
|
|
10941
|
+
`Delete folder "${label}"?\n\nEvery canvas inside moves to trash (recoverable from .design/_trash/); ` +
|
|
10942
|
+
`any empty subfolders are removed.`
|
|
10943
|
+
);
|
|
10944
|
+
if (!ok) return;
|
|
10945
|
+
try {
|
|
10946
|
+
const r = await fetch(`/_api/canvas?file=${encodeURIComponent(dirPath)}`, {
|
|
10947
|
+
method: 'DELETE',
|
|
10948
|
+
});
|
|
10949
|
+
const j = await r.json().catch(() => ({}));
|
|
10950
|
+
if (!r.ok || !j.ok) {
|
|
10951
|
+
shellToast(`Could not delete folder: ${j.error || `error ${r.status}`}`);
|
|
10952
|
+
return;
|
|
10953
|
+
}
|
|
10954
|
+
await loadTree();
|
|
10955
|
+
} catch (e) {
|
|
10956
|
+
shellToast(`Delete folder failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
10957
|
+
}
|
|
10958
|
+
},
|
|
10959
|
+
[loadTree]
|
|
10960
|
+
);
|
|
10961
|
+
|
|
10508
10962
|
const clearSelected = useCallback(() => {
|
|
10509
10963
|
wsSend({ type: 'clear-select' });
|
|
10510
10964
|
setSelected(null);
|
|
@@ -12908,6 +13362,9 @@ function App() {
|
|
|
12908
13362
|
onToggleSection={toggleSection}
|
|
12909
13363
|
onNewBoard={createBoard}
|
|
12910
13364
|
onDeleteBoard={deleteBoard}
|
|
13365
|
+
onMoveCanvas={moveCanvasReq}
|
|
13366
|
+
onNewFolder={newFolderReq}
|
|
13367
|
+
onDeleteFolder={deleteFolderReq}
|
|
12911
13368
|
onRefresh={refreshTree}
|
|
12912
13369
|
refreshing={treeRefreshing}
|
|
12913
13370
|
collapsed={false}
|