@1agh/maude 0.49.2 → 0.51.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 (39) 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 +388 -0
  7. package/apps/studio/client/styles/3-shell-maude.css +28 -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 +284 -0
  11. package/apps/studio/collab/registry.ts +23 -0
  12. package/apps/studio/debug-bundle.ts +144 -0
  13. package/apps/studio/dist/client.bundle.js +1235 -1228
  14. package/apps/studio/dist/styles.css +1 -1
  15. package/apps/studio/http.ts +205 -1
  16. package/apps/studio/inspect.ts +49 -0
  17. package/apps/studio/server.ts +15 -0
  18. package/apps/studio/sync/workspace-signin.ts +15 -0
  19. package/apps/studio/test/canvas-artifacts.test.ts +125 -0
  20. package/apps/studio/test/canvas-create-api.test.ts +159 -1
  21. package/apps/studio/test/canvas-move-api.test.ts +556 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  23. package/apps/studio/test/cloud-endpoints.test.ts +130 -0
  24. package/apps/studio/test/debug-bundle.test.ts +105 -0
  25. package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
  26. package/apps/studio/test/workspace-signin.test.ts +19 -0
  27. package/apps/studio/whats-new.json +27 -0
  28. package/cli/bin/maude.mjs +1 -0
  29. package/cli/commands/hub-workspace.mjs +294 -7
  30. package/cli/commands/init.mjs +33 -2
  31. package/cli/commands/kg.mjs +129 -4
  32. package/cli/commands/share.mjs +184 -0
  33. package/cli/lib/ddr-to-kgai.mjs +310 -82
  34. package/cli/lib/ddr-to-kgai.test.mjs +20 -0
  35. package/cli/lib/share-plan.mjs +96 -0
  36. package/cli/lib/share-plan.test.mjs +57 -0
  37. package/cli/lib/workspace-plan.mjs +130 -12
  38. package/cli/lib/workspace-plan.test.mjs +160 -0
  39. package/package.json +9 -8
