@1agh/maude 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/.ai/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/annotations-layer.tsx +1055 -278
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +173 -63
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +160 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/canvas-edit.ts +459 -10
- package/apps/studio/canvas-lib.tsx +523 -5
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +476 -88
- package/apps/studio/client/export-center.jsx +460 -0
- package/apps/studio/client/github.js +4 -0
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +437 -0
- package/apps/studio/client/styles/3-shell-maude.css +59 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/context-menu.tsx +31 -6
- package/apps/studio/dist/client.bundle.js +5561 -19
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/export-dialog.tsx +18 -25
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +376 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +37 -19
- package/apps/studio/footage/schema.test.ts +195 -0
- package/apps/studio/footage/schema.ts +484 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +198 -20
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +318 -64
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/server.ts +8 -2
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +19 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +46 -0
- package/apps/studio/test/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +43 -20
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +38 -1
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +70 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/commands/design.mjs +34 -3
- package/cli/lib/gitignore-block.mjs +2 -0
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// export-center.jsx — background export notification center
|
|
2
|
+
// (feature-background-export-notification-center).
|
|
3
|
+
//
|
|
4
|
+
// useExportCenter() → headless handle (hydrate + WS upsert + toast/panel state)
|
|
5
|
+
// <ExportBadge> → menubar entry point (busy pulse + running/queued count)
|
|
6
|
+
// <ExportToast> → completion notice (bottom-left, mirrors WhatsNewToast)
|
|
7
|
+
// <ExportPanel> → job list (reuses the help-modal backdrop)
|
|
8
|
+
//
|
|
9
|
+
// Structurally a near-exact twin of whats-new.jsx, but driven by live WS
|
|
10
|
+
// `export:job` pushes (via `upsert`, called from app.jsx's WS message handler)
|
|
11
|
+
// instead of a static feed. Styling lives in client/styles/3-shell-maude.css
|
|
12
|
+
// (`.st-exports`) and 4-components.css (`.st-export-*`).
|
|
13
|
+
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import { isNativeApp, saveExport } from './github.js';
|
|
16
|
+
|
|
17
|
+
const FORMAT_LABELS = {
|
|
18
|
+
png: 'PNG',
|
|
19
|
+
pdf: 'PDF',
|
|
20
|
+
svg: 'SVG',
|
|
21
|
+
html: 'HTML',
|
|
22
|
+
pptx: 'PPTX',
|
|
23
|
+
canva: 'Canva',
|
|
24
|
+
zip: 'ZIP',
|
|
25
|
+
mp4: 'MP4',
|
|
26
|
+
webm: 'WebM',
|
|
27
|
+
gif: 'GIF',
|
|
28
|
+
};
|
|
29
|
+
const SCOPE_LABELS = {
|
|
30
|
+
selection: 'Selection',
|
|
31
|
+
artboard: 'Artboard',
|
|
32
|
+
'canvas-as-separate': 'Canvas → separate',
|
|
33
|
+
'project-raw': 'Project (raw)',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async function fetchJobBytes(id) {
|
|
37
|
+
const r = await fetch(`/_api/export-jobs/download?id=${encodeURIComponent(id)}`);
|
|
38
|
+
if (!r.ok) return null;
|
|
39
|
+
const disp = r.headers.get('Content-Disposition') || '';
|
|
40
|
+
const filename = /filename="([^"]+)"/.exec(disp)?.[1] || null;
|
|
41
|
+
const blob = await r.blob();
|
|
42
|
+
return { blob, filename };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function autoDownloadBlob(id, fallbackName) {
|
|
46
|
+
const got = await fetchJobBytes(id);
|
|
47
|
+
if (!got) return;
|
|
48
|
+
const filename = got.filename || fallbackName || 'export';
|
|
49
|
+
const url = URL.createObjectURL(got.blob);
|
|
50
|
+
const a = document.createElement('a');
|
|
51
|
+
a.href = url;
|
|
52
|
+
a.download = filename;
|
|
53
|
+
document.body.appendChild(a);
|
|
54
|
+
a.click();
|
|
55
|
+
a.remove();
|
|
56
|
+
URL.revokeObjectURL(url);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function saveNative(id, fallbackName) {
|
|
60
|
+
const got = await fetchJobBytes(id);
|
|
61
|
+
if (!got) return null;
|
|
62
|
+
const filename = got.filename || fallbackName || 'export';
|
|
63
|
+
const bytes = Array.from(new Uint8Array(await got.blob.arrayBuffer()));
|
|
64
|
+
return saveExport(filename, bytes);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useExportCenter() {
|
|
68
|
+
const [jobs, setJobs] = useState(() => new Map());
|
|
69
|
+
const [panelOpen, setPanelOpen] = useState(false);
|
|
70
|
+
const [toastQueue, setToastQueue] = useState([]);
|
|
71
|
+
const [savingIds, setSavingIds] = useState(() => new Set());
|
|
72
|
+
const prevStatusRef = useRef(new Map());
|
|
73
|
+
// Dedup guards (not state — re-rendering on these changing would be
|
|
74
|
+
// pointless; they only gate one-shot side effects).
|
|
75
|
+
const finalizedRef = useRef(new Set()); // auto-download fires at most once per job
|
|
76
|
+
const startToastShownRef = useRef(new Set()); // "Exporting…" toast fires at most once per job
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
let alive = true;
|
|
80
|
+
fetch('/_api/export-jobs')
|
|
81
|
+
.then((r) => r.json())
|
|
82
|
+
.then((j) => {
|
|
83
|
+
if (!alive) return;
|
|
84
|
+
const m = new Map();
|
|
85
|
+
for (const job of Array.isArray(j?.jobs) ? j.jobs : []) {
|
|
86
|
+
m.set(job.id, job);
|
|
87
|
+
// Hydration is a SNAPSHOT, not a live transition — seed both guards
|
|
88
|
+
// so a page (re)load never replays a toast / auto-download for a
|
|
89
|
+
// job that already reached this status before we opened.
|
|
90
|
+
prevStatusRef.current.set(job.id, job.status);
|
|
91
|
+
startToastShownRef.current.add(job.id);
|
|
92
|
+
}
|
|
93
|
+
setJobs(m);
|
|
94
|
+
})
|
|
95
|
+
.catch(() => {
|
|
96
|
+
/* offline / restart — the badge just stays empty until a push arrives */
|
|
97
|
+
});
|
|
98
|
+
return () => {
|
|
99
|
+
alive = false;
|
|
100
|
+
};
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const upsert = useCallback((job) => {
|
|
104
|
+
if (!job || typeof job.id !== 'string') return;
|
|
105
|
+
setJobs((prev) => {
|
|
106
|
+
const next = new Map(prev);
|
|
107
|
+
next.set(job.id, job);
|
|
108
|
+
return next;
|
|
109
|
+
});
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// One toast per job, morphing in place as the job's live status changes
|
|
113
|
+
// (queued/running → done/failed) rather than stacking a separate "started"
|
|
114
|
+
// and "finished" toast. Detects two kinds of transitions:
|
|
115
|
+
// - first time a job is seen active (queued/running) → surfaces it so the
|
|
116
|
+
// user knows it's running in the background right after the dialog closes.
|
|
117
|
+
// - a done/failed transition → surfaces it (or refreshes it in place if
|
|
118
|
+
// it's still the front-of-queue toast from the "started" step above).
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const prevStatus = prevStatusRef.current;
|
|
121
|
+
const toSurface = [];
|
|
122
|
+
const justFinished = [];
|
|
123
|
+
// Single pass — read `was` for every job BEFORE prevStatus gets mutated
|
|
124
|
+
// below, so both the toast + auto-download decisions see the real
|
|
125
|
+
// "did this change just now" transition, not an already-updated ref.
|
|
126
|
+
for (const job of jobs.values()) {
|
|
127
|
+
const was = prevStatus.get(job.id);
|
|
128
|
+
const isActive = job.status === 'queued' || job.status === 'running';
|
|
129
|
+
const isFinished = job.status === 'done' || job.status === 'failed';
|
|
130
|
+
if (was === undefined && isActive && !startToastShownRef.current.has(job.id)) {
|
|
131
|
+
startToastShownRef.current.add(job.id);
|
|
132
|
+
toSurface.push(job.id);
|
|
133
|
+
} else if (was !== job.status && isFinished) {
|
|
134
|
+
toSurface.push(job.id);
|
|
135
|
+
justFinished.push(job);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const job of jobs.values()) prevStatus.set(job.id, job.status);
|
|
139
|
+
|
|
140
|
+
if (toSurface.length) {
|
|
141
|
+
setToastQueue((q) => {
|
|
142
|
+
const merged = q.filter((id) => !toSurface.includes(id));
|
|
143
|
+
return [...toSurface, ...merged].slice(0, 8);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Native (Tauri) never auto-pops the OS save dialog — it would interrupt
|
|
148
|
+
// whatever the user is doing elsewhere. Always an explicit "Save…" click.
|
|
149
|
+
if (isNativeApp() || !justFinished.length) return;
|
|
150
|
+
for (const job of justFinished) {
|
|
151
|
+
if (job.status !== 'done') continue;
|
|
152
|
+
if (finalizedRef.current.has(job.id)) continue;
|
|
153
|
+
// Focus-gated: avoids duplicate downloads when multiple tabs share one
|
|
154
|
+
// dev-server and both receive the same WS push.
|
|
155
|
+
if (!document.hasFocus()) continue;
|
|
156
|
+
finalizedRef.current.add(job.id);
|
|
157
|
+
void autoDownloadBlob(job.id, job.filename);
|
|
158
|
+
}
|
|
159
|
+
}, [jobs]);
|
|
160
|
+
|
|
161
|
+
const list = useMemo(
|
|
162
|
+
() =>
|
|
163
|
+
Array.from(jobs.values()).sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')),
|
|
164
|
+
[jobs]
|
|
165
|
+
);
|
|
166
|
+
const runningCount = list.filter((j) => j.status === 'running').length;
|
|
167
|
+
const queuedCount = list.filter((j) => j.status === 'queued').length;
|
|
168
|
+
|
|
169
|
+
const dismissToast = useCallback(() => setToastQueue((q) => q.slice(1)), []);
|
|
170
|
+
const openPanel = useCallback(() => setPanelOpen(true), []);
|
|
171
|
+
const closePanel = useCallback(() => setPanelOpen(false), []);
|
|
172
|
+
|
|
173
|
+
const withSaving = useCallback(async (job, fn) => {
|
|
174
|
+
setSavingIds((prev) => new Set(prev).add(job.id));
|
|
175
|
+
try {
|
|
176
|
+
await fn();
|
|
177
|
+
} finally {
|
|
178
|
+
setSavingIds((prev) => {
|
|
179
|
+
const next = new Set(prev);
|
|
180
|
+
next.delete(job.id);
|
|
181
|
+
return next;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
// Manual "Download" (web, unfocused-tab fallback) / "Save…" (native) — the
|
|
187
|
+
// toast/panel row action. Both mark the job finalized so the focus-gated
|
|
188
|
+
// auto-download effect above doesn't ALSO fire for it later, and both track
|
|
189
|
+
// a busy state (`savingIds`) so the button can show it's working instead of
|
|
190
|
+
// going silent while the bytes fetch + (for native) the OS dialog opens.
|
|
191
|
+
const download = useCallback(
|
|
192
|
+
async (job) => {
|
|
193
|
+
finalizedRef.current.add(job.id);
|
|
194
|
+
await withSaving(job, () => autoDownloadBlob(job.id, job.filename));
|
|
195
|
+
},
|
|
196
|
+
[withSaving]
|
|
197
|
+
);
|
|
198
|
+
const save = useCallback(
|
|
199
|
+
async (job) => {
|
|
200
|
+
finalizedRef.current.add(job.id);
|
|
201
|
+
await withSaving(job, () => saveNative(job.id, job.filename));
|
|
202
|
+
},
|
|
203
|
+
[withSaving]
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const toastJobId = panelOpen ? null : (toastQueue[0] ?? null);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
jobs: list,
|
|
210
|
+
upsert,
|
|
211
|
+
runningCount,
|
|
212
|
+
queuedCount,
|
|
213
|
+
busyCount: runningCount + queuedCount,
|
|
214
|
+
panelOpen,
|
|
215
|
+
openPanel,
|
|
216
|
+
closePanel,
|
|
217
|
+
showToast: !!toastJobId,
|
|
218
|
+
toastJob: toastJobId ? (jobs.get(toastJobId) ?? null) : null,
|
|
219
|
+
dismissToast,
|
|
220
|
+
download,
|
|
221
|
+
save,
|
|
222
|
+
savingIds,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function jobLabel(job) {
|
|
227
|
+
const fmt = FORMAT_LABELS[job.format] || String(job.format || '').toUpperCase();
|
|
228
|
+
const scope = SCOPE_LABELS[job.scope] || job.scope;
|
|
229
|
+
return `${fmt} · ${scope}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Native "Save…" opens the OS file picker; web "Download" streams a blob
|
|
233
|
+
// through <a download> — distinct verbs, so the busy label stays distinct too
|
|
234
|
+
// (the fetch-bytes step behind both can take several seconds on a large export).
|
|
235
|
+
function saveActionLabel(saving) {
|
|
236
|
+
if (isNativeApp()) return saving ? 'Saving…' : 'Save…';
|
|
237
|
+
return saving ? 'Downloading…' : 'Download';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function ProgressBar({ progress }) {
|
|
241
|
+
if (!progress || !progress.total) {
|
|
242
|
+
return <div className="st-export-progress st-export-progress--indeterminate" aria-hidden="true" />;
|
|
243
|
+
}
|
|
244
|
+
const pct = Math.max(0, Math.min(100, Math.round((progress.current / progress.total) * 100)));
|
|
245
|
+
return (
|
|
246
|
+
<div
|
|
247
|
+
className="st-export-progress"
|
|
248
|
+
role="progressbar"
|
|
249
|
+
aria-valuenow={pct}
|
|
250
|
+
aria-valuemin={0}
|
|
251
|
+
aria-valuemax={100}
|
|
252
|
+
>
|
|
253
|
+
<div className="st-export-progress-bar" style={{ width: `${pct}%` }} />
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Per-job anchor for the ETA: the FIRST progress sample the client sees. For a
|
|
259
|
+
// video export that's the first frame-step frame — after the (progress-less)
|
|
260
|
+
// renderMediaOnWeb attempt — so the rate is measured over real capture work and
|
|
261
|
+
// isn't skewed by that pre-roll. Keyed by job id; cleared when the job ends.
|
|
262
|
+
const etaAnchors = new Map();
|
|
263
|
+
|
|
264
|
+
/** Rough "~N min left" from the rate since the first observed progress sample.
|
|
265
|
+
* Recomputes on every progress update (frame-step emits ~1–2/s), so it ticks
|
|
266
|
+
* down live without a timer. Returns null until there's enough delta to trust. */
|
|
267
|
+
function etaLabel(job) {
|
|
268
|
+
const cur = job.progress?.current;
|
|
269
|
+
const total = job.progress?.total;
|
|
270
|
+
if (!cur || !total || cur >= total) {
|
|
271
|
+
etaAnchors.delete(job.id);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
let anchor = etaAnchors.get(job.id);
|
|
275
|
+
if (!anchor || cur < anchor.c) {
|
|
276
|
+
anchor = { t: Date.now(), c: cur };
|
|
277
|
+
etaAnchors.set(job.id, anchor);
|
|
278
|
+
}
|
|
279
|
+
const doneSinceAnchor = cur - anchor.c;
|
|
280
|
+
if (doneSinceAnchor < 3) return null; // need a few frames to estimate a rate
|
|
281
|
+
const remainingMs = ((Date.now() - anchor.t) / doneSinceAnchor) * (total - cur);
|
|
282
|
+
if (remainingMs < 45_000) return '<1 min left';
|
|
283
|
+
return `~${Math.round(remainingMs / 60_000)} min left`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function StatusPill({ job }) {
|
|
287
|
+
const statusText =
|
|
288
|
+
job.status === 'queued'
|
|
289
|
+
? 'Queued'
|
|
290
|
+
: job.status === 'running'
|
|
291
|
+
? job.progress?.total
|
|
292
|
+
? `${job.progress.current} of ${job.progress.total}`
|
|
293
|
+
: 'Rendering…'
|
|
294
|
+
: job.status === 'done'
|
|
295
|
+
? 'Ready'
|
|
296
|
+
: 'Failed';
|
|
297
|
+
const eta = job.status === 'running' ? etaLabel(job) : null;
|
|
298
|
+
return (
|
|
299
|
+
<span className={`st-export-pill st-export-pill--${job.status}`}>
|
|
300
|
+
{statusText}
|
|
301
|
+
{eta ? ` · ${eta}` : ''}
|
|
302
|
+
</span>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function ExportBadge({ center }) {
|
|
307
|
+
const busy = center.busyCount > 0;
|
|
308
|
+
return (
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
className="st-exports"
|
|
312
|
+
data-tour="exports"
|
|
313
|
+
data-busy={busy ? 'true' : 'false'}
|
|
314
|
+
aria-label={busy ? `Exports — ${center.busyCount} running` : 'Exports'}
|
|
315
|
+
data-tip="Exports"
|
|
316
|
+
onClick={center.openPanel}
|
|
317
|
+
>
|
|
318
|
+
<span aria-hidden="true">⬇</span>
|
|
319
|
+
{busy && (
|
|
320
|
+
<span className="st-exports__count" aria-hidden="true">
|
|
321
|
+
{center.busyCount}
|
|
322
|
+
</span>
|
|
323
|
+
)}
|
|
324
|
+
</button>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function ExportToast({ center }) {
|
|
329
|
+
if (!center.showToast || !center.toastJob) return null;
|
|
330
|
+
const job = center.toastJob;
|
|
331
|
+
const pending = job.status === 'queued' || job.status === 'running';
|
|
332
|
+
const ok = job.status === 'done';
|
|
333
|
+
const saving = center.savingIds.has(job.id);
|
|
334
|
+
const finalize = () => {
|
|
335
|
+
if (isNativeApp()) void center.save(job);
|
|
336
|
+
else void center.download(job);
|
|
337
|
+
};
|
|
338
|
+
return (
|
|
339
|
+
<div className="st-toast" role="status" aria-live="polite">
|
|
340
|
+
<button type="button" className="st-toast-close" aria-label="Dismiss" onClick={center.dismissToast}>
|
|
341
|
+
×
|
|
342
|
+
</button>
|
|
343
|
+
<div className="st-toast-hd">
|
|
344
|
+
<span aria-hidden="true">{pending ? '⋯' : ok ? '⬇' : '⚠'}</span>
|
|
345
|
+
{pending ? 'Exporting…' : ok ? 'Export ready' : 'Export failed'}
|
|
346
|
+
</div>
|
|
347
|
+
<div className="st-toast-title">{jobLabel(job)}</div>
|
|
348
|
+
{pending ? (
|
|
349
|
+
<>
|
|
350
|
+
<div className="st-toast-txt">Running in the background — you can keep working.</div>
|
|
351
|
+
<ProgressBar progress={job.progress} />
|
|
352
|
+
</>
|
|
353
|
+
) : (
|
|
354
|
+
<div className="st-toast-txt">
|
|
355
|
+
{ok ? (job.filename ?? 'Ready to download.') : (job.error ?? 'Something went wrong.')}
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
{ok && (
|
|
359
|
+
<div className="st-toast-actions">
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
className="btn btn--primary btn--sm"
|
|
363
|
+
onClick={finalize}
|
|
364
|
+
disabled={saving}
|
|
365
|
+
>
|
|
366
|
+
{saving && <span className="st-btn-spin" aria-hidden="true" />}
|
|
367
|
+
{saveActionLabel(saving)}
|
|
368
|
+
</button>
|
|
369
|
+
<button type="button" className="btn btn--ghost btn--sm" onClick={center.dismissToast}>
|
|
370
|
+
Dismiss
|
|
371
|
+
</button>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function ExportPanel({ center }) {
|
|
379
|
+
const { panelOpen, closePanel } = center;
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
if (!panelOpen) return;
|
|
382
|
+
function onKey(e) {
|
|
383
|
+
if (e.key === 'Escape') closePanel();
|
|
384
|
+
}
|
|
385
|
+
window.addEventListener('keydown', onKey);
|
|
386
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
387
|
+
}, [panelOpen, closePanel]);
|
|
388
|
+
|
|
389
|
+
if (!panelOpen) return null;
|
|
390
|
+
return (
|
|
391
|
+
<div
|
|
392
|
+
className="help-modal-backdrop"
|
|
393
|
+
role="presentation"
|
|
394
|
+
onMouseDown={(e) => {
|
|
395
|
+
if (e.target === e.currentTarget) closePanel();
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
<div
|
|
399
|
+
className="st-export-panel"
|
|
400
|
+
role="dialog"
|
|
401
|
+
aria-modal="true"
|
|
402
|
+
aria-labelledby="st-export-panel-title"
|
|
403
|
+
>
|
|
404
|
+
<header className="help-modal-hd">
|
|
405
|
+
<span className="title" id="st-export-panel-title">
|
|
406
|
+
Exports
|
|
407
|
+
</span>
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
className="help-modal-close"
|
|
411
|
+
aria-label="Close (Esc)"
|
|
412
|
+
onClick={closePanel}
|
|
413
|
+
>
|
|
414
|
+
×
|
|
415
|
+
</button>
|
|
416
|
+
</header>
|
|
417
|
+
<div className="st-export-panel__body">
|
|
418
|
+
{center.jobs.length === 0 ? (
|
|
419
|
+
<p className="mdcc-wn-empty">No exports yet.</p>
|
|
420
|
+
) : (
|
|
421
|
+
<ul className="st-export-list">
|
|
422
|
+
{center.jobs.map((job) => (
|
|
423
|
+
<li key={job.id} className="st-export-item">
|
|
424
|
+
<div className="st-export-item__hd">
|
|
425
|
+
<span className="st-export-item__label">{jobLabel(job)}</span>
|
|
426
|
+
<StatusPill job={job} />
|
|
427
|
+
</div>
|
|
428
|
+
{(job.status === 'queued' || job.status === 'running') && (
|
|
429
|
+
<ProgressBar progress={job.progress} />
|
|
430
|
+
)}
|
|
431
|
+
{job.status === 'done' && (
|
|
432
|
+
<div className="st-export-item__ft">
|
|
433
|
+
<span className="st-export-item__filename">{job.filename}</span>
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
className="btn btn--ghost btn--sm"
|
|
437
|
+
disabled={center.savingIds.has(job.id)}
|
|
438
|
+
onClick={() => (isNativeApp() ? center.save(job) : center.download(job))}
|
|
439
|
+
>
|
|
440
|
+
{center.savingIds.has(job.id) && (
|
|
441
|
+
<span className="st-btn-spin" aria-hidden="true" />
|
|
442
|
+
)}
|
|
443
|
+
{saveActionLabel(center.savingIds.has(job.id))}
|
|
444
|
+
</button>
|
|
445
|
+
</div>
|
|
446
|
+
)}
|
|
447
|
+
{job.status === 'failed' && (
|
|
448
|
+
<div className="st-export-item__ft">
|
|
449
|
+
<span className="st-export-item__error">{job.error ?? 'Export failed.'}</span>
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
</li>
|
|
453
|
+
))}
|
|
454
|
+
</ul>
|
|
455
|
+
)}
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
@@ -35,6 +35,10 @@ export const signOut = () => invoke('github_sign_out');
|
|
|
35
35
|
export const isSignedIn = () => invoke('github_is_signed_in');
|
|
36
36
|
export const openVerification = () =>
|
|
37
37
|
invoke('github_open_verification', { url: 'https://github.com/login/device' });
|
|
38
|
+
/** Open a github.com URL (PR link, repo page) in the OS browser. Native only; the
|
|
39
|
+
* Rust side host-locks it to github.com. Throws on an older desktop build without
|
|
40
|
+
* the command — callers fall back to copy-to-clipboard. */
|
|
41
|
+
export const openGitHubUrl = (url) => invoke('open_github_url', { url });
|
|
38
42
|
/** Show the device code as soon as the shell has it. Returns an unlisten promise. */
|
|
39
43
|
export const onDeviceCode = (cb) => listen('github://device-code', cb);
|
|
40
44
|
/** Fire when sign-in completes (any surface) so other surfaces can flip live. cb(login). */
|
|
@@ -364,6 +364,14 @@ export default function GitPanel({
|
|
|
364
364
|
getLatest: true,
|
|
365
365
|
}
|
|
366
366
|
);
|
|
367
|
+
else if (kind === 'publish' && /protected branch|GH006/i.test(res.error || ''))
|
|
368
|
+
// A protected default branch rejects a direct Publish — point at the PR flow
|
|
369
|
+
// (DDR-162): the way to add work to a protected branch is a draft + a pull request.
|
|
370
|
+
setBanner({
|
|
371
|
+
variant: 'warn',
|
|
372
|
+
title: 'This branch is protected',
|
|
373
|
+
text: 'It needs a pull request. Switch to a draft, then use “Add to Shared version” to open one.',
|
|
374
|
+
});
|
|
367
375
|
else setBanner({ variant: 'error', text: res.error || 'Something went wrong.' });
|
|
368
376
|
return res;
|
|
369
377
|
} finally {
|