@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.
- 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 +388 -0
- package/apps/studio/client/styles/3-shell-maude.css +28 -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 +284 -0
- package/apps/studio/collab/registry.ts +23 -0
- package/apps/studio/debug-bundle.ts +144 -0
- package/apps/studio/dist/client.bundle.js +1235 -1228
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +205 -1
- package/apps/studio/inspect.ts +49 -0
- package/apps/studio/server.ts +15 -0
- package/apps/studio/sync/workspace-signin.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/cloud-endpoints.test.ts +130 -0
- package/apps/studio/test/debug-bundle.test.ts +105 -0
- package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
- package/apps/studio/test/workspace-signin.test.ts +19 -0
- package/apps/studio/whats-new.json +27 -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 +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
|
+
}
|