@1agh/maude 1.2.0 → 1.3.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/README.md +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +689 -36
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +35 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1586 -1554
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/whats-new.json +101 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +47 -0
- package/plugins/flow/dependencies.json +3 -3
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The shell half of an inline-edit undo/redo (`dgn:'apply-edit'`). The canvas
|
|
2
|
+
// iframe's edit-source command cannot reach the main-origin `/_api/edit-*`
|
|
3
|
+
// routes (DDR-054), so it posts the re-application here and the shell builds
|
|
4
|
+
// the request.
|
|
5
|
+
//
|
|
6
|
+
// `from` is the value the command expects the source to hold right now (the
|
|
7
|
+
// side it replaces FROM). For css/attr it becomes the route's `expected`
|
|
8
|
+
// precondition, so an undo cannot overwrite a newer value a teammate wrote
|
|
9
|
+
// (audit 2026-09-13 P1 #5). For text it keeps its existing job: targeting a
|
|
10
|
+
// `{variable}` edit at the right source string.
|
|
11
|
+
|
|
12
|
+
export interface ApplyEditMessage {
|
|
13
|
+
op: unknown;
|
|
14
|
+
canvas?: unknown;
|
|
15
|
+
id?: unknown;
|
|
16
|
+
key?: unknown;
|
|
17
|
+
value?: unknown;
|
|
18
|
+
from?: unknown;
|
|
19
|
+
occurrence?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ApplyEditRequest {
|
|
23
|
+
op: 'css' | 'text' | 'attr';
|
|
24
|
+
url: string;
|
|
25
|
+
body: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function applyEditRequest(m: ApplyEditMessage): ApplyEditRequest | null {
|
|
29
|
+
if (m.op !== 'css' && m.op !== 'text' && m.op !== 'attr') return null;
|
|
30
|
+
if (typeof m.id !== 'string' || !m.id) return null;
|
|
31
|
+
const value = typeof m.value === 'string' ? m.value : null;
|
|
32
|
+
// Older canvas bundles send no `from`; they keep the legacy unconditional write.
|
|
33
|
+
const expected =
|
|
34
|
+
typeof m.from === 'string' || m.from === null ? { expected: m.from } : ({} as object);
|
|
35
|
+
if (m.op === 'css') {
|
|
36
|
+
const target = value == null ? { reset: true } : { value };
|
|
37
|
+
return {
|
|
38
|
+
op: 'css',
|
|
39
|
+
url: '/_api/edit-css',
|
|
40
|
+
body: { canvas: m.canvas, id: m.id, property: m.key, ...target, ...expected },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (m.op === 'attr') {
|
|
44
|
+
const target = value == null ? { reset: true } : { value };
|
|
45
|
+
return {
|
|
46
|
+
op: 'attr',
|
|
47
|
+
url: '/_api/edit-attr',
|
|
48
|
+
body: { canvas: m.canvas, id: m.id, attr: m.key, ...target, ...expected },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const body: Record<string, unknown> = { canvas: m.canvas, id: m.id, text: value ?? '' };
|
|
52
|
+
if (typeof m.occurrence === 'number') body.occurrence = m.occurrence;
|
|
53
|
+
if (typeof m.from === 'string') body.before = m.from;
|
|
54
|
+
return { op: 'text', url: '/_api/edit-text', body };
|
|
55
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// (`.st-exports`) and 4-components.css (`.st-export-*`).
|
|
13
13
|
|
|
14
14
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import { dismissNotice, notify, summarizeNoticeText } from '../notifications.tsx';
|
|
15
16
|
import { isNativeApp, saveExport } from './github.js';
|
|
16
17
|
|
|
17
18
|
const FORMAT_LABELS = {
|
|
@@ -75,6 +76,7 @@ async function saveNative(id, filename) {
|
|
|
75
76
|
export function useExportCenter({ enabled = true } = {}) {
|
|
76
77
|
const [jobs, setJobs] = useState(() => new Map());
|
|
77
78
|
const [panelOpen, setPanelOpen] = useState(false);
|
|
79
|
+
const [selectedJobId, setSelectedJobId] = useState(null);
|
|
78
80
|
const [toastQueue, setToastQueue] = useState([]);
|
|
79
81
|
const [savingIds, setSavingIds] = useState(() => new Set());
|
|
80
82
|
const prevStatusRef = useRef(new Map());
|
|
@@ -149,7 +151,7 @@ export function useExportCenter({ enabled = true } = {}) {
|
|
|
149
151
|
if (toSurface.length) {
|
|
150
152
|
setToastQueue((q) => {
|
|
151
153
|
const merged = q.filter((id) => !toSurface.includes(id));
|
|
152
|
-
return [...toSurface, ...merged]
|
|
154
|
+
return [...toSurface, ...merged];
|
|
153
155
|
});
|
|
154
156
|
}
|
|
155
157
|
|
|
@@ -169,14 +171,22 @@ export function useExportCenter({ enabled = true } = {}) {
|
|
|
169
171
|
|
|
170
172
|
const list = useMemo(
|
|
171
173
|
() =>
|
|
172
|
-
Array.from(jobs.values()).sort((a, b) =>
|
|
174
|
+
Array.from(jobs.values()).sort((a, b) =>
|
|
175
|
+
(b.createdAt || '').localeCompare(a.createdAt || '')
|
|
176
|
+
),
|
|
173
177
|
[jobs]
|
|
174
178
|
);
|
|
175
179
|
const runningCount = list.filter((j) => j.status === 'running').length;
|
|
176
180
|
const queuedCount = list.filter((j) => j.status === 'queued').length;
|
|
177
181
|
|
|
178
|
-
const dismissToast = useCallback(
|
|
179
|
-
|
|
182
|
+
const dismissToast = useCallback(
|
|
183
|
+
(id) => setToastQueue((q) => q.filter((item) => item !== (id ?? q[0]))),
|
|
184
|
+
[]
|
|
185
|
+
);
|
|
186
|
+
const openPanel = useCallback((jobId) => {
|
|
187
|
+
setSelectedJobId(typeof jobId === 'string' ? jobId : null);
|
|
188
|
+
setPanelOpen(true);
|
|
189
|
+
}, []);
|
|
180
190
|
const closePanel = useCallback(() => setPanelOpen(false), []);
|
|
181
191
|
|
|
182
192
|
const withSaving = useCallback(async (job, fn) => {
|
|
@@ -221,6 +231,8 @@ export function useExportCenter({ enabled = true } = {}) {
|
|
|
221
231
|
queuedCount,
|
|
222
232
|
busyCount: runningCount + queuedCount,
|
|
223
233
|
panelOpen,
|
|
234
|
+
selectedJobId,
|
|
235
|
+
toastJobs: toastQueue.map((id) => jobs.get(id)).filter(Boolean),
|
|
224
236
|
openPanel,
|
|
225
237
|
closePanel,
|
|
226
238
|
showToast: !!toastJobId,
|
|
@@ -248,7 +260,9 @@ function saveActionLabel(saving) {
|
|
|
248
260
|
|
|
249
261
|
function ProgressBar({ progress }) {
|
|
250
262
|
if (!progress || !progress.total) {
|
|
251
|
-
return
|
|
263
|
+
return (
|
|
264
|
+
<div className="st-export-progress st-export-progress--indeterminate" aria-hidden="true" />
|
|
265
|
+
);
|
|
252
266
|
}
|
|
253
267
|
const pct = Math.max(0, Math.min(100, Math.round((progress.current / progress.total) * 100)));
|
|
254
268
|
return (
|
|
@@ -331,7 +345,7 @@ function StatusPill({ job }) {
|
|
|
331
345
|
* back degraded — in the panel row and in the completion toast, because the
|
|
332
346
|
* whole failure mode of this bug was that neither surface said anything.
|
|
333
347
|
*/
|
|
334
|
-
function DegradedNote({ degraded }) {
|
|
348
|
+
function DegradedNote({ degraded, compact = false }) {
|
|
335
349
|
if (!degraded) return null;
|
|
336
350
|
// The font case names the fonts, because "reduced fidelity" is useless to
|
|
337
351
|
// someone about to send this to a printer — what they need is which faces
|
|
@@ -344,8 +358,12 @@ function DegradedNote({ degraded }) {
|
|
|
344
358
|
: 'Exported with reduced fidelity.';
|
|
345
359
|
return (
|
|
346
360
|
<div className="st-export-degraded" data-testid="export-degraded-note">
|
|
347
|
-
<span aria-hidden="true"
|
|
348
|
-
{degraded.remedy ?
|
|
361
|
+
<span aria-hidden="true">!</span> {compact ? summarizeNoticeText(headline) : headline}
|
|
362
|
+
{degraded.remedy ? (
|
|
363
|
+
<div className="st-export-degraded__fix">
|
|
364
|
+
{compact ? summarizeNoticeText(degraded.remedy) : degraded.remedy}
|
|
365
|
+
</div>
|
|
366
|
+
) : null}
|
|
349
367
|
</div>
|
|
350
368
|
);
|
|
351
369
|
}
|
|
@@ -372,65 +390,88 @@ export function ExportBadge({ center }) {
|
|
|
372
390
|
);
|
|
373
391
|
}
|
|
374
392
|
|
|
375
|
-
|
|
376
|
-
if (!center.showToast || !center.toastJob) return null;
|
|
377
|
-
const job = center.toastJob;
|
|
393
|
+
function ExportJobNotice({ job, center }) {
|
|
378
394
|
const pending = job.status === 'queued' || job.status === 'running';
|
|
379
395
|
const ok = job.status === 'done';
|
|
380
396
|
const saving = center.savingIds.has(job.id);
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
397
|
+
const id = `export-${job.id}`;
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
notify({
|
|
400
|
+
id,
|
|
401
|
+
group: 'exports',
|
|
402
|
+
title: pending
|
|
403
|
+
? 'Exporting…'
|
|
404
|
+
: ok
|
|
405
|
+
? job.degraded
|
|
406
|
+
? 'Export ready — with a problem'
|
|
407
|
+
: 'Export ready'
|
|
408
|
+
: 'Export failed',
|
|
409
|
+
description: pending
|
|
410
|
+
? 'Running in the background — you can keep working.'
|
|
411
|
+
: ok
|
|
412
|
+
? (job.filename ?? 'Ready to download.')
|
|
413
|
+
: (job.error ?? 'Something went wrong.'),
|
|
414
|
+
kind: pending ? 'info' : ok ? (job.degraded ? 'warning' : 'success') : 'error',
|
|
415
|
+
duration: pending ? Infinity : undefined,
|
|
416
|
+
timerKey: pending ? 'active' : job.status,
|
|
417
|
+
paused: saving,
|
|
418
|
+
onDismiss: () => center.dismissToast(job.id),
|
|
419
|
+
content: (
|
|
402
420
|
<>
|
|
403
|
-
<div className="
|
|
404
|
-
<ProgressBar progress={job.progress} />
|
|
421
|
+
<div className="maude-notice-summary">{jobLabel(job)}</div>
|
|
422
|
+
{pending && <ProgressBar progress={job.progress} />}
|
|
423
|
+
{ok && <DegradedNote degraded={job.degraded} compact />}
|
|
424
|
+
<div className="st-toast-actions">
|
|
425
|
+
{ok && (
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
className="btn btn--primary btn--sm"
|
|
429
|
+
disabled={saving}
|
|
430
|
+
onClick={() => (isNativeApp() ? void center.save(job) : void center.download(job))}
|
|
431
|
+
>
|
|
432
|
+
{saving && <span className="st-btn-spin" aria-hidden="true" />}
|
|
433
|
+
{saveActionLabel(saving)}
|
|
434
|
+
</button>
|
|
435
|
+
)}
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
className="btn btn--ghost btn--sm"
|
|
439
|
+
onClick={() => center.openPanel(job.id)}
|
|
440
|
+
>
|
|
441
|
+
View in history
|
|
442
|
+
</button>
|
|
443
|
+
</div>
|
|
405
444
|
</>
|
|
406
|
-
)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
</button>
|
|
426
|
-
</div>
|
|
427
|
-
)}
|
|
428
|
-
</div>
|
|
429
|
-
);
|
|
445
|
+
),
|
|
446
|
+
});
|
|
447
|
+
}, [
|
|
448
|
+
id,
|
|
449
|
+
job,
|
|
450
|
+
pending,
|
|
451
|
+
ok,
|
|
452
|
+
saving,
|
|
453
|
+
center.dismissToast,
|
|
454
|
+
center.openPanel,
|
|
455
|
+
center.save,
|
|
456
|
+
center.download,
|
|
457
|
+
]);
|
|
458
|
+
useEffect(() => () => dismissNotice(id), [id]);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function ExportToast({ center }) {
|
|
463
|
+
return center.toastJobs.map((job) => <ExportJobNotice key={job.id} job={job} center={center} />);
|
|
430
464
|
}
|
|
431
465
|
|
|
432
466
|
export function ExportPanel({ center }) {
|
|
433
|
-
const { panelOpen, closePanel } = center;
|
|
467
|
+
const { panelOpen, closePanel, selectedJobId } = center;
|
|
468
|
+
const selectedRow = useRef(null);
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
if (panelOpen && selectedJobId) {
|
|
471
|
+
selectedRow.current?.scrollIntoView?.({ block: 'nearest' });
|
|
472
|
+
selectedRow.current?.focus();
|
|
473
|
+
}
|
|
474
|
+
}, [panelOpen, selectedJobId]);
|
|
434
475
|
useEffect(() => {
|
|
435
476
|
if (!panelOpen) return;
|
|
436
477
|
function onKey(e) {
|
|
@@ -474,7 +515,13 @@ export function ExportPanel({ center }) {
|
|
|
474
515
|
) : (
|
|
475
516
|
<ul className="st-export-list">
|
|
476
517
|
{center.jobs.map((job) => (
|
|
477
|
-
<li
|
|
518
|
+
<li
|
|
519
|
+
key={job.id}
|
|
520
|
+
className="st-export-item"
|
|
521
|
+
data-testid={`export-job-${job.id}`}
|
|
522
|
+
ref={job.id === selectedJobId ? selectedRow : null}
|
|
523
|
+
tabIndex={-1}
|
|
524
|
+
>
|
|
478
525
|
<div className="st-export-item__hd">
|
|
479
526
|
<span className="st-export-item__label">{jobLabel(job)}</span>
|
|
480
527
|
<StatusPill job={job} />
|
|
@@ -501,7 +548,10 @@ export function ExportPanel({ center }) {
|
|
|
501
548
|
)}
|
|
502
549
|
{job.status === 'failed' && (
|
|
503
550
|
<div className="st-export-item__ft">
|
|
504
|
-
<
|
|
551
|
+
<details className="st-export-item__error">
|
|
552
|
+
<summary>Show error details</summary>
|
|
553
|
+
<pre>{job.error ?? 'Export failed.'}</pre>
|
|
554
|
+
</details>
|
|
505
555
|
</div>
|
|
506
556
|
)}
|
|
507
557
|
</li>
|
|
@@ -86,6 +86,42 @@ export const initDesign = (dir) => api('/_api/design/init', { method: 'POST', bo
|
|
|
86
86
|
/** Phase 29 (E4) Door C — connect to a team hub (saves the global hub credential). */
|
|
87
87
|
export const hubLink = (body) => api('/_api/hub/link', { method: 'POST', body: JSON.stringify(body) });
|
|
88
88
|
|
|
89
|
+
// ── managed team projects (plan T21/T22) ───────────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Sign in / hold the credential and describe a team project, so its own copy can
|
|
92
|
+
* be created without choosing a folder. body: { kind: 'cloud', projectId } |
|
|
93
|
+
* { kind: 'handoff', code, claimedProject } | { kind: 'hub', url, email, password }
|
|
94
|
+
* | { kind: 'known-hub', url }.
|
|
95
|
+
*/
|
|
96
|
+
export const prepareProject = (body) => api('/_api/projects/prepare', { method: 'POST', body: JSON.stringify(body) });
|
|
97
|
+
/** Create (first time) or reuse the project's managed copy, then switch to it. */
|
|
98
|
+
export const managedProjectOpen = (p) =>
|
|
99
|
+
invoke('managed_project_open', {
|
|
100
|
+
serverUrl: p.serverUrl,
|
|
101
|
+
projectId: p.projectId,
|
|
102
|
+
name: p.name || p.projectId,
|
|
103
|
+
canvasGroups: p.canvasGroups || [],
|
|
104
|
+
});
|
|
105
|
+
/** The team projects this computer keeps a copy of, most recent first. */
|
|
106
|
+
export const managedProjectsList = () => invoke('managed_projects_list');
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Prepare, then open. Resolves `{ ok: true, path }` once the switch has started
|
|
110
|
+
* (the webview reloads onto the project), or `{ ok: false, error }`.
|
|
111
|
+
*/
|
|
112
|
+
export async function openTeamProject(input) {
|
|
113
|
+
const r = await prepareProject(input);
|
|
114
|
+
if (!r.ok || !r.json?.ok) {
|
|
115
|
+
return { ok: false, error: r.json?.error || 'The project could not be opened.', status: r.status };
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const path = await managedProjectOpen(r.json);
|
|
119
|
+
return { ok: true, path, project: r.json };
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { ok: false, error: String(e?.message || e || 'The project could not be opened.') };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
89
125
|
// ── Tauri shell: app-state (first-run / last-project / recent) — Phase 29 (E4) ──
|
|
90
126
|
// In a plain browser these throw via tauri(); the wizard only mounts in the native
|
|
91
127
|
// app (isNativeApp()), so call sites guard accordingly.
|
|
@@ -99,7 +135,8 @@ export const appRecentProjects = () => invoke('app_recent_projects');
|
|
|
99
135
|
/** Native folder picker → chosen parent dir, or null if cancelled. */
|
|
100
136
|
export const pickDirectory = () => invoke('pick_directory');
|
|
101
137
|
/** Switch the app to a local project folder (the freshly cloned copy). */
|
|
102
|
-
export const openLocalProject = (path) => invoke('open_local_project', { path });
|
|
138
|
+
export const openLocalProject = (path, open) => invoke('open_local_project', { path, open });
|
|
139
|
+
export const resolveProjectForLink = (project) => invoke('resolve_project_for_link', { project });
|
|
103
140
|
/**
|
|
104
141
|
* Native "Save As…" for an export — opens an OS save dialog seeded with
|
|
105
142
|
* `filename`, then has the Rust side fetch the finished `jobId`'s bytes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** One project-index request at a time, with automatic transient recovery. */
|
|
2
|
+
export function createIndexLoader<T>({
|
|
3
|
+
read,
|
|
4
|
+
apply,
|
|
5
|
+
onError,
|
|
6
|
+
retryDelayMs = 500,
|
|
7
|
+
maxRetryDelayMs = 5000,
|
|
8
|
+
timeoutMs = 10000,
|
|
9
|
+
}: {
|
|
10
|
+
read: (signal: AbortSignal) => Promise<T>;
|
|
11
|
+
apply: (value: T) => void;
|
|
12
|
+
onError: (error: unknown) => void;
|
|
13
|
+
retryDelayMs?: number;
|
|
14
|
+
maxRetryDelayMs?: number;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}) {
|
|
17
|
+
let disposed = false;
|
|
18
|
+
let dirty = false;
|
|
19
|
+
let failures = 0;
|
|
20
|
+
let pending: Promise<void> | null = null;
|
|
21
|
+
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
22
|
+
let controller: AbortController | undefined;
|
|
23
|
+
const cancelRetry = () => {
|
|
24
|
+
clearTimeout(retryTimer);
|
|
25
|
+
retryTimer = undefined;
|
|
26
|
+
};
|
|
27
|
+
async function drain() {
|
|
28
|
+
while (dirty && !disposed) {
|
|
29
|
+
dirty = false;
|
|
30
|
+
cancelRetry();
|
|
31
|
+
const current = new AbortController();
|
|
32
|
+
controller = current;
|
|
33
|
+
const timeout = setTimeout(() => current.abort(), timeoutMs);
|
|
34
|
+
try {
|
|
35
|
+
const value = await read(current.signal);
|
|
36
|
+
// A mutation/refresh requested a newer index during this read. Do not
|
|
37
|
+
// briefly restore the older tree; the next request serves all waiters.
|
|
38
|
+
if (!disposed && !dirty) apply(value);
|
|
39
|
+
failures = 0;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (disposed) return;
|
|
42
|
+
onError(error);
|
|
43
|
+
const status = (error as { status?: number } | null)?.status;
|
|
44
|
+
if (status == null || status === 408 || status === 429 || status >= 500) {
|
|
45
|
+
const delay = Math.min(maxRetryDelayMs, retryDelayMs * 2 ** Math.min(failures++, 10));
|
|
46
|
+
retryTimer = setTimeout(() => { void reload(); }, delay);
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timeout);
|
|
50
|
+
if (controller === current) controller = undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function reload(): Promise<void> {
|
|
55
|
+
if (disposed) return Promise.resolve();
|
|
56
|
+
dirty = true;
|
|
57
|
+
cancelRetry();
|
|
58
|
+
if (!pending) {
|
|
59
|
+
pending = drain().finally(() => {
|
|
60
|
+
pending = null;
|
|
61
|
+
// Cover a reload requested between drain's return and this microtask.
|
|
62
|
+
if (dirty && !disposed) return reload();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return pending;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
reload,
|
|
69
|
+
dispose() {
|
|
70
|
+
disposed = true;
|
|
71
|
+
dirty = false;
|
|
72
|
+
cancelRetry();
|
|
73
|
+
controller?.abort();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
import { useEffect, useRef, useState } from 'react';
|
|
16
16
|
|
|
17
|
+
import { localIdentityMatches, parseFileDeepLink } from '../share-link.js';
|
|
18
|
+
import FileDeepLinkDialog from './file-deep-link-dialog.jsx';
|
|
17
19
|
import { safeName, syncPresentation } from '../../sync/presentation.ts';
|
|
18
|
-
import { invoke, isNativeApp, listen, openCloudUrl } from '../github.js';
|
|
20
|
+
import { invoke, isNativeApp, listen, openCloudUrl, openTeamProject } from '../github.js';
|
|
19
21
|
|
|
20
22
|
const api = async (path, init) => {
|
|
21
23
|
const res = await fetch(path, init);
|
|
@@ -80,7 +82,7 @@ export function isDisplayableUrl(url, cloudUrl) {
|
|
|
80
82
|
* browser didn't open, go to:" teaches the person to copy the address into a
|
|
81
83
|
* browser the lock cannot see (B4). Callers surface the failure instead.
|
|
82
84
|
*/
|
|
83
|
-
function openExternal(url) {
|
|
85
|
+
export function openExternal(url) {
|
|
84
86
|
if (!url) return Promise.resolve('failed');
|
|
85
87
|
if (isNativeApp()) {
|
|
86
88
|
return openCloudUrl(url).then(
|
|
@@ -118,7 +120,9 @@ export function parseDeepLink(url) {
|
|
|
118
120
|
// without this, arbitrary attacker text renders in the dialog's heading, prose
|
|
119
121
|
// and aria-label. Nothing longer can name a real project anyway.
|
|
120
122
|
if (m[1].length > 40) return null;
|
|
121
|
-
const
|
|
123
|
+
const params = new URLSearchParams(m[2]);
|
|
124
|
+
if (params.has('open') || params.getAll('code').length !== 1) return null;
|
|
125
|
+
const code = params.get('code') ?? '';
|
|
122
126
|
if (!/^mhc_[0-9a-f]{16,128}$/.test(code)) return null;
|
|
123
127
|
return { project: m[1], code };
|
|
124
128
|
}
|
|
@@ -402,7 +406,7 @@ function Icon({ name, size = 15 }) {
|
|
|
402
406
|
// GitPanel's cloud-managed posture turns on the same `linkedHub.credentialed`
|
|
403
407
|
// fact this panel's Connected row does, and CloudBar owns every action that
|
|
404
408
|
// changes it (status resolve, attach, detach) — so it reports, the app decides.
|
|
405
|
-
export default function CloudBar({ syncStatus, onLinkedHub }) {
|
|
409
|
+
export default function CloudBar({ syncStatus, onLinkedHub, onLocalProject, onOpenFile, filesReady }) {
|
|
406
410
|
const [state, setState] = useState('loading'); // loading | out | in
|
|
407
411
|
const [email, setEmail] = useState(null);
|
|
408
412
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
@@ -451,6 +455,7 @@ export default function CloudBar({ syncStatus, onLinkedHub }) {
|
|
|
451
455
|
resolved: true,
|
|
452
456
|
});
|
|
453
457
|
onLinkedHub?.(r.json?.linkedHub ?? null);
|
|
458
|
+
onLocalProject?.(r.json?.project ?? null);
|
|
454
459
|
if (r.ok && r.json?.connected) {
|
|
455
460
|
setEmail(r.json.email);
|
|
456
461
|
setState('in');
|
|
@@ -474,7 +479,9 @@ export default function CloudBar({ syncStatus, onLinkedHub }) {
|
|
|
474
479
|
useEffect(() => {
|
|
475
480
|
if (!isNativeApp()) return undefined;
|
|
476
481
|
const consume = (url) => {
|
|
477
|
-
const
|
|
482
|
+
const connect = parseDeepLink(url);
|
|
483
|
+
const file = connect ? null : parseFileDeepLink(url);
|
|
484
|
+
const parsed = connect ? { ...connect, kind: 'connect' } : file ? { ...file, kind: 'file' } : null;
|
|
478
485
|
if (!parsed) return;
|
|
479
486
|
// NEVER replace a link the person is already looking at. The dialog is a
|
|
480
487
|
// single slot, so an overwrite would swap the code out from under a
|
|
@@ -493,6 +500,13 @@ export default function CloudBar({ syncStatus, onLinkedHub }) {
|
|
|
493
500
|
return () => unlisten?.();
|
|
494
501
|
}, []);
|
|
495
502
|
|
|
503
|
+
const fileMatches = pending?.kind === 'file' && local.resolved && localIdentityMatches(local, pending.project);
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
if (!fileMatches || !filesReady || !onOpenFile) return;
|
|
506
|
+
onOpenFile(pending.rel);
|
|
507
|
+
setPending(null);
|
|
508
|
+
}, [fileMatches, filesReady, pending, onOpenFile]);
|
|
509
|
+
|
|
496
510
|
async function connectPending() {
|
|
497
511
|
if (!pending) return;
|
|
498
512
|
setBusy('deeplink');
|
|
@@ -516,6 +530,21 @@ export default function CloudBar({ syncStatus, onLinkedHub }) {
|
|
|
516
530
|
}
|
|
517
531
|
}
|
|
518
532
|
|
|
533
|
+
// Plan T22: the link's project opened as ITS OWN copy — the designer's
|
|
534
|
+
// default, and the one answer that can't sync the wrong folder anywhere. The
|
|
535
|
+
// server still checks the claimed name against what the code opens.
|
|
536
|
+
async function openPendingManaged() {
|
|
537
|
+
if (!pending) return;
|
|
538
|
+
setBusy('deeplink');
|
|
539
|
+
setError('');
|
|
540
|
+
const r = await openTeamProject({ kind: 'handoff', code: pending.code, claimedProject: pending.project });
|
|
541
|
+
setPending(null);
|
|
542
|
+
if (!r.ok) {
|
|
543
|
+
setBusy('');
|
|
544
|
+
setError(r.error || 'The project could not be opened.');
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
519
548
|
// A successful attach IS a credentialed link — mirror it into `local` so the
|
|
520
549
|
// project list flips to Connected without a second status fetch (the server
|
|
521
550
|
// just wrote both halves: linkedHub in config.json + the hub credential).
|
|
@@ -677,7 +706,8 @@ export default function CloudBar({ syncStatus, onLinkedHub }) {
|
|
|
677
706
|
status fetch, and a modal rendered without the local half showed no
|
|
678
707
|
warning and a primary Connect — failing OPEN in the one scenario with
|
|
679
708
|
the least context (attacker pass B3). */}
|
|
680
|
-
{pending && local.resolved && (
|
|
709
|
+
{pending?.kind === 'file' && local.resolved && !fileMatches && <FileDeepLinkDialog key={pending.project + pending.rel} pending={pending} local={local} cloudUrl={cloudUrl} onClose={() => setPending(null)} />}
|
|
710
|
+
{pending?.kind === 'connect' && local.resolved && (
|
|
681
711
|
<div
|
|
682
712
|
className="gi-modal"
|
|
683
713
|
role="dialog"
|
|
@@ -751,6 +781,22 @@ export default function CloudBar({ syncStatus, onLinkedHub }) {
|
|
|
751
781
|
</span>
|
|
752
782
|
</div>
|
|
753
783
|
)}
|
|
784
|
+
{isNativeApp() && (
|
|
785
|
+
<div className="gi-dl-open">
|
|
786
|
+
<button
|
|
787
|
+
type="button"
|
|
788
|
+
className="btn btn--primary"
|
|
789
|
+
disabled={busy === 'deeplink'}
|
|
790
|
+
onClick={openPendingManaged}
|
|
791
|
+
data-testid="cloud-deeplink-open"
|
|
792
|
+
>
|
|
793
|
+
{busy === 'deeplink' ? 'Opening…' : `Open ${pending.project}`}
|
|
794
|
+
</button>
|
|
795
|
+
<span className="gi-dc-foot-note">
|
|
796
|
+
Opens the project in its own copy on this computer. Nothing in this folder changes.
|
|
797
|
+
</span>
|
|
798
|
+
</div>
|
|
799
|
+
)}
|
|
754
800
|
<div className="gi-dc-foot">
|
|
755
801
|
{/* On a mismatch the safe action leads and connecting is the one you
|
|
756
802
|
have to mean. No project switcher lives here on purpose: the
|