@@ -0,0 +1,388 @@
1
+ // Cloud Phase 23 C3 — Maude Cloud in the sidebar footer.
2
+ //
3
+ // The same compact rail treatment as the GitHub IdentityBar directly below
4
+ // it, and deliberately the same `gi-*` chrome (one material, one dialog
5
+ // family). Differences that matter:
6
+ //
7
+ // • Works in BOTH the desktop shell and a plain browser: the whole lane is
8
+ // the dev-server's loopback `/_api/cloud/*` — no Tauri, no keychain, no
9
+ // CORS. The credential never enters this JS; the server holds it.
10
+ // • Sign-in is OUR device flow: the dashboard shows /activate, the person
11
+ // types (or arrives with) the short code, this panel just polls.
12
+ // • "Connect" attaches THIS project to the chosen cloud workspace — the
13
+ // exact state `maude design link` writes, minus the terminal.
14
+
15
+ import { useEffect, useRef, useState } from 'react';
16
+
17
+ import { invoke, isNativeApp, listen } from '../github.js';
18
+
19
+ const api = async (path, init) => {
20
+ const res = await fetch(path, init);
21
+ const json = await res.json().catch(() => ({}));
22
+ return { ok: res.ok, status: res.status, json };
23
+ };
24
+
25
+ /**
26
+ * Parse a maude://open/<project>?code=mhc_… deep link. Untrusted input — a
27
+ * drive-by page can navigate to maude:// — so nothing here is acted on
28
+ * without the person pressing Connect, and the code is only ever exchanged
29
+ * against the app's own configured Maude Cloud address.
30
+ */
31
+ export function parseDeepLink(url) {
32
+ const m = /^maude:\/\/open\/([a-z0-9]+(?:-[a-z0-9]+)*)\/?\?(.*)$/.exec(String(url ?? ''));
33
+ if (!m) return null;
34
+ const code = new URLSearchParams(m[2]).get('code') ?? '';
35
+ if (!/^mhc_[0-9a-f]{16,128}$/.test(code)) return null;
36
+ return { project: m[1], code };
37
+ }
38
+
39
+ /** The share view's address for a project cell URL (viewer's browser home). */
40
+ export function shareViewUrl(projectUrl, projectId) {
41
+ try {
42
+ const u = new URL(projectUrl);
43
+ return `${u.protocol}//view-${u.host}`;
44
+ } catch {
45
+ return `https://view-${projectId}.cloud.maude.sh`;
46
+ }
47
+ }
48
+
49
+ function Spark({ size = 15 }) {
50
+ return (
51
+ <svg width={size} height={size} viewBox="0 0 32 32" fill="currentColor" aria-hidden="true">
52
+ <path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" />
53
+ </svg>
54
+ );
55
+ }
56
+
57
+ function Icon({ name, size = 15 }) {
58
+ const p = {
59
+ 'chevron-up': <polyline points="3.5 10 8 5.5 12.5 10" />,
60
+ external: (
61
+ <>
62
+ <path d="M6 3.5H3.2A.7.7 0 0 0 2.5 4.2v8.6a.7.7 0 0 0 .7.7h8.6a.7.7 0 0 0 .7-.7V10" />
63
+ <line x1="8" y1="8" x2="13" y2="3" />
64
+ <polyline points="9.5 3 13 3 13 6.5" />
65
+ </>
66
+ ),
67
+ copy: (
68
+ <>
69
+ <rect x="5.5" y="5.5" width="7.5" height="7.5" rx="1.2" />
70
+ <path d="M3 10.5V3.2A.7.7 0 0 1 3.7 2.5H10" />
71
+ </>
72
+ ),
73
+ link: (
74
+ <>
75
+ <path d="M6.5 9.5l3-3" />
76
+ <path d="M7.5 4.5l1-1a2.47 2.47 0 0 1 3.5 3.5l-1 1" />
77
+ <path d="M8.5 11.5l-1 1a2.47 2.47 0 0 1-3.5-3.5l1-1" />
78
+ </>
79
+ ),
80
+ signout: (
81
+ <>
82
+ <path d="M6.5 13.5H3.2a.7.7 0 0 1-.7-.7V3.2a.7.7 0 0 1 .7-.7h3.3" />
83
+ <line x1="13" y1="8" x2="6.5" y2="8" />
84
+ <polyline points="10 5 13 8 10 11" />
85
+ </>
86
+ ),
87
+ }[name];
88
+ return (
89
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
90
+ {p}
91
+ </svg>
92
+ );
93
+ }
94
+
95
+ export default function CloudBar() {
96
+ const [state, setState] = useState('loading'); // loading | out | in
97
+ const [email, setEmail] = useState(null);
98
+ const [menuOpen, setMenuOpen] = useState(false);
99
+ const [device, setDevice] = useState(null); // { userCode, verificationUrl, deviceCode }
100
+ const [projects, setProjects] = useState(null);
101
+ const [busy, setBusy] = useState('');
102
+ const [note, setNote] = useState('');
103
+ const [error, setError] = useState('');
104
+ const [copied, setCopied] = useState(false);
105
+ const [pending, setPending] = useState(null); // { project, code } from maude://
106
+ const railRef = useRef(null);
107
+ const pollRef = useRef(null);
108
+
109
+ useEffect(() => {
110
+ api('/_api/cloud/status').then((r) => {
111
+ if (r.ok && r.json?.connected) {
112
+ setEmail(r.json.email);
113
+ setState('in');
114
+ } else setState('out');
115
+ });
116
+ return () => clearInterval(pollRef.current);
117
+ }, []);
118
+
119
+ // maude:// deep links (Phase 17) — native shell only. The link that LAUNCHED
120
+ // the app is parked in the shell (take once); links while running arrive as
121
+ // events. Either way: ask, never auto-connect.
122
+ useEffect(() => {
123
+ if (!isNativeApp()) return undefined;
124
+ const consume = (url) => {
125
+ const parsed = parseDeepLink(url);
126
+ if (parsed) setPending(parsed);
127
+ };
128
+ invoke('take_pending_deep_link')
129
+ .then((url) => url && consume(url))
130
+ .catch(() => {});
131
+ let unlisten = null;
132
+ listen('maude://deep-link', consume).then((fn) => {
133
+ unlisten = fn;
134
+ });
135
+ return () => unlisten?.();
136
+ }, []);
137
+
138
+ async function connectPending() {
139
+ if (!pending) return;
140
+ setBusy('deeplink');
141
+ setError('');
142
+ const r = await api('/_api/cloud/attach/code', {
143
+ method: 'POST',
144
+ headers: { 'content-type': 'application/json' },
145
+ body: JSON.stringify({ code: pending.code }),
146
+ });
147
+ setBusy('');
148
+ setPending(null);
149
+ if (r.ok && r.json?.ok) {
150
+ setNote(`Linked to ${r.json.project ?? pending.project} — restart the studio server to start syncing.`);
151
+ } else {
152
+ setError(r.json?.error || 'The workspace could not be connected.');
153
+ }
154
+ }
155
+
156
+ useEffect(() => {
157
+ if (!menuOpen) return undefined;
158
+ const onDoc = (e) => {
159
+ if (railRef.current && !railRef.current.contains(e.target)) setMenuOpen(false);
160
+ };
161
+ document.addEventListener('mousedown', onDoc);
162
+ return () => document.removeEventListener('mousedown', onDoc);
163
+ }, [menuOpen]);
164
+
165
+ async function startSignIn() {
166
+ setError('');
167
+ const r = await api('/_api/cloud/signin/start', { method: 'POST' });
168
+ if (!r.ok || !r.json?.ok) {
169
+ setError(r.json?.error || 'Maude Cloud could not be reached.');
170
+ return;
171
+ }
172
+ setDevice(r.json);
173
+ window.open(r.json.verificationUrl, '_blank', 'noopener');
174
+ pollRef.current = setInterval(async () => {
175
+ const p = await api('/_api/cloud/signin/poll', {
176
+ method: 'POST',
177
+ headers: { 'content-type': 'application/json' },
178
+ body: JSON.stringify({ deviceCode: r.json.deviceCode }),
179
+ });
180
+ if (p.json?.pending) return;
181
+ clearInterval(pollRef.current);
182
+ if (p.ok && p.json?.ok) {
183
+ setEmail(p.json.email);
184
+ setState('in');
185
+ setDevice(null);
186
+ } else {
187
+ setDevice(null);
188
+ setError(p.json?.error || 'The sign-in did not finish. Try again.');
189
+ }
190
+ }, (r.json.interval ?? 5) * 1000);
191
+ }
192
+
193
+ function cancelSignIn() {
194
+ clearInterval(pollRef.current);
195
+ setDevice(null);
196
+ }
197
+
198
+ async function openMenu() {
199
+ const next = !menuOpen;
200
+ setMenuOpen(next);
201
+ if (next && projects === null) {
202
+ const r = await api('/_api/cloud/projects');
203
+ setProjects(r.ok && r.json?.ok ? r.json.projects : []);
204
+ if (!r.ok && r.status === 401) {
205
+ setMenuOpen(false);
206
+ setState('out');
207
+ setEmail(null);
208
+ }
209
+ }
210
+ }
211
+
212
+ async function connect(projectId) {
213
+ setBusy(projectId);
214
+ setNote('');
215
+ setError('');
216
+ const r = await api('/_api/cloud/attach', {
217
+ method: 'POST',
218
+ headers: { 'content-type': 'application/json' },
219
+ body: JSON.stringify({ project: projectId }),
220
+ });
221
+ setBusy('');
222
+ if (r.ok && r.json?.ok) {
223
+ setNote(`Linked to ${projectId} — restart the studio server to start syncing.`);
224
+ } else {
225
+ setError(r.json?.error || 'The workspace could not be connected.');
226
+ }
227
+ setMenuOpen(false);
228
+ }
229
+
230
+ async function signOut() {
231
+ setMenuOpen(false);
232
+ await api('/_api/cloud/signout', { method: 'POST' });
233
+ setEmail(null);
234
+ setProjects(null);
235
+ setState('out');
236
+ }
237
+
238
+ function copyCode() {
239
+ if (!device?.userCode) return;
240
+ navigator.clipboard?.writeText(device.userCode).then(
241
+ () => {
242
+ setCopied(true);
243
+ setTimeout(() => setCopied(false), 1500);
244
+ },
245
+ () => {}
246
+ );
247
+ }
248
+
249
+ if (state === 'loading') return null;
250
+
251
+ return (
252
+ <div className="gi-rail cb-rail" ref={railRef} data-testid="cloud-bar">
253
+ {pending && (
254
+ <div className="gi-rail-confirm" data-testid="cloud-deeplink">
255
+ <span className="gi-rail-hint">Connect this project to {pending.project}?</span>
256
+ <button
257
+ type="button"
258
+ className="btn btn--sm"
259
+ disabled={busy === 'deeplink'}
260
+ onClick={connectPending}
261
+ data-testid="cloud-deeplink-connect"
262
+ >
263
+ {busy === 'deeplink' ? 'Connecting…' : 'Connect'}
264
+ </button>
265
+ <button
266
+ type="button"
267
+ className="btn btn--ghost btn--sm"
268
+ onClick={() => setPending(null)}
269
+ data-testid="cloud-deeplink-dismiss"
270
+ >
271
+ Not now
272
+ </button>
273
+ </div>
274
+ )}
275
+ {state === 'out' && (
276
+ <>
277
+ <button type="button" className="btn btn--ghost btn--sm gi-rail-signin" onClick={startSignIn} data-testid="cloud-signin">
278
+ <Spark size={14} /> Sign in to Maude Cloud
279
+ </button>
280
+ {error && <span className="gi-rail-err" title={error}>{error}</span>}
281
+ </>
282
+ )}
283
+
284
+ {state === 'in' && (
285
+ <>
286
+ <button
287
+ type="button"
288
+ className={'gi-rail-account' + (menuOpen ? ' is-open' : '')}
289
+ aria-expanded={menuOpen}
290
+ aria-haspopup="menu"
291
+ onClick={openMenu}
292
+ title={`Maude Cloud — ${email ?? 'connected'}`}
293
+ data-testid="cloud-account"
294
+ >
295
+ <span className="gi-avatar gi-avatar--fallback" aria-hidden="true"><Spark size={14} /></span>
296
+ <span className="gi-rail-login">{email ?? 'Maude Cloud'}</span>
297
+ <span className="gi-rail-caret"><Icon name="chevron-up" size={13} /></span>
298
+ </button>
299
+ {note && <span className="gi-rail-hint" title={note}>{note}</span>}
300
+ {error && <span className="gi-rail-err" title={error}>{error}</span>}
301
+ {menuOpen && (
302
+ <div className="gi-menu" role="menu" aria-label="Maude Cloud">
303
+ <div className="gi-menu-hd">
304
+ <span className="gi-avatar gi-avatar--fallback" aria-hidden="true"><Spark size={16} /></span>
305
+ <span className="gi-menu-id">
306
+ <span className="gi-menu-name">Maude Cloud</span>
307
+ <span className="gi-menu-login">{email ?? 'connected'}</span>
308
+ </span>
309
+ </div>
310
+ {projects === null && <div className="gi-menu-item" aria-disabled="true">Loading projects…</div>}
311
+ {Array.isArray(projects) && projects.length === 0 && (
312
+ <div className="gi-menu-item" aria-disabled="true">No projects yet — start one on the dashboard.</div>
313
+ )}
314
+ {Array.isArray(projects) &&
315
+ projects.map((p) =>
316
+ p.role === 'viewer' ? (
317
+ // Viewer dignity (Phase 17 T4): no dead Connect that would
318
+ // 403 at the workspace — viewing has a first-class home in
319
+ // the browser, and the label says why.
320
+ <button
321
+ key={p.id}
322
+ type="button"
323
+ className="gi-menu-item"
324
+ role="menuitem"
325
+ onClick={() => window.open(shareViewUrl(p.url, p.id), '_blank', 'noopener')}
326
+ title="Viewing works in the browser. Editing in the app needs the member role — ask whoever runs the project."
327
+ data-testid={`cloud-project-${p.id}`}
328
+ >
329
+ <Icon name="external" size={15} /> View {p.name || p.id} in the browser
330
+ <span className="gi-menu-login" style={{ marginLeft: 'auto' }}>viewer</span>
331
+ </button>
332
+ ) : (
333
+ <button
334
+ key={p.id}
335
+ type="button"
336
+ className="gi-menu-item"
337
+ role="menuitem"
338
+ disabled={busy === p.id}
339
+ onClick={() => connect(p.id)}
340
+ title={`Connect this project to ${p.url} (${p.role})`}
341
+ data-testid={`cloud-project-${p.id}`}
342
+ >
343
+ <Icon name="link" size={15} /> {busy === p.id ? 'Connecting…' : `Connect ${p.name || p.id}`}
344
+ <span className="gi-menu-login" style={{ marginLeft: 'auto' }}>{p.stateLabel}</span>
345
+ </button>
346
+ )
347
+ )}
348
+ <div className="gi-menu-sep" />
349
+ <button type="button" className="gi-menu-item" role="menuitem" onClick={() => window.open('https://cloud.maude.sh', '_blank', 'noopener')}>
350
+ <Icon name="external" size={15} /> Open the dashboard
351
+ </button>
352
+ <button type="button" className="gi-menu-item gi-menu-item--danger" role="menuitem" onClick={signOut}>
353
+ <Icon name="signout" size={15} /> Sign out
354
+ </button>
355
+ </div>
356
+ )}
357
+ </>
358
+ )}
359
+
360
+ {device && (
361
+ <div className="gi-modal" role="dialog" aria-modal="true" aria-label="Sign in to Maude Cloud" onKeyDown={(e) => { if (e.key === 'Escape') cancelSignIn(); }}>
362
+ <div className="gi-scrim" aria-hidden="true" onClick={cancelSignIn} />
363
+ <div className="gi-dialog gi-dialog--code" data-testid="cloud-device-dialog">
364
+ <div className="gi-dc-head">
365
+ <span className="gi-dc-marks"><Spark size={26} /></span>
366
+ <h2>Sign in to Maude Cloud</h2>
367
+ <p>Maude opened your dashboard in the browser. Confirm this code there to connect.</p>
368
+ </div>
369
+ <div className="gi-code">
370
+ <span className="gi-code-val" data-testid="cloud-user-code">{device.userCode}</span>
371
+ <button type="button" className="btn btn--ghost gi-code-copy" onClick={copyCode} aria-label="Copy the code">
372
+ <Icon name="copy" size={15} /> {copied ? 'Copied' : 'Copy'}
373
+ </button>
374
+ </div>
375
+ <div className="gi-dc-status" aria-live="polite">
376
+ <span className="gi-pulse" aria-hidden="true" />
377
+ <span>Waiting for you to confirm in the browser…</span>
378
+ </div>
379
+ <div className="gi-dc-foot">
380
+ <button type="button" className="btn btn--ghost" onClick={cancelSignIn}>Cancel</button>
381
+ <span className="gi-dc-foot-note">Nothing is stored until you confirm.</span>
382
+ </div>
383
+ </div>
384
+ </div>
385
+ )}
386
+ </div>
387
+ );
388
+ }
@@ -281,6 +281,10 @@
281
281
  appearance: none; border: 0; background: transparent; text-align: left;
