@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.
- 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
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
const api = async (path, init) => {
|
|
18
|
+
const res = await fetch(path, init);
|
|
19
|
+
const json = await res.json().catch(() => ({}));
|
|
20
|
+
return { ok: res.ok, status: res.status, json };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function Spark({ size = 15 }) {
|
|
24
|
+
return (
|
|
25
|
+
<svg width={size} height={size} viewBox="0 0 32 32" fill="currentColor" aria-hidden="true">
|
|
26
|
+
<path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Icon({ name, size = 15 }) {
|
|
32
|
+
const p = {
|
|
33
|
+
'chevron-up': <polyline points="3.5 10 8 5.5 12.5 10" />,
|
|
34
|
+
external: (
|
|
35
|
+
<>
|
|
36
|
+
<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" />
|
|
37
|
+
<line x1="8" y1="8" x2="13" y2="3" />
|
|
38
|
+
<polyline points="9.5 3 13 3 13 6.5" />
|
|
39
|
+
</>
|
|
40
|
+
),
|
|
41
|
+
copy: (
|
|
42
|
+
<>
|
|
43
|
+
<rect x="5.5" y="5.5" width="7.5" height="7.5" rx="1.2" />
|
|
44
|
+
<path d="M3 10.5V3.2A.7.7 0 0 1 3.7 2.5H10" />
|
|
45
|
+
</>
|
|
46
|
+
),
|
|
47
|
+
link: (
|
|
48
|
+
<>
|
|
49
|
+
<path d="M6.5 9.5l3-3" />
|
|
50
|
+
<path d="M7.5 4.5l1-1a2.47 2.47 0 0 1 3.5 3.5l-1 1" />
|
|
51
|
+
<path d="M8.5 11.5l-1 1a2.47 2.47 0 0 1-3.5-3.5l1-1" />
|
|
52
|
+
</>
|
|
53
|
+
),
|
|
54
|
+
signout: (
|
|
55
|
+
<>
|
|
56
|
+
<path d="M6.5 13.5H3.2a.7.7 0 0 1-.7-.7V3.2a.7.7 0 0 1 .7-.7h3.3" />
|
|
57
|
+
<line x1="13" y1="8" x2="6.5" y2="8" />
|
|
58
|
+
<polyline points="10 5 13 8 10 11" />
|
|
59
|
+
</>
|
|
60
|
+
),
|
|
61
|
+
}[name];
|
|
62
|
+
return (
|
|
63
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
64
|
+
{p}
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default function CloudBar() {
|
|
70
|
+
const [state, setState] = useState('loading'); // loading | out | in
|
|
71
|
+
const [email, setEmail] = useState(null);
|
|
72
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
73
|
+
const [device, setDevice] = useState(null); // { userCode, verificationUrl, deviceCode }
|
|
74
|
+
const [projects, setProjects] = useState(null);
|
|
75
|
+
const [busy, setBusy] = useState('');
|
|
76
|
+
const [note, setNote] = useState('');
|
|
77
|
+
const [error, setError] = useState('');
|
|
78
|
+
const [copied, setCopied] = useState(false);
|
|
79
|
+
const railRef = useRef(null);
|
|
80
|
+
const pollRef = useRef(null);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
api('/_api/cloud/status').then((r) => {
|
|
84
|
+
if (r.ok && r.json?.connected) {
|
|
85
|
+
setEmail(r.json.email);
|
|
86
|
+
setState('in');
|
|
87
|
+
} else setState('out');
|
|
88
|
+
});
|
|
89
|
+
return () => clearInterval(pollRef.current);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!menuOpen) return undefined;
|
|
94
|
+
const onDoc = (e) => {
|
|
95
|
+
if (railRef.current && !railRef.current.contains(e.target)) setMenuOpen(false);
|
|
96
|
+
};
|
|
97
|
+
document.addEventListener('mousedown', onDoc);
|
|
98
|
+
return () => document.removeEventListener('mousedown', onDoc);
|
|
99
|
+
}, [menuOpen]);
|
|
100
|
+
|
|
101
|
+
async function startSignIn() {
|
|
102
|
+
setError('');
|
|
103
|
+
const r = await api('/_api/cloud/signin/start', { method: 'POST' });
|
|
104
|
+
if (!r.ok || !r.json?.ok) {
|
|
105
|
+
setError(r.json?.error || 'Maude Cloud could not be reached.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
setDevice(r.json);
|
|
109
|
+
window.open(r.json.verificationUrl, '_blank', 'noopener');
|
|
110
|
+
pollRef.current = setInterval(async () => {
|
|
111
|
+
const p = await api('/_api/cloud/signin/poll', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({ deviceCode: r.json.deviceCode }),
|
|
115
|
+
});
|
|
116
|
+
if (p.json?.pending) return;
|
|
117
|
+
clearInterval(pollRef.current);
|
|
118
|
+
if (p.ok && p.json?.ok) {
|
|
119
|
+
setEmail(p.json.email);
|
|
120
|
+
setState('in');
|
|
121
|
+
setDevice(null);
|
|
122
|
+
} else {
|
|
123
|
+
setDevice(null);
|
|
124
|
+
setError(p.json?.error || 'The sign-in did not finish. Try again.');
|
|
125
|
+
}
|
|
126
|
+
}, (r.json.interval ?? 5) * 1000);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function cancelSignIn() {
|
|
130
|
+
clearInterval(pollRef.current);
|
|
131
|
+
setDevice(null);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function openMenu() {
|
|
135
|
+
const next = !menuOpen;
|
|
136
|
+
setMenuOpen(next);
|
|
137
|
+
if (next && projects === null) {
|
|
138
|
+
const r = await api('/_api/cloud/projects');
|
|
139
|
+
setProjects(r.ok && r.json?.ok ? r.json.projects : []);
|
|
140
|
+
if (!r.ok && r.status === 401) {
|
|
141
|
+
setMenuOpen(false);
|
|
142
|
+
setState('out');
|
|
143
|
+
setEmail(null);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function connect(projectId) {
|
|
149
|
+
setBusy(projectId);
|
|
150
|
+
setNote('');
|
|
151
|
+
setError('');
|
|
152
|
+
const r = await api('/_api/cloud/attach', {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'content-type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({ project: projectId }),
|
|
156
|
+
});
|
|
157
|
+
setBusy('');
|
|
158
|
+
if (r.ok && r.json?.ok) {
|
|
159
|
+
setNote(`Linked to ${projectId} — restart the studio server to start syncing.`);
|
|
160
|
+
} else {
|
|
161
|
+
setError(r.json?.error || 'The workspace could not be connected.');
|
|
162
|
+
}
|
|
163
|
+
setMenuOpen(false);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function signOut() {
|
|
167
|
+
setMenuOpen(false);
|
|
168
|
+
await api('/_api/cloud/signout', { method: 'POST' });
|
|
169
|
+
setEmail(null);
|
|
170
|
+
setProjects(null);
|
|
171
|
+
setState('out');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function copyCode() {
|
|
175
|
+
if (!device?.userCode) return;
|
|
176
|
+
navigator.clipboard?.writeText(device.userCode).then(
|
|
177
|
+
() => {
|
|
178
|
+
setCopied(true);
|
|
179
|
+
setTimeout(() => setCopied(false), 1500);
|
|
180
|
+
},
|
|
181
|
+
() => {}
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (state === 'loading') return null;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className="gi-rail cb-rail" ref={railRef} data-testid="cloud-bar">
|
|
189
|
+
{state === 'out' && (
|
|
190
|
+
<>
|
|
191
|
+
<button type="button" className="btn btn--ghost btn--sm gi-rail-signin" onClick={startSignIn} data-testid="cloud-signin">
|
|
192
|
+
<Spark size={14} /> Sign in to Maude Cloud
|
|
193
|
+
</button>
|
|
194
|
+
{error && <span className="gi-rail-err" title={error}>{error}</span>}
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{state === 'in' && (
|
|
199
|
+
<>
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
className={'gi-rail-account' + (menuOpen ? ' is-open' : '')}
|
|
203
|
+
aria-expanded={menuOpen}
|
|
204
|
+
aria-haspopup="menu"
|
|
205
|
+
onClick={openMenu}
|
|
206
|
+
title={`Maude Cloud — ${email ?? 'connected'}`}
|
|
207
|
+
data-testid="cloud-account"
|
|
208
|
+
>
|
|
209
|
+
<span className="gi-avatar gi-avatar--fallback" aria-hidden="true"><Spark size={14} /></span>
|
|
210
|
+
<span className="gi-rail-login">{email ?? 'Maude Cloud'}</span>
|
|
211
|
+
<span className="gi-rail-caret"><Icon name="chevron-up" size={13} /></span>
|
|
212
|
+
</button>
|
|
213
|
+
{note && <span className="gi-rail-hint" title={note}>{note}</span>}
|
|
214
|
+
{error && <span className="gi-rail-err" title={error}>{error}</span>}
|
|
215
|
+
{menuOpen && (
|
|
216
|
+
<div className="gi-menu" role="menu" aria-label="Maude Cloud">
|
|
217
|
+
<div className="gi-menu-hd">
|
|
218
|
+
<span className="gi-avatar gi-avatar--fallback" aria-hidden="true"><Spark size={16} /></span>
|
|
219
|
+
<span className="gi-menu-id">
|
|
220
|
+
<span className="gi-menu-name">Maude Cloud</span>
|
|
221
|
+
<span className="gi-menu-login">{email ?? 'connected'}</span>
|
|
222
|
+
</span>
|
|
223
|
+
</div>
|
|
224
|
+
{projects === null && <div className="gi-menu-item" aria-disabled="true">Loading projects…</div>}
|
|
225
|
+
{Array.isArray(projects) && projects.length === 0 && (
|
|
226
|
+
<div className="gi-menu-item" aria-disabled="true">No projects yet — start one on the dashboard.</div>
|
|
227
|
+
)}
|
|
228
|
+
{Array.isArray(projects) &&
|
|
229
|
+
projects.map((p) => (
|
|
230
|
+
<button
|
|
231
|
+
key={p.id}
|
|
232
|
+
type="button"
|
|
233
|
+
className="gi-menu-item"
|
|
234
|
+
role="menuitem"
|
|
235
|
+
disabled={busy === p.id}
|
|
236
|
+
onClick={() => connect(p.id)}
|
|
237
|
+
title={`Connect this project to ${p.url} (${p.role})`}
|
|
238
|
+
data-testid={`cloud-project-${p.id}`}
|
|
239
|
+
>
|
|
240
|
+
<Icon name="link" size={15} /> {busy === p.id ? 'Connecting…' : `Connect ${p.name || p.id}`}
|
|
241
|
+
<span className="gi-menu-login" style={{ marginLeft: 'auto' }}>{p.stateLabel}</span>
|
|
242
|
+
</button>
|
|
243
|
+
))}
|
|
244
|
+
<div className="gi-menu-sep" />
|
|
245
|
+
<button type="button" className="gi-menu-item" role="menuitem" onClick={() => window.open('https://cloud.maude.sh', '_blank', 'noopener')}>
|
|
246
|
+
<Icon name="external" size={15} /> Open the dashboard
|
|
247
|
+
</button>
|
|
248
|
+
<button type="button" className="gi-menu-item gi-menu-item--danger" role="menuitem" onClick={signOut}>
|
|
249
|
+
<Icon name="signout" size={15} /> Sign out
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{device && (
|
|
257
|
+
<div className="gi-modal" role="dialog" aria-modal="true" aria-label="Sign in to Maude Cloud" onKeyDown={(e) => { if (e.key === 'Escape') cancelSignIn(); }}>
|
|
258
|
+
<div className="gi-scrim" aria-hidden="true" onClick={cancelSignIn} />
|
|
259
|
+
<div className="gi-dialog gi-dialog--code" data-testid="cloud-device-dialog">
|
|
260
|
+
<div className="gi-dc-head">
|
|
261
|
+
<span className="gi-dc-marks"><Spark size={26} /></span>
|
|
262
|
+
<h2>Sign in to Maude Cloud</h2>
|
|
263
|
+
<p>Maude opened your dashboard in the browser. Confirm this code there to connect.</p>
|
|
264
|
+
</div>
|
|
265
|
+
<div className="gi-code">
|
|
266
|
+
<span className="gi-code-val" data-testid="cloud-user-code">{device.userCode}</span>
|
|
267
|
+
<button type="button" className="btn btn--ghost gi-code-copy" onClick={copyCode} aria-label="Copy the code">
|
|
268
|
+
<Icon name="copy" size={15} /> {copied ? 'Copied' : 'Copy'}
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
<div className="gi-dc-status" aria-live="polite">
|
|
272
|
+
<span className="gi-pulse" aria-hidden="true" />
|
|
273
|
+
<span>Waiting for you to confirm in the browser…</span>
|
|
274
|
+
</div>
|
|
275
|
+
<div className="gi-dc-foot">
|
|
276
|
+
<button type="button" className="btn btn--ghost" onClick={cancelSignIn}>Cancel</button>
|
|
277
|
+
<span className="gi-dc-foot-note">Nothing is stored until you confirm.</span>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
@@ -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; }
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// use-tree-drag.js — feature-file-tree-drag-drop-folders (Task 8).
|
|
2
|
+
//
|
|
3
|
+
// Drag source/target bookkeeping for the file tree's HTML5 drag & drop layer:
|
|
4
|
+
// which canvas row is being dragged, which folder row is currently hovered
|
|
5
|
+
// (for the highlight + spring-load-open), and a small timer for the
|
|
6
|
+
// spring-load itself. Kept out of Tree/DirRow/CanvasRow so those components
|
|
7
|
+
// stay about rendering, not drag bookkeeping.
|
|
8
|
+
//
|
|
9
|
+
// Payload MIME is a custom `application/x-maude-canvas` (not `text/plain` or
|
|
10
|
+
// `Files`) so an OS file drag can't be mistaken for a tree-row drag, and the
|
|
11
|
+
// existing canvas-side media-drop path (use-canvas-media-drop.tsx) — which
|
|
12
|
+
// only reacts to real `DataTransfer.files` — stays unaffected.
|
|
13
|
+
|
|
14
|
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
15
|
+
|
|
16
|
+
export const TREE_DRAG_MIME = 'application/x-maude-canvas';
|
|
17
|
+
const SPRING_LOAD_MS = 600;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `onMove(fromPath, toDir)` — async, called on a valid drop. The caller owns
|
|
21
|
+
* the actual fetch + tree refresh; this hook only tracks UI state and decides
|
|
22
|
+
* when a drop is valid.
|
|
23
|
+
*/
|
|
24
|
+
export function useTreeDrag(onMove) {
|
|
25
|
+
const [draggedPath, setDraggedPath] = useState(null);
|
|
26
|
+
const [overDir, setOverDir] = useState(null);
|
|
27
|
+
const [busyPath, setBusyPath] = useState(null);
|
|
28
|
+
const springTimer = useRef(null);
|
|
29
|
+
|
|
30
|
+
const clearSpring = useCallback(() => {
|
|
31
|
+
if (springTimer.current) {
|
|
32
|
+
clearTimeout(springTimer.current);
|
|
33
|
+
springTimer.current = null;
|
|
34
|
+
}
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const dragProps = useMemo(
|
|
38
|
+
() => (path, draggable) => {
|
|
39
|
+
if (!draggable) return {};
|
|
40
|
+
return {
|
|
41
|
+
draggable: true,
|
|
42
|
+
onDragStart: (e) => {
|
|
43
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
44
|
+
e.dataTransfer.setData(TREE_DRAG_MIME, path);
|
|
45
|
+
// A lightweight ghost — the browser default drag image for a
|
|
46
|
+
// <button> row is poor (grabs the whole row incl. badges).
|
|
47
|
+
const ghost = document.createElement('div');
|
|
48
|
+
ghost.textContent = path.split('/').pop();
|
|
49
|
+
ghost.style.cssText =
|
|
50
|
+
'position:fixed;top:-1000px;left:-1000px;padding:4px 10px;' +
|
|
51
|
+
'background:var(--bg-2);color:var(--fg-1);border-radius:6px;' +
|
|
52
|
+
'font:12px system-ui;pointer-events:none;';
|
|
53
|
+
document.body.appendChild(ghost);
|
|
54
|
+
e.dataTransfer.setDragImage(ghost, 8, 8);
|
|
55
|
+
setTimeout(() => ghost.remove(), 0);
|
|
56
|
+
setDraggedPath(path);
|
|
57
|
+
},
|
|
58
|
+
onDragEnd: () => {
|
|
59
|
+
setDraggedPath(null);
|
|
60
|
+
setOverDir(null);
|
|
61
|
+
clearSpring();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
[clearSpring]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/** `dirPath` is the drop target's full path; `openIfClosed` spring-loads it
|
|
69
|
+
* open after hovering. `valid` gates whether this target accepts drops at
|
|
70
|
+
* all (e.g. false for the design-system group). */
|
|
71
|
+
const dropProps = useMemo(
|
|
72
|
+
() => (dirPath, valid, openIfClosed) => {
|
|
73
|
+
if (!valid) return {};
|
|
74
|
+
return {
|
|
75
|
+
onDragOver: (e) => {
|
|
76
|
+
if (!e.dataTransfer.types.includes(TREE_DRAG_MIME)) return;
|
|
77
|
+
// Only preventDefault on a valid target — the cursor itself then
|
|
78
|
+
// communicates validity (no-drop glyph elsewhere).
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
e.dataTransfer.dropEffect = 'move';
|
|
81
|
+
if (overDir !== dirPath) {
|
|
82
|
+
setOverDir(dirPath);
|
|
83
|
+
clearSpring();
|
|
84
|
+
if (openIfClosed) {
|
|
85
|
+
springTimer.current = setTimeout(() => {
|
|
86
|
+
openIfClosed();
|
|
87
|
+
springTimer.current = null;
|
|
88
|
+
}, SPRING_LOAD_MS);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
onDragLeave: (e) => {
|
|
93
|
+
// Ignore bubbled leave events from child elements re-entering.
|
|
94
|
+
if (e.currentTarget.contains(e.relatedTarget)) return;
|
|
95
|
+
if (overDir === dirPath) setOverDir(null);
|
|
96
|
+
clearSpring();
|
|
97
|
+
},
|
|
98
|
+
onDrop: async (e) => {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
clearSpring();
|
|
101
|
+
setOverDir(null);
|
|
102
|
+
const fromPath = e.dataTransfer.getData(TREE_DRAG_MIME);
|
|
103
|
+
setDraggedPath(null);
|
|
104
|
+
if (!fromPath || fromPath === dirPath) return;
|
|
105
|
+
setBusyPath(fromPath);
|
|
106
|
+
try {
|
|
107
|
+
await onMove(fromPath, dirPath);
|
|
108
|
+
} finally {
|
|
109
|
+
setBusyPath(null);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
[overDir, clearSpring, onMove]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// `move` — the raw callback, for the Task 9 keyboard path ("Move to…"),
|
|
118
|
+
// which doesn't go through a drop event at all.
|
|
119
|
+
return { draggedPath, overDir, busyPath, dragProps, dropProps, move: onMove };
|
|
120
|
+
}
|