@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.
Files changed (83) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +689 -36
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-edit.ts +391 -7
  10. package/apps/studio/canvas-lib.tsx +82 -18
  11. package/apps/studio/canvas-notice-message.ts +16 -0
  12. package/apps/studio/canvas-notifications.tsx +19 -0
  13. package/apps/studio/canvas-shell.tsx +57 -6
  14. package/apps/studio/client/app.jsx +686 -241
  15. package/apps/studio/client/apply-edit-request.ts +55 -0
  16. package/apps/studio/client/export-center.jsx +111 -61
  17. package/apps/studio/client/github.js +38 -1
  18. package/apps/studio/client/index-loader.ts +76 -0
  19. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  20. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  21. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  23. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  24. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  25. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  26. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  27. package/apps/studio/client/photo-knobs.jsx +3 -0
  28. package/apps/studio/client/share-dialog.jsx +91 -0
  29. package/apps/studio/client/share-link.js +94 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +35 -18
  31. package/apps/studio/client/styles/4-components.css +18 -1
  32. package/apps/studio/client/tree-row-menu.jsx +48 -3
  33. package/apps/studio/client/whats-new.jsx +26 -32
  34. package/apps/studio/cloud/endpoints.ts +132 -0
  35. package/apps/studio/collab/index.ts +4 -0
  36. package/apps/studio/collab/persistence.ts +5 -1
  37. package/apps/studio/collab/registry.ts +9 -3
  38. package/apps/studio/collab/room.ts +13 -2
  39. package/apps/studio/context.ts +59 -0
  40. package/apps/studio/dist/client.bundle.js +1586 -1554
  41. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  42. package/apps/studio/dist/runtime/sonner.js +1 -0
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/http.ts +289 -18
  45. package/apps/studio/inspect.ts +19 -0
  46. package/apps/studio/managed-projects.ts +150 -0
  47. package/apps/studio/notifications.tsx +292 -0
  48. package/apps/studio/runtime-bundle.ts +2 -0
  49. package/apps/studio/server.ts +59 -3
  50. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  51. package/apps/studio/sync/accepted-link.ts +320 -0
  52. package/apps/studio/sync/action-stage.ts +343 -0
  53. package/apps/studio/sync/agent.ts +12 -60
  54. package/apps/studio/sync/cell-file-events.ts +3 -0
  55. package/apps/studio/sync/codec.ts +73 -6
  56. package/apps/studio/sync/ctl-provider.ts +15 -2
  57. package/apps/studio/sync/document-discovery.ts +81 -0
  58. package/apps/studio/sync/file-membership.ts +10 -2
  59. package/apps/studio/sync/file-plane.ts +309 -26
  60. package/apps/studio/sync/index.ts +1078 -19
  61. package/apps/studio/sync/migrate-seed.ts +110 -19
  62. package/apps/studio/sync/poke.ts +4 -2
  63. package/apps/studio/sync/presentation.ts +285 -2
  64. package/apps/studio/sync/projection.ts +820 -23
  65. package/apps/studio/sync/repeated-module.ts +73 -0
  66. package/apps/studio/sync/revision-barrier.ts +129 -0
  67. package/apps/studio/sync/seed-repair.ts +46 -0
  68. package/apps/studio/sync/source-merge.ts +100 -0
  69. package/apps/studio/sync/source-ops.ts +289 -0
  70. package/apps/studio/sync/source-recovery.ts +70 -0
  71. package/apps/studio/sync/source-validation.ts +56 -0
  72. package/apps/studio/sync/status.ts +79 -1
  73. package/apps/studio/sync/transaction-client.ts +460 -0
  74. package/apps/studio/sync/writer-registry.ts +236 -0
  75. package/apps/studio/text-caret.ts +35 -0
  76. package/apps/studio/undo-hud.tsx +9 -87
  77. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  78. package/apps/studio/whats-new.json +101 -0
  79. package/cli/lib/harness/codex-runtime.mjs +4 -1
  80. package/package.json +9 -8
  81. package/plugins/design/dependencies.json +3 -3
  82. package/plugins/design/templates/_shell.html +47 -0
  83. 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].slice(0, 8);
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) => (b.createdAt || '').localeCompare(a.createdAt || '')),
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(() => setToastQueue((q) => q.slice(1)), []);
179
- const openPanel = useCallback(() => setPanelOpen(true), []);
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 <div className="st-export-progress st-export-progress--indeterminate" aria-hidden="true" />;
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">⚠</span> {headline}
348
- {degraded.remedy ? <div className="st-export-degraded__fix">{degraded.remedy}</div> : null}
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
- export function ExportToast({ center }) {
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 finalize = () => {
382
- if (isNativeApp()) void center.save(job);
383
- else void center.download(job);
384
- };
385
- return (
386
- <div className="st-toast" role="status" aria-live="polite">
387
- <button type="button" className="st-toast-close" aria-label="Dismiss" onClick={center.dismissToast}>
388
- ×
389
- </button>
390
- <div className="st-toast-hd">
391
- <span aria-hidden="true">{pending ? '⋯' : ok && !job.degraded ? '⬇' : '⚠'}</span>
392
- {pending
393
- ? 'Exporting…'
394
- : ok
395
- ? job.degraded
396
- ? 'Export ready — with a problem'
397
- : 'Export ready'
398
- : 'Export failed'}
399
- </div>
400
- <div className="st-toast-title">{jobLabel(job)}</div>
401
- {pending ? (
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="st-toast-txt">Running in the background — you can keep working.</div>
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
- <div className="st-toast-txt">
408
- {ok ? (job.filename ?? 'Ready to download.') : (job.error ?? 'Something went wrong.')}
409
- </div>
410
- )}
411
- {ok && <DegradedNote degraded={job.degraded} />}
412
- {ok && (
413
- <div className="st-toast-actions">
414
- <button
415
- type="button"
416
- className="btn btn--primary btn--sm"
417
- onClick={finalize}
418
- disabled={saving}
419
- >
420
- {saving && <span className="st-btn-spin" aria-hidden="true" />}
421
- {saveActionLabel(saving)}
422
- </button>
423
- <button type="button" className="btn btn--ghost btn--sm" onClick={center.dismissToast}>
424
- Dismiss
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 key={job.id} className="st-export-item">
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
- <span className="st-export-item__error">{job.error ?? 'Export failed.'}</span>
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 code = new URLSearchParams(m[2]).get('code') ?? '';
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 parsed = parseDeepLink(url);
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