282
282
  }
283
283
  .st-tree-sec-hd:hover { color: var(--fg-0); }
284
+ /* feature-file-tree-drag-drop-folders (dogfood follow-up) — the section
285
+ header is the drop target for "move back to this group's root". Same
286
+ accent language as .st-row.is-drop-target. */
287
+ .st-tree-sec-hd.is-drop-target { background: var(--accent-tint); color: var(--fg-0); box-shadow: inset 2px 0 0 var(--accent); }
284
288
  .st-tree-sec-hd .st-sec-name { font-family: var(--font-mono); font-size: var(--type-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
285
289
  .st-tree-sec-hd .st-pill {
286
290
  margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--fg-3);
@@ -298,6 +302,15 @@
298
302
  .st-row.is-sel { background: var(--accent-tint); color: var(--fg-0); box-shadow: inset 2px 0 0 var(--accent); }
299
303
  .st-row.is-muted { color: var(--fg-3); }
300
304
  .st-row[aria-disabled="true"] { cursor: default; }
305
+ /* feature-file-tree-drag-drop-folders (Task 8) — a valid drop target reuses
306
+ the exact .st-row.is-sel treatment (same accent language: "this is where
307
+ it lands"), the dragged source dims, and a busy row (server round-trip
308
+ in flight) dims further + suspends pointer events so it can't be re-dragged
309
+ mid-move. */
310
+ .st-row.is-drop-target { background: var(--accent-tint); color: var(--fg-0); box-shadow: inset 2px 0 0 var(--accent); }
311
+ .st-row.is-dragging { opacity: 0.45; transition: opacity var(--dur-soft) var(--ease-out); }
312
+ .st-row.is-busy { opacity: 0.6; pointer-events: none; }
313
+ @media (prefers-reduced-motion: reduce) { .st-row.is-dragging { transition-duration: 1ms; } }
301
314
  .st-row-glyph { width: 14px; display: inline-flex; justify-content: center; color: var(--fg-3); flex: 0 0 14px; }
302
315
  .st-row.is-sel .st-row-glyph { color: var(--accent); }
303
316
  .st-row-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
@@ -339,6 +352,19 @@
339
352
  .st-row-wrap:hover .st-git-badge,
340
353
  .st-row-wrap:focus-within .st-git-badge { margin-right: 24px; }
341
354
  .st-row-del:hover { color: var(--status-error); background: color-mix(in oklab, var(--status-error) 14%, transparent); }
355
+ /* feature-file-tree-drag-drop-folders (Task 9) — the keyboard-reachable
356
+ "⋯" trigger (Move to… / New folder here). Same treatment as .st-row-del;
357
+ sits to ITS left when both share a row (a canvas row has delete too). */
358
+ .st-row-menu-btn {
359
+ position: absolute; right: var(--space-2); top: 50%; transform: translateY(-50%);
360
+ appearance: none; border: 0; background: transparent; color: var(--fg-3); cursor: pointer;
361
+ width: 22px; height: 22px; display: grid; place-items: center; border-radius: var(--radius-xs);
362
+ opacity: 0; transition: opacity var(--dur-soft) var(--ease-out);
363
+ }
364
+ .st-row-wrap:hover .st-row-menu-btn, .st-row-menu-btn:focus-visible { opacity: 1; }
365
+ .st-row-menu-btn:hover { background: var(--bg-3); color: var(--fg-0); }
366
+ .st-row-menu-btn.has-delete-sibling { right: calc(var(--space-2) + 24px); }
367
+ @media (prefers-reduced-motion: reduce) { .st-row-menu-btn, .st-row-del { transition-duration: 1ms; } }
342
368
 
343
369
  /* DS-folder row — chevron toggles disclosure, name opens the system view. */
344
370
  .st-ds-folder { display: flex; align-items: center; gap: 0; padding: 0; }
@@ -1967,6 +1993,8 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1967
1993
  .gi-rail { position: relative; flex: 0 0 auto; margin-top: auto; padding: var(--space-2) var(--space-3); border-top: 1px solid var(--border-default); display: flex; flex-direction: column; gap: var(--space-2); }
1968
1994
  .gi-rail-hint { font-size: var(--type-xs); color: var(--fg-2); padding: var(--space-1) var(--space-1); }
1969
1995
  .gi-rail-signin { width: 100%; justify-content: center; }
1996
+ .gi-rail-confirm { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; padding: var(--space-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); }
1997
+ .gi-rail-confirm .gi-rail-hint { flex: 1 1 100%; padding: 0; }
1970
1998
  .gi-rail-err { font-size: var(--type-xs); color: var(--status-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1971
1999
  .gi-avatar { border-radius: var(--radius-pill); display: grid; place-items: center; font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--accent-fg); background: var(--accent); flex: 0 0 auto; }
1972
2000
  .gi-rail-account { display: flex; align-items: center; gap: var(--space-2); width: 100%; padding: var(--space-1) var(--space-2); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--fg-0); cursor: pointer; text-align: left; }
@@ -0,0 +1,132 @@
1
+ // tree-row-menu.jsx — feature-file-tree-drag-drop-folders (Task 9).
2
+ //
3
+ // The KEYBOARD path for tree operations that Task 8's drag & drop can't
4
+ // reach on its own — WCAG 2.1.1 (keyboard operability) makes this a hard-stop
5
+ // for the a11y gate, not optional polish. A focusable "⋯" button + Enter/click
6
+ // opens a small menu; Move to… drills into a flat destination-folder list.
7
+ // Reuses the existing menubar dropdown visual language (`.st-dropdown` /
8
+ // `.st-dd-item` from 3-shell-maude.css) rather than inventing new chrome —
9
+ // only `position` is overridden inline since this menu anchors to an
10
+ // arbitrary row, not the menubar's fixed offset.
11
+
12
+ import { useEffect, useRef, useState } from 'react';
13
+
14
+ /**
15
+ * One shared menu instance per tree (mirrors useTreeDrag — only one row's
16
+ * menu is ever open at a time). `extra` is caller-defined data identifying
17
+ * WHICH row this is for (e.g. `{ kind: 'file', path }` / `{ kind: 'dir',
18
+ * dirPath }`), read back by the caller when rendering <TreeRowMenu> so
19
+ * rootItems/destinations can be computed per-row without the hook needing to
20
+ * know about canvases or folders at all.
21
+ */
22
+ export function useRowMenu() {
23
+ const [state, setState] = useState(null); // { x, y, anchorEl, view, extra }
24
+ const openAt = (e, extra) => {
25
+ e.preventDefault();
26
+ e.stopPropagation();
27
+ const anchorEl = e.currentTarget;
28
+ const r = anchorEl.getBoundingClientRect();
29
+ setState({ x: r.left, y: r.bottom + 4, anchorEl, view: 'root', extra });
30
+ };
31
+ const close = () => {
32
+ setState((s) => {
33
+ s?.anchorEl?.focus();
34
+ return null;
35
+ });
36
+ };
37
+ const showMoveTo = () => setState((s) => (s ? { ...s, view: 'move-to' } : s));
38
+ return { state, openAt, close, showMoveTo };
39
+ }
40
+
41
+ /**
42
+ * `rootItems` — [{ id, label, onSelect, disabled?, destructive? }] for the
43
+ * top-level menu. `destinations` — [{ path, label }] shown when the user
44
+ * picks the item with `id === 'move-to'`; `onPickDestination(path)` fires
45
+ * the actual move.
46
+ */
47
+ export function TreeRowMenu({ state, onClose, rootItems, destinations, onPickDestination }) {
48
+ const ref = useRef(null);
49
+ useEffect(() => {
50
+ if (!state) return;
51
+ const el = ref.current;
52
+ const focusFirst = () => el?.querySelector('button:not([disabled])')?.focus();
53
+ focusFirst();
54
+ const onDocPointer = (e) => {
55
+ if (!el.contains(e.target)) onClose();
56
+ };
57
+ const onKey = (e) => {
58
+ if (e.key === 'Escape') {
59
+ e.preventDefault();
60
+ onClose();
61
+ return;
62
+ }
63
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
64
+ e.preventDefault();
65
+ const items = Array.from(el.querySelectorAll('button:not([disabled])'));
66
+ if (items.length === 0) return;
67
+ const idx = items.indexOf(document.activeElement);
68
+ const next =
69
+ e.key === 'ArrowDown' ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
70
+ items[next]?.focus();
71
+ }
72
+ };
73
+ document.addEventListener('pointerdown', onDocPointer, true);
74
+ document.addEventListener('keydown', onKey, true);
75
+ return () => {
76
+ document.removeEventListener('pointerdown', onDocPointer, true);
77
+ document.removeEventListener('keydown', onKey, true);
78
+ };
79
+ }, [state, onClose]);
80
+
81
+ if (!state) return null;
82
+ const style = { position: 'fixed', left: state.x, top: state.y };
83
+
84
+ if (state.view === 'move-to') {
85
+ return (
86
+ <div ref={ref} className="st-dropdown" role="menu" aria-label="Move to…" style={style}>
87
+ <div className="st-dd-hd">Move to…</div>
88
+ {destinations.length === 0 ? (
89
+ <div className="st-dd-item" aria-disabled="true">
90
+ No other folders
91
+ </div>
92
+ ) : (
93
+ destinations.map((d) => (
94
+ <button
95
+ key={d.path}
96
+ type="button"
97
+ role="menuitem"
98
+ className="st-dd-item"
99
+ onClick={() => {
100
+ onPickDestination(d.path);
101
+ onClose();
102
+ }}
103
+ >
104
+ <span className="st-dd-lead">{d.label}</span>
105
+ </button>
106
+ ))
107
+ )}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ return (
113
+ <div ref={ref} className="st-dropdown" role="menu" style={style}>
114
+ {rootItems.map((item) => (
115
+ <button
116
+ key={item.id}
117
+ type="button"
118
+ role="menuitem"
119
+ disabled={item.disabled}
120
+ className="st-dd-item"
121
+ style={item.destructive ? { color: 'var(--status-error)' } : undefined}
122
+ onClick={() => {
123
+ if (item.disabled) return;
124
+ item.onSelect();
125
+ }}
126
+ >
127
+ <span className="st-dd-lead">{item.label}</span>
128
+ </button>
129
+ ))}
130
+ </div>
131
+ );
132
+ }