@1agh/maude 0.51.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.
- package/apps/studio/client/app.jsx +25 -0
- package/apps/studio/client/github.js +2 -0
- package/apps/studio/client/report-bug.jsx +590 -0
- package/apps/studio/client/styles/4-components.css +84 -0
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/server.ts +7 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +8 -8
|
@@ -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 = {}) {
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// client/report-bug.jsx — the Report-a-Bug dialog (feature-bug-report-button).
|
|
2
|
+
//
|
|
3
|
+
// Consent-first by construction (docs/report-schema.md): the dialog's middle
|
|
4
|
+
// step IS the payload preview — every optional item (screenshot, log tail,
|
|
5
|
+
// project name, active canvas, crash logs) has its own checkbox, description
|
|
6
|
+
// is the only mandatory field, and nothing transmits until "Send report".
|
|
7
|
+
// Screenshots can be redacted in place (black-box rects, flattened into the
|
|
8
|
+
// PNG client-side — the original pixels never leave the machine).
|
|
9
|
+
//
|
|
10
|
+
// Transport: POST /_api/report (dev-server proxy → cloud intake). Cloud down
|
|
11
|
+
// or declined → POST /_api/report-fallback writes the bundle under
|
|
12
|
+
// `<designRoot>/_reports/` and we open a prefilled public-issue URL (text
|
|
13
|
+
// only — media never goes to the public repo).
|
|
14
|
+
|
|
15
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import { invoke, isNativeApp, openGitHubUrl } from './github.js';
|
|
18
|
+
|
|
19
|
+
const MAX_SHOTS = 3;
|
|
20
|
+
const PUBLIC_ISSUES_URL = 'https://github.com/1aGh/maude/issues/new';
|
|
21
|
+
|
|
22
|
+
function randHex(n) {
|
|
23
|
+
const bytes = new Uint8Array(n / 2);
|
|
24
|
+
crypto.getRandomValues(bytes);
|
|
25
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function installId() {
|
|
29
|
+
try {
|
|
30
|
+
let id = localStorage.getItem('maude-install-id');
|
|
31
|
+
if (!id) {
|
|
32
|
+
id = `i-${randHex(12)}`;
|
|
33
|
+
localStorage.setItem('maude-install-id', id);
|
|
34
|
+
}
|
|
35
|
+
return id;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Flatten one screenshot: source image + redaction rects → PNG blob. */
|
|
42
|
+
async function flattenShot(shot) {
|
|
43
|
+
const img = new Image();
|
|
44
|
+
img.src = shot.url;
|
|
45
|
+
await img.decode();
|
|
46
|
+
const canvas = document.createElement('canvas');
|
|
47
|
+
canvas.width = img.naturalWidth;
|
|
48
|
+
canvas.height = img.naturalHeight;
|
|
49
|
+
const c2d = canvas.getContext('2d');
|
|
50
|
+
c2d.drawImage(img, 0, 0);
|
|
51
|
+
c2d.fillStyle = '#000';
|
|
52
|
+
for (const r of shot.rects) {
|
|
53
|
+
c2d.fillRect(
|
|
54
|
+
r.x * img.naturalWidth,
|
|
55
|
+
r.y * img.naturalHeight,
|
|
56
|
+
r.w * img.naturalWidth,
|
|
57
|
+
r.h * img.naturalHeight
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const blobToDataUrl = (blob) =>
|
|
64
|
+
new Promise((resolve) => {
|
|
65
|
+
const fr = new FileReader();
|
|
66
|
+
fr.onload = () => resolve(fr.result);
|
|
67
|
+
fr.readAsDataURL(blob);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** One screenshot preview tile: redact-mode drag draws normalized black rects. */
|
|
71
|
+
function ShotTile({ shot, redacting, onRects, onRemove }) {
|
|
72
|
+
const boxRef = useRef(null);
|
|
73
|
+
const dragRef = useRef(null);
|
|
74
|
+
const [draft, setDraft] = useState(null);
|
|
75
|
+
|
|
76
|
+
const norm = (e) => {
|
|
77
|
+
const b = boxRef.current.getBoundingClientRect();
|
|
78
|
+
return {
|
|
79
|
+
x: Math.min(Math.max((e.clientX - b.left) / b.width, 0), 1),
|
|
80
|
+
y: Math.min(Math.max((e.clientY - b.top) / b.height, 0), 1),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="rb-shot" data-testid="report-bug-shot">
|
|
86
|
+
<div
|
|
87
|
+
ref={boxRef}
|
|
88
|
+
className={`rb-shot-box${redacting ? ' is-redacting' : ''}`}
|
|
89
|
+
onPointerDown={(e) => {
|
|
90
|
+
if (!redacting) return;
|
|
91
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
92
|
+
dragRef.current = norm(e);
|
|
93
|
+
setDraft({ ...dragRef.current, w: 0, h: 0 });
|
|
94
|
+
}}
|
|
95
|
+
onPointerMove={(e) => {
|
|
96
|
+
if (!redacting || !dragRef.current) return;
|
|
97
|
+
const p = norm(e);
|
|
98
|
+
const s = dragRef.current;
|
|
99
|
+
setDraft({
|
|
100
|
+
x: Math.min(s.x, p.x),
|
|
101
|
+
y: Math.min(s.y, p.y),
|
|
102
|
+
w: Math.abs(p.x - s.x),
|
|
103
|
+
h: Math.abs(p.y - s.y),
|
|
104
|
+
});
|
|
105
|
+
}}
|
|
106
|
+
onPointerUp={() => {
|
|
107
|
+
if (!redacting || !dragRef.current) return;
|
|
108
|
+
dragRef.current = null;
|
|
109
|
+
if (draft && draft.w > 0.005 && draft.h > 0.005) onRects([...shot.rects, draft]);
|
|
110
|
+
setDraft(null);
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<img src={shot.url} alt="screenshot to attach" draggable={false} />
|
|
114
|
+
{[...shot.rects, ...(draft ? [draft] : [])].map((r, i) => (
|
|
115
|
+
<div
|
|
116
|
+
key={i}
|
|
117
|
+
className="rb-redact-rect"
|
|
118
|
+
style={{
|
|
119
|
+
left: `${r.x * 100}%`,
|
|
120
|
+
top: `${r.y * 100}%`,
|
|
121
|
+
width: `${r.w * 100}%`,
|
|
122
|
+
height: `${r.h * 100}%`,
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
<div className="rb-shot-actions">
|
|
128
|
+
{shot.rects.length > 0 && (
|
|
129
|
+
<button type="button" className="rb-link" onClick={() => onRects([])}>
|
|
130
|
+
Clear redactions
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
<button type="button" className="rb-link" data-testid="report-bug-shot-remove" onClick={onRemove}>
|
|
134
|
+
Remove
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function ReportBugDialog({ open, onClose }) {
|
|
142
|
+
const [step, setStep] = useState('describe'); // describe | preview | sending | done | fallback
|
|
143
|
+
const [description, setDescription] = useState('');
|
|
144
|
+
const [bundle, setBundle] = useState(null);
|
|
145
|
+
const [shots, setShots] = useState([]); // {url, blob, rects:[{x,y,w,h} normalized]}
|
|
146
|
+
const [crashLogs, setCrashLogs] = useState([]); // native only: {name, firstLine}
|
|
147
|
+
const [consent, setConsent] = useState({
|
|
148
|
+
logTail: true,
|
|
149
|
+
activeCanvas: true,
|
|
150
|
+
projectName: false,
|
|
151
|
+
crashLogs: true,
|
|
152
|
+
});
|
|
153
|
+
const [redacting, setRedacting] = useState(false);
|
|
154
|
+
const [result, setResult] = useState(null); // {issueUrl, issueNumber} | {fallbackDir}
|
|
155
|
+
const [error, setError] = useState(null);
|
|
156
|
+
|
|
157
|
+
const reset = useCallback(() => {
|
|
158
|
+
for (const s of shots) URL.revokeObjectURL(s.url);
|
|
159
|
+
setStep('describe');
|
|
160
|
+
setDescription('');
|
|
161
|
+
setBundle(null);
|
|
162
|
+
setShots([]);
|
|
163
|
+
setCrashLogs([]);
|
|
164
|
+
setRedacting(false);
|
|
165
|
+
setResult(null);
|
|
166
|
+
setError(null);
|
|
167
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
168
|
+
}, [shots]);
|
|
169
|
+
|
|
170
|
+
const addShotBlob = useCallback((blob) => {
|
|
171
|
+
if (!blob) return;
|
|
172
|
+
setShots((prev) =>
|
|
173
|
+
prev.length >= MAX_SHOTS
|
|
174
|
+
? prev
|
|
175
|
+
: [...prev, { url: URL.createObjectURL(blob), blob, rects: [] }]
|
|
176
|
+
);
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
// On open: pull the scrubbed bundle, capture the active artboard, list
|
|
180
|
+
// native crash logs. Every leg fails soft — a report with fewer attachments
|
|
181
|
+
// beats no dialog.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!open) return;
|
|
184
|
+
let alive = true;
|
|
185
|
+
fetch('/_api/debug-bundle')
|
|
186
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
187
|
+
.then((b) => alive && setBundle(b))
|
|
188
|
+
.catch(() => {});
|
|
189
|
+
fetch('/_api/export', {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'Content-Type': 'application/json' },
|
|
192
|
+
body: JSON.stringify({ format: 'png', scope: 'artboard' }),
|
|
193
|
+
})
|
|
194
|
+
.then((r) => (r.ok ? r.blob() : null))
|
|
195
|
+
.then((blob) => alive && blob && addShotBlob(blob))
|
|
196
|
+
.catch(() => {});
|
|
197
|
+
if (isNativeApp()) {
|
|
198
|
+
invoke('list_crash_logs')
|
|
199
|
+
.then((logs) => alive && setCrashLogs(Array.isArray(logs) ? logs.slice(0, 3) : []))
|
|
200
|
+
.catch(() => {});
|
|
201
|
+
}
|
|
202
|
+
return () => {
|
|
203
|
+
alive = false;
|
|
204
|
+
};
|
|
205
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
206
|
+
}, [open]);
|
|
207
|
+
|
|
208
|
+
// Paste-to-attach — the lane that covers native-shell bugs the canvas
|
|
209
|
+
// capture can't see (user screenshots the window, pastes here).
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (!open) return;
|
|
212
|
+
function onPaste(e) {
|
|
213
|
+
for (const item of e.clipboardData?.items ?? []) {
|
|
214
|
+
if (item.type.startsWith('image/')) addShotBlob(item.getAsFile());
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
window.addEventListener('paste', onPaste);
|
|
218
|
+
return () => window.removeEventListener('paste', onPaste);
|
|
219
|
+
}, [open, addShotBlob]);
|
|
220
|
+
|
|
221
|
+
if (!open) return null;
|
|
222
|
+
|
|
223
|
+
const close = () => {
|
|
224
|
+
reset();
|
|
225
|
+
onClose();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
async function buildReport() {
|
|
229
|
+
const report = {
|
|
230
|
+
schema: 'maude-report/v1',
|
|
231
|
+
reportId: `r-${randHex(8)}`,
|
|
232
|
+
createdAt: new Date().toISOString(),
|
|
233
|
+
app: {
|
|
234
|
+
maudeVersion: bundle?.app?.maudeVersion ?? 'unknown',
|
|
235
|
+
surface: isNativeApp() ? 'native' : 'browser',
|
|
236
|
+
platform: bundle?.app?.platform ?? 'unknown',
|
|
237
|
+
arch: bundle?.app?.arch ?? 'unknown',
|
|
238
|
+
},
|
|
239
|
+
context: {
|
|
240
|
+
projectName: consent.projectName ? (bundle?.context?.projectName ?? null) : null,
|
|
241
|
+
activeCanvas: consent.activeCanvas ? (bundle?.context?.activeCanvas ?? null) : null,
|
|
242
|
+
},
|
|
243
|
+
description: description.trim(),
|
|
244
|
+
attachments: {
|
|
245
|
+
screenshots: shots.length,
|
|
246
|
+
serverLogTail: consent.logTail && !!bundle?.logs?.serverLogTail,
|
|
247
|
+
crashLogs: consent.crashLogs ? crashLogs.length : 0,
|
|
248
|
+
},
|
|
249
|
+
logs: {},
|
|
250
|
+
};
|
|
251
|
+
const id = installId();
|
|
252
|
+
if (id) report.installId = id;
|
|
253
|
+
if (consent.logTail && bundle?.logs?.serverLogTail) {
|
|
254
|
+
report.logs.serverLogTail = bundle.logs.serverLogTail;
|
|
255
|
+
}
|
|
256
|
+
if (consent.crashLogs && crashLogs.length && isNativeApp()) {
|
|
257
|
+
report.logs.crashLogs = [];
|
|
258
|
+
for (const log of crashLogs) {
|
|
259
|
+
try {
|
|
260
|
+
const body = await invoke('read_crash_log', { name: log.name });
|
|
261
|
+
report.logs.crashLogs.push({ name: log.name, body });
|
|
262
|
+
} catch {
|
|
263
|
+
/* an unreadable crash log is dropped, not fatal */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return report;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function send() {
|
|
271
|
+
setStep('sending');
|
|
272
|
+
setError(null);
|
|
273
|
+
try {
|
|
274
|
+
const report = await buildReport();
|
|
275
|
+
const form = new FormData();
|
|
276
|
+
form.set('report', JSON.stringify(report));
|
|
277
|
+
for (const shot of shots) {
|
|
278
|
+
form.append('screenshot', await flattenShot(shot), 'screenshot.png');
|
|
279
|
+
}
|
|
280
|
+
const res = await fetch('/_api/report', { method: 'POST', body: form });
|
|
281
|
+
const json = await res.json().catch(() => null);
|
|
282
|
+
if (res.ok && json?.ok) {
|
|
283
|
+
setResult(json);
|
|
284
|
+
setStep('done');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
setError(json?.error ?? `report failed (HTTP ${res.status})`);
|
|
288
|
+
setStep('fallback');
|
|
289
|
+
} catch {
|
|
290
|
+
setError('Maude could not reach the report service.');
|
|
291
|
+
setStep('fallback');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function saveFallback() {
|
|
296
|
+
try {
|
|
297
|
+
const report = await buildReport();
|
|
298
|
+
const screenshots = [];
|
|
299
|
+
for (const shot of shots) screenshots.push(await blobToDataUrl(await flattenShot(shot)));
|
|
300
|
+
const res = await fetch('/_api/report-fallback', {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
headers: { 'Content-Type': 'application/json' },
|
|
303
|
+
body: JSON.stringify({ report, screenshots }),
|
|
304
|
+
});
|
|
305
|
+
const json = await res.json().catch(() => null);
|
|
306
|
+
const title = encodeURIComponent(report.description.split('\n')[0].slice(0, 80));
|
|
307
|
+
const body = encodeURIComponent(
|
|
308
|
+
`${report.description}\n\n> Maude ${report.app.maudeVersion} · ${report.app.platform} ${report.app.arch} · ${report.app.surface}\n> Local report bundle: \`${json?.dir ?? '.design/_reports/'}\` (attach its files here)`
|
|
309
|
+
);
|
|
310
|
+
const url = `${PUBLIC_ISSUES_URL}?title=${title}&body=${body}&labels=report`;
|
|
311
|
+
if (isNativeApp()) {
|
|
312
|
+
try {
|
|
313
|
+
await openGitHubUrl(url);
|
|
314
|
+
} catch {
|
|
315
|
+
window.open(url, '_blank', 'noopener');
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
window.open(url, '_blank', 'noopener');
|
|
319
|
+
}
|
|
320
|
+
setResult({ fallbackDir: json?.dir ?? null });
|
|
321
|
+
setStep('done');
|
|
322
|
+
} catch {
|
|
323
|
+
setError('Saving the local bundle failed too — please file the issue by hand.');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const canPreview = description.trim().length > 0;
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<div className="help-modal-backdrop" onClick={close} data-testid="report-bug-dialog">
|
|
331
|
+
<div
|
|
332
|
+
className="help-modal rb-modal"
|
|
333
|
+
role="dialog"
|
|
334
|
+
aria-modal="true"
|
|
335
|
+
aria-labelledby="rb-title"
|
|
336
|
+
onClick={(e) => e.stopPropagation()}
|
|
337
|
+
>
|
|
338
|
+
<header className="help-modal-hd">
|
|
339
|
+
<span className="title" id="rb-title">
|
|
340
|
+
Report a bug
|
|
341
|
+
</span>
|
|
342
|
+
<button type="button" className="help-modal-close" onClick={close} aria-label="Close">
|
|
343
|
+
×
|
|
344
|
+
</button>
|
|
345
|
+
</header>
|
|
346
|
+
|
|
347
|
+
<div className="help-modal-body rb-body">
|
|
348
|
+
{step === 'describe' && (
|
|
349
|
+
<>
|
|
350
|
+
<label className="rb-label" htmlFor="rb-desc">
|
|
351
|
+
What happened? What did you expect instead?
|
|
352
|
+
</label>
|
|
353
|
+
<textarea
|
|
354
|
+
id="rb-desc"
|
|
355
|
+
className="rb-desc"
|
|
356
|
+
data-testid="report-bug-description"
|
|
357
|
+
rows={5}
|
|
358
|
+
autoFocus
|
|
359
|
+
value={description}
|
|
360
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
361
|
+
placeholder="e.g. Clicking Export → PNG shows a spinner forever…"
|
|
362
|
+
/>
|
|
363
|
+
<p className="rb-hint">
|
|
364
|
+
Next you'll see exactly what gets attached — nothing is sent until you approve it.
|
|
365
|
+
</p>
|
|
366
|
+
<footer className="rb-actions">
|
|
367
|
+
<button type="button" className="rb-btn" onClick={close}>
|
|
368
|
+
Cancel
|
|
369
|
+
</button>
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
className="rb-btn rb-primary"
|
|
373
|
+
data-testid="report-bug-preview"
|
|
374
|
+
disabled={!canPreview}
|
|
375
|
+
onClick={() => setStep('preview')}
|
|
376
|
+
>
|
|
377
|
+
Review & attach
|
|
378
|
+
</button>
|
|
379
|
+
</footer>
|
|
380
|
+
</>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
{step === 'preview' && (
|
|
384
|
+
<>
|
|
385
|
+
<div className="rb-section">
|
|
386
|
+
<div className="rb-section-hd">
|
|
387
|
+
<span>Screenshots ({shots.length})</span>
|
|
388
|
+
{shots.length > 0 && (
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
className={`rb-link${redacting ? ' is-on' : ''}`}
|
|
392
|
+
data-testid="report-bug-redact"
|
|
393
|
+
onClick={() => setRedacting((v) => !v)}
|
|
394
|
+
>
|
|
395
|
+
{redacting ? 'Done redacting' : 'Redact…'}
|
|
396
|
+
</button>
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
{shots.length === 0 ? (
|
|
400
|
+
<p className="rb-hint">
|
|
401
|
+
No screenshot attached. Paste one (⌘V) to add it — up to {MAX_SHOTS}.
|
|
402
|
+
</p>
|
|
403
|
+
) : (
|
|
404
|
+
<>
|
|
405
|
+
{redacting && (
|
|
406
|
+
<p className="rb-hint">Drag over anything private — it ships as solid black.</p>
|
|
407
|
+
)}
|
|
408
|
+
<div className="rb-shots">
|
|
409
|
+
{shots.map((shot, i) => (
|
|
410
|
+
<ShotTile
|
|
411
|
+
key={shot.url}
|
|
412
|
+
shot={shot}
|
|
413
|
+
redacting={redacting}
|
|
414
|
+
onRects={(rects) =>
|
|
415
|
+
setShots((prev) => prev.map((s, j) => (j === i ? { ...s, rects } : s)))
|
|
416
|
+
}
|
|
417
|
+
onRemove={() => {
|
|
418
|
+
URL.revokeObjectURL(shot.url);
|
|
419
|
+
setShots((prev) => prev.filter((_, j) => j !== i));
|
|
420
|
+
}}
|
|
421
|
+
/>
|
|
422
|
+
))}
|
|
423
|
+
</div>
|
|
424
|
+
{shots.length < MAX_SHOTS && (
|
|
425
|
+
<p className="rb-hint">Paste (⌘V) to attach another screenshot.</p>
|
|
426
|
+
)}
|
|
427
|
+
</>
|
|
428
|
+
)}
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
<div className="rb-section">
|
|
432
|
+
<div className="rb-section-hd">
|
|
433
|
+
<span>Included with the report</span>
|
|
434
|
+
</div>
|
|
435
|
+
<label className="rb-check">
|
|
436
|
+
<input type="checkbox" checked disabled />
|
|
437
|
+
<span>
|
|
438
|
+
Maude {bundle?.app?.maudeVersion ?? '…'} · {bundle?.app?.platform ?? '…'}{' '}
|
|
439
|
+
{bundle?.app?.arch ?? ''} · {isNativeApp() ? 'desktop app' : 'browser'}
|
|
440
|
+
</span>
|
|
441
|
+
</label>
|
|
442
|
+
{bundle?.context?.activeCanvas && (
|
|
443
|
+
<label className="rb-check">
|
|
444
|
+
<input
|
|
445
|
+
type="checkbox"
|
|
446
|
+
checked={consent.activeCanvas}
|
|
447
|
+
onChange={(e) => setConsent({ ...consent, activeCanvas: e.target.checked })}
|
|
448
|
+
/>
|
|
449
|
+
<span>
|
|
450
|
+
Active canvas path — <code>{bundle.context.activeCanvas}</code>
|
|
451
|
+
</span>
|
|
452
|
+
</label>
|
|
453
|
+
)}
|
|
454
|
+
{bundle?.context?.projectName && (
|
|
455
|
+
<label className="rb-check">
|
|
456
|
+
<input
|
|
457
|
+
type="checkbox"
|
|
458
|
+
checked={consent.projectName}
|
|
459
|
+
onChange={(e) => setConsent({ ...consent, projectName: e.target.checked })}
|
|
460
|
+
/>
|
|
461
|
+
<span>
|
|
462
|
+
Project name — <code>{bundle.context.projectName}</code>
|
|
463
|
+
</span>
|
|
464
|
+
</label>
|
|
465
|
+
)}
|
|
466
|
+
{bundle?.logs?.serverLogTail ? (
|
|
467
|
+
<label className="rb-check">
|
|
468
|
+
<input
|
|
469
|
+
type="checkbox"
|
|
470
|
+
data-testid="report-bug-consent-logs"
|
|
471
|
+
checked={consent.logTail}
|
|
472
|
+
onChange={(e) => setConsent({ ...consent, logTail: e.target.checked })}
|
|
473
|
+
/>
|
|
474
|
+
<span>
|
|
475
|
+
Recent server log ({bundle.logs.serverLogTail.split('\n').length} lines,
|
|
476
|
+
paths & secrets scrubbed) —{' '}
|
|
477
|
+
<details className="rb-details">
|
|
478
|
+
<summary>view</summary>
|
|
479
|
+
<pre className="rb-log">{bundle.logs.serverLogTail}</pre>
|
|
480
|
+
</details>
|
|
481
|
+
</span>
|
|
482
|
+
</label>
|
|
483
|
+
) : null}
|
|
484
|
+
{isNativeApp() && crashLogs.length > 0 && (
|
|
485
|
+
<label className="rb-check">
|
|
486
|
+
<input
|
|
487
|
+
type="checkbox"
|
|
488
|
+
checked={consent.crashLogs}
|
|
489
|
+
onChange={(e) => setConsent({ ...consent, crashLogs: e.target.checked })}
|
|
490
|
+
/>
|
|
491
|
+
<span>
|
|
492
|
+
Crash logs ({crashLogs.length}) — <code>{crashLogs.map((l) => l.name).join(', ')}</code>
|
|
493
|
+
</span>
|
|
494
|
+
</label>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<p className="rb-hint">
|
|
499
|
+
Your description and the system info above become a{' '}
|
|
500
|
+
<strong>public GitHub issue</strong>; screenshots and logs go to a private
|
|
501
|
+
repository only maintainers can open.
|
|
502
|
+
</p>
|
|
503
|
+
<footer className="rb-actions">
|
|
504
|
+
<button type="button" className="rb-btn" onClick={() => setStep('describe')}>
|
|
505
|
+
Back
|
|
506
|
+
</button>
|
|
507
|
+
<button
|
|
508
|
+
type="button"
|
|
509
|
+
className="rb-btn rb-primary"
|
|
510
|
+
data-testid="report-bug-send"
|
|
511
|
+
onClick={send}
|
|
512
|
+
>
|
|
513
|
+
Send report
|
|
514
|
+
</button>
|
|
515
|
+
</footer>
|
|
516
|
+
</>
|
|
517
|
+
)}
|
|
518
|
+
|
|
519
|
+
{step === 'sending' && <p className="rb-hint">Sending your report…</p>}
|
|
520
|
+
|
|
521
|
+
{step === 'done' && (
|
|
522
|
+
<>
|
|
523
|
+
{result?.issueUrl ? (
|
|
524
|
+
<p>
|
|
525
|
+
Thank you — your report is filed as{' '}
|
|
526
|
+
<button
|
|
527
|
+
type="button"
|
|
528
|
+
className="rb-link"
|
|
529
|
+
data-testid="report-bug-issue-link"
|
|
530
|
+
onClick={() => {
|
|
531
|
+
if (isNativeApp()) {
|
|
532
|
+
openGitHubUrl(result.issueUrl).catch(() =>
|
|
533
|
+
navigator.clipboard?.writeText(result.issueUrl)
|
|
534
|
+
);
|
|
535
|
+
} else {
|
|
536
|
+
window.open(result.issueUrl, '_blank', 'noopener');
|
|
537
|
+
}
|
|
538
|
+
}}
|
|
539
|
+
>
|
|
540
|
+
report #{result.issueNumber}
|
|
541
|
+
</button>
|
|
542
|
+
{' '}
|
|
543
|
+
on the public Maude issue tracker. Screenshots and logs stay in a private
|
|
544
|
+
repository only maintainers can open — the issue links to them.
|
|
545
|
+
</p>
|
|
546
|
+
) : (
|
|
547
|
+
<p>
|
|
548
|
+
The bundle is saved locally
|
|
549
|
+
{result?.fallbackDir ? (
|
|
550
|
+
<>
|
|
551
|
+
{' '}
|
|
552
|
+
at <code>{result.fallbackDir}</code>
|
|
553
|
+
</>
|
|
554
|
+
) : null}{' '}
|
|
555
|
+
and a prefilled GitHub issue was opened — attach the bundle files there.
|
|
556
|
+
</p>
|
|
557
|
+
)}
|
|
558
|
+
<footer className="rb-actions">
|
|
559
|
+
<button type="button" className="rb-btn rb-primary" onClick={close}>
|
|
560
|
+
Done
|
|
561
|
+
</button>
|
|
562
|
+
</footer>
|
|
563
|
+
</>
|
|
564
|
+
)}
|
|
565
|
+
|
|
566
|
+
{step === 'fallback' && (
|
|
567
|
+
<>
|
|
568
|
+
<p className="rb-error" data-testid="report-bug-error">
|
|
569
|
+
{error}
|
|
570
|
+
</p>
|
|
571
|
+
<p className="rb-hint">
|
|
572
|
+
You can save the report locally instead and file it on GitHub yourself — the
|
|
573
|
+
prefilled issue carries the text; screenshots stay on this machine unless you
|
|
574
|
+
attach them by hand.
|
|
575
|
+
</p>
|
|
576
|
+
<footer className="rb-actions">
|
|
577
|
+
<button type="button" className="rb-btn" onClick={() => setStep('preview')}>
|
|
578
|
+
Back
|
|
579
|
+
</button>
|
|
580
|
+
<button type="button" className="rb-btn rb-primary" onClick={saveFallback}>
|
|
581
|
+
Save locally & open GitHub
|
|
582
|
+
</button>
|
|
583
|
+
</footer>
|
|
584
|
+
</>
|
|
585
|
+
)}
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
);
|
|
590
|
+
}
|