@1agh/maude 0.49.2 → 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.
Files changed (34) hide show
  1. package/apps/studio/api.ts +751 -52
  2. package/apps/studio/canvas-artifacts.ts +153 -0
  3. package/apps/studio/canvas-create.ts +23 -0
  4. package/apps/studio/canvas-slug.ts +30 -0
  5. package/apps/studio/client/app.jsx +547 -90
  6. package/apps/studio/client/panels/CloudBar.jsx +284 -0
  7. package/apps/studio/client/styles/3-shell-maude.css +26 -0
  8. package/apps/studio/client/tree-row-menu.jsx +132 -0
  9. package/apps/studio/client/use-tree-drag.js +120 -0
  10. package/apps/studio/cloud/endpoints.ts +236 -0
  11. package/apps/studio/collab/registry.ts +23 -0
  12. package/apps/studio/dist/client.bundle.js +960 -958
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/http.ts +113 -0
  15. package/apps/studio/inspect.ts +49 -0
  16. package/apps/studio/server.ts +15 -0
  17. package/apps/studio/test/canvas-artifacts.test.ts +125 -0
  18. package/apps/studio/test/canvas-create-api.test.ts +159 -1
  19. package/apps/studio/test/canvas-move-api.test.ts +556 -0
  20. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  21. package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
  22. package/apps/studio/whats-new.json +18 -0
  23. package/cli/bin/maude.mjs +1 -0
  24. package/cli/commands/hub-workspace.mjs +294 -7
  25. package/cli/commands/init.mjs +33 -2
  26. package/cli/commands/kg.mjs +129 -4
  27. package/cli/commands/share.mjs +184 -0
  28. package/cli/lib/ddr-to-kgai.mjs +310 -82
  29. package/cli/lib/ddr-to-kgai.test.mjs +20 -0
  30. package/cli/lib/share-plan.mjs +96 -0
  31. package/cli/lib/share-plan.test.mjs +57 -0
  32. package/cli/lib/workspace-plan.mjs +130 -12
  33. package/cli/lib/workspace-plan.test.mjs +160 -0
  34. 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
- function shellToast(message, ok = false) {
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;border-radius:8px;font:12px/1.45 var(--font-mono,monospace);' +
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 = message;
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
- el.style.opacity = '0';
275
- }, 3200);
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
- function buildTree(paths, stripPrefix) {
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
- <button
1792
- type="button"
1793
- role="treeitem"
1794
- aria-expanded={open}
1795
- tabIndex={-1}
1796
- className="st-row"
1797
- style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
1798
- onClick={() => setOpen((v) => !v)}
1799
- >
1800
- <span className="st-row-glyph">
1801
- <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
1802
- </span>
1803
- <span className="st-row-name">{name}</span>
1804
- </button>
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' + (isSel ? ' is-sel' : '') + (kind === 'runtime' ? ' is-muted' : '')
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
- // A sibling delete button (can't nest a button in the row button). The wrapper
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
- <button
1930
- type="button"
1931
- className="st-row-del"
1932
- title={`Delete ${label}`}
1933
- aria-label={`Delete canvas ${label}`}
1934
- onClick={(e) => {
1935
- e.stopPropagation();
1936
- onDelete(file.path, label);
1937
- }}
1938
- >
1939
- <Icon d="M3 6h18 M8 6V4h8v2 M6 6l1 14h10l1-14 M10 11v6 M14 11v6" size={12} />
1940
- </button>
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
- return (
1984
- <Fragment>
1985
- <button
1986
- type="button"
1987
- role="treeitem"
1988
- aria-selected={isSel}
1989
- aria-expanded={open}
1990
- tabIndex={isSel ? 0 : -1}
1991
- className={'st-row st-canvas-row' + (isSel ? ' is-sel' : '')}
1992
- style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
1993
- title={primary.path}
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
- // Click the chevron region → toggle disclosure. Click anywhere else → open canvas.
1996
- if (e.target.closest('.st-canvas-chev')) {
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-glyph st-canvas-chev"
2005
- onClick={(e) => {
2006
- e.stopPropagation();
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
- <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
2183
+ exp
2011
2184
  </span>
2012
- <span className="st-row-name">{displayName(primary.name)}</span>
2013
- {experimentalKind === 'reconstructed-experimental' && (
2014
- <span
2015
- className="st-row-exp-badge"
2016
- title="Reconstructed from an image via /design:import --reconstruct — experimental, lossy, review before trusting (DDR-174)"
2017
- aria-label="Reconstructed, experimental"
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
- exp
2020
- </span>
2021
- )}
2022
- {dirty && (
2023
- <span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
2024
- {dirty}
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 key={d} name={d} depth={depth} defaultOpen={true}>
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
- const submitNewBoard = useCallback(async () => {
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
- setCreating((v) => !v);
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="brief board name…"
2629
+ placeholder={composerMode === 'folder' ? 'folder name…' : 'brief board name…'}
2320
2630
  value={newName}
2321
2631
  maxLength={60}
2322
2632
  disabled={newBusy}
2323
- aria-label="New brief board name"
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
- submitNewBoard();
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="Create brief board"
2343
- onClick={submitNewBoard}
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="st-tree-sec-hd"
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}