@1agh/maude 0.50.0 → 0.51.1

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.
@@ -78,6 +78,7 @@ import { PhotoKnobs } from './photo-knobs.jsx';
78
78
  import {
79
79
  appIsFirstRun,
80
80
  isNativeApp,
81
+ onMenuReportBug,
81
82
  onUpdateReady,
82
83
  pickMediaFile,
83
84
  pickMediaFiles,
@@ -88,6 +89,7 @@ import { TourOverlay } from './tour/overlay.jsx';
88
89
  import { QUICK_SETUP_TOUR } from './tour/quick-setup-tour.js';
89
90
  import { USAGE_TOUR } from './tour/usage-tour.js';
90
91
  import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
92
+ import { ReportBugDialog } from './report-bug.jsx';
91
93
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
92
94
 
93
95
  const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
@@ -3408,6 +3410,7 @@ function HelpDropdown({ onAction, onClose }) {
3408
3410
  items={[
3409
3411
  { id: 'shortcuts', label: 'Keyboard shortcuts', shortcut: '?' },
3410
3412
  { id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
3413
+ { id: 'report-bug', label: 'Report a bug…' },
3411
3414
  { sep: true },
3412
3415
  { id: 'tour', label: 'Take the tour' },
3413
3416
  { id: 'watch-intro', label: 'Watch the intro' },
@@ -3552,6 +3555,7 @@ function Menubar({
3552
3555
  onToggleShowHidden,
3553
3556
  onOpenHelp,
3554
3557
  onOpenShortcuts,
3558
+ onReportBug,
3555
3559
  onStartTour,
3556
3560
  onStartCollabTour,
3557
3561
  annotationsVisible,
@@ -3878,6 +3882,7 @@ function Menubar({
3878
3882
  onAction={(id) => {
3879
3883
  if (id === 'shortcuts') onOpenShortcuts?.();
3880
3884
  else if (id === 'help') onOpenHelp?.();
3885
+ else if (id === 'report-bug') onReportBug?.();
3881
3886
  else if (id === 'tour') onStartTour?.();
3882
3887
  else if (id === 'collab-tour') onStartCollabTour?.();
3883
3888
  else if (id === 'quick-setup') onOpenQuickSetup?.();
@@ -9158,6 +9163,17 @@ function App() {
9158
9163
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
9159
9164
  const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
9160
9165
  const [helpOpen, setHelpOpen] = useState(false);
9166
+ const [reportBugOpen, setReportBugOpen] = useState(false);
9167
+
9168
+ // Native Help ▸ Report a Bug… (menu.rs emits `menu://report-bug`) — same
9169
+ // lane as IdentityBar's File ▸ New Project… subscription.
9170
+ useEffect(() => {
9171
+ if (!isNativeApp()) return;
9172
+ const p = onMenuReportBug(() => setReportBugOpen(true));
9173
+ return () => {
9174
+ p.then((un) => un()).catch(() => {});
9175
+ };
9176
+ }, []);
9161
9177
  const [readinessOpen, setReadinessOpen] = useState(false);
9162
9178
  const [introOpen, setIntroOpen] = useState(false);
9163
9179
  const [quickSetupOpen, setQuickSetupOpen] = useState(false);
@@ -13309,6 +13325,13 @@ function App() {
13309
13325
  kbd: 'F1',
13310
13326
  run: () => setHelpOpen(true),
13311
13327
  },
13328
+ {
13329
+ id: 'report-bug',
13330
+ group: 'Help',
13331
+ label: 'Report a bug…',
13332
+ icon: 'help',
13333
+ run: () => setReportBugOpen(true),
13334
+ },
13312
13335
  ],
13313
13336
  [openSystem, toggleTheme, reloadActive, whatsNew]
13314
13337
  );
@@ -13522,6 +13545,7 @@ function App() {
13522
13545
  onToggleShowHidden={() => setShowHidden((v) => !v)}
13523
13546
  onOpenHelp={() => setHelpOpen(true)}
13524
13547
  onOpenShortcuts={() => setShortcutsOpen(true)}
13548
+ onReportBug={() => setReportBugOpen(true)}
13525
13549
  onStartTour={() => startTour(USAGE_TOUR)}
13526
13550
  onStartCollabTour={() => startTour(COLLAB_TOUR)}
13527
13551
  annotationsVisible={annotationsVisible}
@@ -14182,6 +14206,7 @@ function App() {
14182
14206
  startTour(USAGE_TOUR);
14183
14207
  }}
14184
14208
  />
14209
+ <ReportBugDialog open={reportBugOpen} onClose={() => setReportBugOpen(false)} />
14185
14210
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
14186
14211
  <ExportPanel center={exportCenter} />
14187
14212
  <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
@@ -45,6 +45,8 @@ export const onDeviceCode = (cb) => listen('github://device-code', cb);
45
45
  export const onSignedIn = (cb) => listen('github://signed-in', cb);
46
46
  /** Fire when the native File ▸ New Project… menu item is chosen. Returns an unlisten promise. */
47
47
  export const onMenuNewProject = (cb) => listen('menu://new-project', cb);
48
+ /** Fire when the native Help ▸ Report a Bug… menu item is chosen. Returns an unlisten promise. */
49
+ export const onMenuReportBug = (cb) => listen('menu://report-bug', cb);
48
50
 
49
51
  // ── dev-server endpoints ────────────────────────────────────────────────────────
50
52
  async function api(path, opts = {}) {
@@ -14,12 +14,38 @@
14
14
 
15
15
  import { useEffect, useRef, useState } from 'react';
16
16
 
17
+ import { invoke, isNativeApp, listen } from '../github.js';
18
+
17
19
  const api = async (path, init) => {
18
20
  const res = await fetch(path, init);
19
21
  const json = await res.json().catch(() => ({}));
20
22
  return { ok: res.ok, status: res.status, json };
21
23
  };
22
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
+
23
49
  function Spark({ size = 15 }) {
24
50
  return (
25
51
  <svg width={size} height={size} viewBox="0 0 32 32" fill="currentColor" aria-hidden="true">
@@ -76,6 +102,7 @@ export default function CloudBar() {
76
102
  const [note, setNote] = useState('');
77
103
  const [error, setError] = useState('');
78
104
  const [copied, setCopied] = useState(false);
105
+ const [pending, setPending] = useState(null); // { project, code } from maude://
79
106
  const railRef = useRef(null);
80
107
  const pollRef = useRef(null);
81
108
 
@@ -89,6 +116,43 @@ export default function CloudBar() {
89
116
  return () => clearInterval(pollRef.current);
90
117
  }, []);
91
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
+
92
156
  useEffect(() => {
93
157
  if (!menuOpen) return undefined;
94
158
  const onDoc = (e) => {
@@ -186,6 +250,28 @@ export default function CloudBar() {
186
250
 
187
251
  return (
188
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
+ )}
189
275
  {state === 'out' && (
190
276
  <>
191
277
  <button type="button" className="btn btn--ghost btn--sm gi-rail-signin" onClick={startSignIn} data-testid="cloud-signin">
@@ -226,21 +312,39 @@ export default function CloudBar() {
226
312
  <div className="gi-menu-item" aria-disabled="true">No projects yet — start one on the dashboard.</div>
227
313
  )}
228
314
  {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
- ))}
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
+ )}
244
348
  <div className="gi-menu-sep" />
245
349
  <button type="button" className="gi-menu-item" role="menuitem" onClick={() => window.open('https://cloud.maude.sh', '_blank', 'noopener')}>
246
350
  <Icon name="external" size={15} /> Open the dashboard