From c3a067643b1cc7aa6b6468bc3e90bc029c640073 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Sat, 3 Oct 2026 19:15:06 -0400 Subject: [PATCH 1/4] feat(plugin): desktop attachments band and staged pane in the uploads mod --- .changeset/plugin-desktop-attachments.md | 5 + plugins/claude/uploads/README.md | 13 +- plugins/claude/uploads/hooks/register.test.ts | 292 ++++++++- plugins/claude/uploads/hooks/register.tsx | 608 +++++++++++++++++- plugins/claude/uploads/types/index.d.ts | 20 +- 5 files changed, 890 insertions(+), 48 deletions(-) create mode 100644 .changeset/plugin-desktop-attachments.md diff --git a/.changeset/plugin-desktop-attachments.md b/.changeset/plugin-desktop-attachments.md new file mode 100644 index 00000000..25865fb0 --- /dev/null +++ b/.changeset/plugin-desktop-attachments.md @@ -0,0 +1,5 @@ +--- +"@uploads/plugin": minor +--- + +The staged media mod now has a desktop band and a pane. On the Desktop app's Code tab, the band shows the mark, a short headline, up to four thumbnails, a View button and a Hide button. View, or the new `/uploads-staged` command, opens a "Staged attachments" pane with a thumbnail grid, per-file detail, copy-markdown, remove, and a PR feed link. All surfaces now say "attachments" and leave the branch name out of the band. diff --git a/plugins/claude/uploads/README.md b/plugins/claude/uploads/README.md index 21368c87..018f82f1 100644 --- a/plugins/claude/uploads/README.md +++ b/plugins/claude/uploads/README.md @@ -15,13 +15,18 @@ describing it in prose. | uploads-cli skill | `/uploads:uploads-cli` | Full reference for the optional `uploads` CLI | | uploads MCP server | (tools) | Hosted server at `https://agents.uploads.sh/mcp` | | PR screenshot reminder hook | (automatic) | Advisory nudge before `gh pr create` on a UI branch with no screenshots | -| Staged media mod | (automatic) | Shows staged screenshots above the prompt; attaches them on PR open | +| Staged media mod | `/uploads-staged` | Shows staged attachments above the prompt; attaches them on PR open | The staged media mod needs Claude Code 2.1.287 or later. Older versions load the rest of the plugin and skip the mod. It draws in the terminal and the Desktop app's Code tab. Elsewhere, such as the VS Code extension's chat panel or `claude -p`, it still attaches files and tells Claude, but shows nothing. +On the Desktop app, the band shows thumbnails of the staged attachments. **View** +or `/uploads-staged` opens a pane where you can see each one, copy its +markdown, remove it, or copy the PR's feed link. **Hide** hides the band until +the staged set changes. + ## Install ``` @@ -74,6 +79,12 @@ looks up the PR's head branch: **Attach staged files on gh pr create** option in `/plugin` to have it only report what is waiting. +On the Desktop app, the pane also runs `uploads delete` when you remove an +attachment, `uploads feed create` when you copy the feed link, and `gh pr view` +to find the branch's open PR. Thumbnails are fetched with `curl` from +`storage.uploads.sh`, resized by Cloudflare's image transform, and kept in the +session's memory only. + If the CLI is not installed or not signed in, the mod does nothing. See the [privacy policy](https://uploads.sh/privacy) and diff --git a/plugins/claude/uploads/hooks/register.test.ts b/plugins/claude/uploads/hooks/register.test.ts index b6c2a139..9364ab32 100644 --- a/plugins/claude/uploads/hooks/register.test.ts +++ b/plugins/claude/uploads/hooks/register.test.ts @@ -23,6 +23,10 @@ type Cli = { files?: { filename: string; state?: string; path?: string }[]; /** How `attach --promote` ends: everything moved (default), one skipped, or a server error. */ promote?: "ok" | "partial" | "error"; + /** The open PR for the session branch, as `gh pr view` reports it. */ + openPr?: number; + /** Thumbnail fetches (`curl | base64`) fail when set. */ + thumbsFail?: boolean; }; type World = { @@ -31,6 +35,7 @@ type World = { toasts: string[]; clock: MockClock; output: { text: string }; + opened: string[]; }; // The engine beneath the plugin: a still clock, nothing drawn when the plugin @@ -50,6 +55,7 @@ const setup = (on: On, over: Partial = {}, text = PR_OUTPUT): World => { toasts: [], clock: mock.clock(on, { now: 1_000_000 }), output: { text }, + opened: [], }; on("ui.render", async ($, e) => { const { Box } = $.ui.resolve(e); @@ -59,6 +65,10 @@ const setup = (on: On, over: Partial = {}, text = PR_OUTPUT): World => { "tool.call", async () => ({ result: { stdout: world.output.text }, text: world.output.text }) as never, ); + on("ui.open", async (_$, e) => { + world.opened.push(`${e.id}:${e.title}`); + return { value: undefined } as never; + }); on("ui.toast", async (_$, e) => { world.toasts.push(e.text); return { value: undefined }; @@ -67,8 +77,32 @@ const setup = (on: On, over: Partial = {}, text = PR_OUTPUT): World => { const argv = [...e.argv]; world.argvs.push(argv); if (argv[0] === "gh") { + if (argv.includes("number,url,state")) { + return cli.openPr + ? ok( + JSON.stringify({ + number: cli.openPr, + url: `https://github.com/${REPO}/pull/${cli.openPr}`, + state: "OPEN", + }), + ) + : { deny: "no pull request" }; + } return ok(`${cli.head}\n`); } + if (argv[0] === "sh") { + return cli.thumbsFail ? { deny: "curl failed" } : ok("AAAA\nBBBB\n"); + } + if (argv[1] === "feed") { + return ok(`https://uploads.sh/feed/abc\nmore\n`); + } + if (argv[1] === "delete") { + const key = argv[2] ?? ""; + const branch = cli.sessionBranch; + cli.counts[branch] = Math.max(0, (cli.counts[branch] ?? 0) - 1); + cli.files = cli.files?.filter((f) => `gh/x/${f.filename}` !== key); + return ok(""); + } if (argv[1] === "attach") { const from = argv[argv.indexOf("--from-branch") + 1] ?? cli.sessionBranch; const staged = cli.counts[from] ?? 0; @@ -95,6 +129,7 @@ const setup = (on: On, over: Partial = {}, text = PR_OUTPUT): World => { key: `gh/x/${name}`, filename: name, size: 94_000, + stagedAt: new Date(1_000_000 - 5 * 60_000).toISOString(), url: `https://storage.uploads.sh/default/gh/x/${name}`, ...cli.files?.[n], }; @@ -145,7 +180,7 @@ describe("gh pr create", () => { "feat/x", "--json", ]); - expect(world.toasts).toContainEqual("uploads: attached 2 staged files to PR #1051"); + expect(world.toasts).toContainEqual("uploads: added 2 attachments to PR #1051"); expect(ran.context?.join("\n")).toContain("PR #1051"); }); @@ -155,7 +190,7 @@ describe("gh pr create", () => { const ran = await runBash($, world, "gh pr create --fill"); expect(world.toasts).toContainEqual( - "uploads: couldn't attach 2 staged files to PR #1051; run uploads attach --promote", + "uploads: couldn't add 2 attachments to PR #1051; run uploads attach --promote", ); expect(ran.context?.join("\n")).toContain("did not attach"); // Nothing is recorded as attached, so the band keeps showing the files. @@ -167,7 +202,7 @@ describe("gh pr create", () => { const ran = await runBash($, world, "gh pr create --fill"); - expect(world.toasts).toContainEqual("uploads: attached 2 staged files to PR #1051; 1 skipped"); + expect(world.toasts).toContainEqual("uploads: added 2 attachments to PR #1051; 1 skipped"); expect(ran.context?.join("\n")).toContain("1 was skipped"); }); @@ -191,7 +226,7 @@ describe("gh pr create", () => { expect(promoted(world)).toBe(false); expect(world.toasts).toContainEqual( - "uploads: 1 staged file will attach to PR #1051 via the uploads-sh bot", + "uploads: 1 attachment will attach to PR #1051 via the uploads-sh bot", ); }); @@ -206,7 +241,7 @@ describe("gh pr create", () => { expect(promoted(world)).toBe(false); expect(world.toasts).toContainEqual( - "uploads: 3 staged files attached to PR #1051 by the uploads-sh bot", + "uploads: 3 attachments added to PR #1051 by the uploads-sh bot", ); }); @@ -239,23 +274,21 @@ describe("band", () => { await runBash($, world, STAGE); const ui = await mountBand($, surface); - expect( - await ui.find({ type: "Text", text: /3 uploads waiting for a PR on feat\/x/ }), - ).toBeTruthy(); + expect(await ui.find({ type: "Text", text: /3 attachments waiting for a PR$/ })).toBeTruthy(); + // The branch stays out of the band. + expect(await ui.find({ text: /feat\/x/ })).toBeUndefined(); }); } test("follows the session to a new branch", async ($, on) => { const world = setup(on, { counts: { "feat/x": 1, "feat/y": 2 }, binding: ["self", true] }, ""); await runBash($, world, STAGE); - expect( - await (await mountBand($)).find({ text: /1 upload waiting for a PR on feat\/x/ }), - ).toBeTruthy(); + expect(await (await mountBand($)).find({ text: /1 attachment waiting for a PR/ })).toBeTruthy(); world.cli.sessionBranch = "feat/y"; await runBash($, world, "git switch feat/y"); expect( - await (await mountBand($)).find({ text: /2 uploads waiting for a PR on feat\/y/ }), + await (await mountBand($)).find({ text: /2 attachments waiting for a PR/ }), ).toBeTruthy(); }); @@ -266,10 +299,11 @@ describe("band", () => { expect(await (await mountBand($, "terminal")).find({ type: "Image" })).toBeTruthy(); const desktop = await mountBand($, "desktop"); expect(await desktop.find({ type: "Image" })).toBeUndefined(); - expect(await desktop.find({ type: "Text", text: "⇡" })).toBeTruthy(); + expect(await desktop.find({ type: "Svg" })).toBeTruthy(); + expect(await desktop.find({ type: "Text", text: "uploads" })).toBeTruthy(); }); - for (const surface of ["terminal", "desktop"] as const) { + for (const surface of ["terminal"] as const) { test(`lists staged files with links to their pages on ${surface}`, async ($, on) => { const world = setup(on, { counts: { "feat/x": 2 }, binding: ["self", true] }, ""); await runBash($, world, STAGE); @@ -333,3 +367,233 @@ describe("band", () => { expect(await ui.findAll({ text: /uploads|staged/ })).toEqual([]); }); }); + +const mountPane = ($: Engine, surface: "terminal" | "desktop" = "desktop") => + $.ui.mount({ + plugin: PLUGIN, + surface, + component: "Pane", + requestId: "uploads-staged", + props: { bodyColumns: 120 } as never, + }); + +const BINDING_LINES: [string, [string, boolean], RegExp, boolean][] = [ + ["self", ["self", true], /^\s*1 attachment waiting for a PR$/, false], + ["unknown", ["unknown", false], /^\s*1 attachment waiting for a PR$/, false], + [ + "none", + ["none", false], + /^\s*1 attachment waiting for a PR · link the repo to auto-attach$/, + false, + ], + [ + "other", + ["other", false], + /^\s*1 attachment won't attach: repo linked to another workspace$/, + true, + ], +]; + +describe("band headline", () => { + for (const surface of ["terminal", "desktop"] as const) { + for (const [name, binding, text, warn] of BINDING_LINES) { + test(`reads for a ${name} binding on ${surface}`, async ($, on) => { + const world = setup(on, { binding }, ""); + await runBash($, world, STAGE); + + const found = await (await mountBand($, surface)).find({ type: "Text", text }); + expect(found).toBeTruthy(); + expect(found?.props.color !== undefined).toBe(warn); + }); + } + + test(`says attachments were added to the PR on ${surface}`, async ($, on) => { + const world = setup(on, { counts: { "feat/x": 3 }, promote: "ok" }, ""); + await runBash($, world, STAGE); + world.output.text = PR_OUTPUT; + await runBash($, world, "gh pr create --fill"); + + expect( + await (await mountBand($, surface)).find({ text: /^\s*3 attachments added to PR #1051$/ }), + ).toBeTruthy(); + }); + } +}); + +describe("desktop band", () => { + test("shows thumbnails, the overflow count, View and a dismiss button", async ($, on) => { + const world = setup(on, { + counts: { "feat/x": 6 }, + files: Array.from({ length: 6 }, (_, n) => ({ filename: `s${n}.png` })), + }); + await runBash($, world, STAGE); + + const ui = await mountBand($, "desktop"); + // The mark and four thumbnails. + expect(await ui.findAll({ type: "Svg" })).toHaveLength(5); + expect(await ui.find({ type: "Text", text: "+2" })).toBeTruthy(); + expect(await ui.find({ type: "Button", text: "View" })).toBeTruthy(); + const hide = await ui.find({ type: "Button", text: "Hide" }); + expect(hide?.props.role).toBe("dismiss"); + // The terminal keeps its own band: no thumbnails, no View. + const terminal = await mountBand($, "terminal"); + expect(await terminal.find({ type: "Button", text: "View" })).toBeUndefined(); + }); + + test("hides until the staged set changes", async ($, on) => { + const world = setup(on, { counts: { "feat/x": 1 } }); + await runBash($, world, STAGE); + + const ui = await mountBand($, "desktop"); + await ui.press({ key: "hide" }); + expect( + await (await mountBand($, "desktop")).find({ text: /waiting for a PR/ }), + ).toBeUndefined(); + + // Same set, refreshed: still hidden. + await runBash($, world, STAGE); + expect( + await (await mountBand($, "desktop")).find({ text: /waiting for a PR/ }), + ).toBeUndefined(); + + // A new file changes the set. + world.cli.counts["feat/x"] = 2; + await runBash($, world, STAGE); + expect( + await (await mountBand($, "desktop")).find({ text: /2 attachments waiting for a PR/ }), + ).toBeTruthy(); + }); + + test("View opens the pane", async ($, on) => { + const world = setup(on); + await runBash($, world, STAGE); + + await (await mountBand($, "desktop")).press({ key: "view" }); + + expect(world.opened).toEqual(["uploads-staged:Staged attachments"]); + }); +}); + +describe("staged pane", () => { + const pairFiles = [ + { filename: "home-before.png" }, + { filename: "home-after.png" }, + { filename: "solo.png" }, + { filename: "notes.txt" }, + ]; + + test("draws a grid: pairs as one tile, singles, file cards, footnote", async ($, on) => { + const world = setup(on, { counts: { "feat/x": 4 }, files: pairFiles }); + await runBash($, world, STAGE); + + const ui = await mountPane($); + // Tiles for home (pair), solo and notes.txt. + expect(await ui.findAll({ type: "Svg" })).toHaveLength(3); + expect( + await ui.find({ text: "4 attachments waiting for a PR · link the repo to auto-attach" }), + ).toBeTruthy(); + expect(await ui.find({ text: /buildinternet\/uploads · feat\/x/ })).toBeTruthy(); + expect(await ui.find({ type: "Button", text: "Copy markdown" })).toBeTruthy(); + expect(await ui.find({ text: "5m ago" })).toBeTruthy(); + expect( + await ui.find({ text: "Hosted on uploads.sh, not committed to the repo." }), + ).toBeTruthy(); + // No open PR: no feed button. + expect(await ui.find({ type: "Button", text: /feed link/ })).toBeUndefined(); + // The pair tile carries a chip for each half; the txt tile is a file card. + const drawn = JSON.stringify(await ui.drawn()); + expect(drawn).toContain("Before"); + expect(drawn).toContain("After"); + expect(drawn).toContain("TXT"); + }); + + test("falls back to a file card when a thumbnail fails", async ($, on) => { + const world = setup(on, { thumbsFail: true, files: [{ filename: "a.png" }] }); + await runBash($, world, STAGE); + + const drawn = JSON.stringify(await (await mountPane($)).drawn()); + expect(drawn).toContain("PNG"); + expect(drawn).not.toContain("data:image/jpeg"); + }); + + test("draws thumbnails as JPEG data URIs fetched through the image transform", async ($, on) => { + const world = setup(on, { files: [{ filename: "a.png" }] }); + await runBash($, world, STAGE); + + expect(JSON.stringify(await (await mountPane($)).drawn())).toContain( + "data:image/jpeg;base64,AAAABBBB", + ); + const fetch = world.argvs.find((argv) => argv[0] === "sh"); + expect(fetch?.at(-1)).toContain( + "/cdn-cgi/image/width=360,fit=scale-down,format=jpeg,quality=70/", + ); + }); + + test("on the terminal the grid has no images", async ($, on) => { + const world = setup(on, { counts: { "feat/x": 2 }, files: pairFiles.slice(0, 2) }); + await runBash($, world, STAGE); + + const ui = await mountPane($, "terminal"); + expect(await ui.findAll({ type: "Svg" })).toEqual([]); + expect(await ui.find({ type: "Button", text: "home" })).toBeTruthy(); + }); + + test("copies the PR feed link when the branch has an open PR", async ($, on) => { + const world = setup(on, { openPr: 12 }); + const copied: string[] = []; + on("ui.copy", async (_$, e) => { + copied.push(e.text); + return { value: undefined } as never; + }); + await runBash($, world, STAGE); + + const ui = await mountPane($); + await ui.press({ key: "feed" }); + + expect(world.argvs).toContainEqual(["uploads", "feed", "create", "--repo", REPO, "--pr", "12"]); + expect(copied).toEqual(["https://uploads.sh/feed/abc"]); + expect(world.toasts).toContainEqual("Copied the PR feed link"); + expect(await ui.find({ type: "Link" })).toBeTruthy(); + }); + + test("opens a row in the same pane, then returns to the grid", async ($, on) => { + const world = setup(on, { counts: { "feat/x": 2 }, files: pairFiles.slice(0, 2) }); + await runBash($, world, STAGE); + + const detail = await mountPane($); + await detail.press({ key: "name-0" }); + + expect(await detail.find({ type: "Button", text: "← All attachments" })).toBeTruthy(); + expect(await detail.find({ type: "Button", text: "Copy markdown" })).toBeTruthy(); + expect(await detail.find({ type: "Button", text: "Remove" })).toBeTruthy(); + expect(await detail.find({ text: "Before" })).toBeTruthy(); + expect(await detail.findAll({ type: "Link", text: "Open on uploads.sh" })).toHaveLength(2); + + await detail.press({ key: "back" }); + expect(await detail.find({ text: /Hosted on uploads.sh/ })).toBeTruthy(); + }); + + test("explains a file that can't be previewed", async ($, on) => { + const world = setup(on, { files: [{ filename: "notes.txt" }] }); + await runBash($, world, STAGE); + const detail = await mountPane($); + await detail.press({ key: "name-0" }); + + expect(await detail.find({ text: "notes.txt can't be previewed here." })).toBeTruthy(); + expect(await detail.find({ type: "Link" })).toBeTruthy(); + }); + + test("Remove deletes each file, then shows the grid", async ($, on) => { + const world = setup(on, { counts: { "feat/x": 2 }, files: pairFiles.slice(0, 2) }); + await runBash($, world, STAGE); + const ui = await mountPane($); + await ui.press({ key: "name-0" }); + await ui.press({ key: "remove" }); + + expect(world.argvs.filter((argv) => argv[1] === "delete").map((argv) => argv[2])).toEqual([ + "gh/x/home-before.png", + "gh/x/home-after.png", + ]); + expect(await ui.find({ text: /Hosted on uploads.sh|Nothing staged/ })).toBeTruthy(); + }); +}); diff --git a/plugins/claude/uploads/hooks/register.tsx b/plugins/claude/uploads/hooks/register.tsx index d03b50b9..e267280b 100644 --- a/plugins/claude/uploads/hooks/register.tsx +++ b/plugins/claude/uploads/hooks/register.tsx @@ -6,6 +6,19 @@ import type { Attached, BandRow, Binding, FileRef, Staged } from "../types"; const staged = atom({ plugin: "uploads", key: "staged" } as const, null); const attached = atom({ plugin: "uploads", key: "attached" } as const, null); const isExpanded = atom({ plugin: "uploads", key: "isExpanded" } as const, false); +// Desktop: JPEG data URIs of the staged images (storage URL -> URI; "" = failed). +const thumbs = atom({ plugin: "uploads", key: "thumbs" } as const, {}); +const large = atom({ plugin: "uploads", key: "large" } as const, {}); +// The row the pane's detail view shows; null shows the grid. +const preview = atom({ plugin: "uploads", key: "preview" } as const, null); +// The open PR for the session branch, for the pane's feed link. +const openPr = atom({ plugin: "uploads", key: "openPr" } as const, null); +const feedUrl = atom({ plugin: "uploads", key: "feedUrl" } as const, null); +// The staged set the desktop band is hidden for; a change shows it again. +const dismissed = atom({ plugin: "uploads", key: "dismissed" } as const, null); + +const PANE = "uploads-staged"; +const PANE_TITLE = "Staged attachments"; // Bash commands that can change what is staged for the branch, or which branch it is. const REFRESH_AFTER = @@ -16,13 +29,7 @@ const IDLE_REFRESH_MS = 2 * 60 * 1000; // Long enough for the GitHub App's webhook to promote the files after a PR opens. const AFTER_PR_REFRESH_MS = 15_000; -// What happens to the uploads once the branch's PR exists, by repo link. -const BAND_SUFFIX: Record = { - self: "attaches when it opens", - none: "attaches when gh pr create runs here (or: uploads github link)", - unknown: "couldn't check the repo link; attaches when gh pr create runs here", - other: "won't attach: this repo is linked to another workspace", -}; +const BINDINGS: readonly Binding[] = ["self", "none", "other", "unknown"]; // Before/after pairing, mirroring the attachments comment (comment-render's // pairAttachments): images only; same `path` metadata with one `state=before` @@ -31,7 +38,15 @@ const BAND_SUFFIX: Record = { const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif)$/i; const STEM_TOKEN = /(^|[-_.])(before|after)($|[-_.])/i; -type RawFile = { filename: string; state?: unknown; path?: unknown; url?: unknown; size?: unknown }; +type RawFile = { + key?: unknown; + filename: string; + state?: unknown; + path?: unknown; + url?: unknown; + size?: unknown; + stagedAt?: unknown; +}; type Role = "before" | "after"; /** The uploads.sh file page (preview and details) for a storage URL; else the URL itself. */ @@ -41,9 +56,12 @@ const filePage = (url: string): string => { }; const fileRef = (file: RawFile): FileRef => ({ + key: typeof file.key === "string" ? file.key : file.filename, name: file.filename, url: typeof file.url === "string" ? filePage(file.url) : null, + src: typeof file.url === "string" ? file.url : null, size: typeof file.size === "number" ? file.size : null, + stagedAt: typeof file.stagedAt === "string" ? file.stagedAt : null, }); /** A file's before/after role and the key its other half shares, or null for neither. */ @@ -126,7 +144,7 @@ const toRows = (files: readonly RawFile[]): BandRow[] => { const filesIn = (row: BandRow): number => row.kind === "file" ? 1 : (row.before ? 1 : 0) + (row.after ? 1 : 0); -// The expanded band lists at most this many rows. +// The terminal's expanded band lists at most this many rows. const MAX_ROWS = 8; const formatSize = (bytes: number | null): string => @@ -150,8 +168,8 @@ const parseStaged = (stdout: string): Staged | null => { repo: doc.repo, branch: doc.branch, count: doc.files.length, - rows: toRows(files).slice(0, MAX_ROWS), - binding: state in BAND_SUFFIX ? state : "unknown", + rows: toRows(files), + binding: BINDINGS.includes(state) ? state : "unknown", autoAttach: doc.binding?.autoAttach === true, }; } catch { @@ -232,22 +250,317 @@ const MARK = (() => { return { rgba: toBase64(pixels), width: size, height: size }; })(); -const plural = (n: number) => (n === 1 ? "1 staged file" : `${n} staged files`); +const attachments = (n: number) => (n === 1 ? "1 attachment" : `${n} attachments`); + +type Line = { text: string; warn: boolean }; + +/** What the staged attachments are waiting for, by the repo's link to a workspace. */ +const headline = (view: Staged): Line => { + const n = attachments(view.count); + if (view.binding === "other") { + return { text: `${n} won't attach: repo linked to another workspace`, warn: true }; + } + if (view.binding === "none") { + return { text: `${n} waiting for a PR · link the repo to auto-attach`, warn: false }; + } + return { text: `${n} waiting for a PR`, warn: false }; +}; /** The band's one line for the session branch, or null to show nothing. */ -const bandLine = (view: Staged, done: Attached | null): string | null => { +const bandLine = (view: Staged, done: Attached | null): Line | null => { if (view.count > 0) { - const uploads = view.count === 1 ? "1 upload" : `${view.count} uploads`; - return `${uploads} waiting for a PR on ${view.branch} · ${BAND_SUFFIX[view.binding]}`; + return headline(view); } if (done?.branch !== view.branch || done.repo !== view.repo) { return null; } - return done.via === "app" - ? `${plural(done.count)} sent to PR #${done.pr} via the uploads-sh bot` - : `${plural(done.count)} attached to PR #${done.pr}`; + const n = attachments(done.count); + return { + text: + done.via === "app" + ? `${n} sent to PR #${done.pr} via the uploads-sh bot` + : `${n} added to PR #${done.pr}`, + warn: false, + }; +}; + +// ---- desktop thumbnails and the pane ------------------------------------------ + +const STORAGE = "https://storage.uploads.sh/"; + +/** The files in a row, before first. */ +const filesOf = (row: BandRow): FileRef[] => + row.kind === "file" ? [row.file] : [row.before, row.after].filter((f): f is FileRef => !!f); + +/** Identifies a staged set: the desktop band hides for it and returns when it changes. */ +const setKey = (view: Staged): string => + `${view.repo}#${view.branch}#${(Array.isArray(view.rows) ? view.rows : []) + .flatMap((row) => filesOf(row).map((f) => f.key)) + .join(",")}`; + +// A resized JPEG of a storage image, through Cloudflare's image transform. +const transformed = (src: string, width: number, quality: number): string => + src.startsWith(STORAGE) && !src.toLowerCase().endsWith(".svg") + ? `${STORAGE}cdn-cgi/image/width=${width},fit=scale-down,format=jpeg,quality=${quality}/${src.slice(STORAGE.length)}` + : src; + +const embedUrl = (src: string): string => + src.startsWith(STORAGE) ? `https://embed.uploads.sh/${src.slice(STORAGE.length)}` : src; + +// A JPEG of `src` as a data URI: the desktop draws Svg as an isolated image, so +// it can't load a remote URL. Null when the fetch fails or the result is too big. +async function fetchUri( + $: EngineInterface, + src: string, + width: number, + quality: number, +): Promise { + const b64 = await run( + $, + ["sh", "-c", 'curl -sfL --max-time 15 "$1" | base64', "sh", transformed(src, width, quality)], + 20_000, + ); + const body = b64?.replace(/\s+/g, "") ?? ""; + return body && body.length <= 120_000 ? `data:image/jpeg;base64,${body}` : null; +} + +const imageFiles = (rows: readonly BandRow[]): FileRef[] => + rows.flatMap(filesOf).filter((f) => !!f.src && IMAGE_EXT.test(f.name)); + +// Fetches thumbnails not yet cached; a failure is remembered as "" (drawn as a file card). +async function loadThumbs($: EngineInterface, rows: readonly BandRow[]): Promise { + for (const file of imageFiles(rows)) { + if (file.src! in (await read($, thumbs))) { + continue; + } + const uri = await fetchUri($, file.src!, 360, 70); + await update($, thumbs, (t) => ({ ...t, [file.src!]: uri ?? "" })); + } +} + +async function loadLarge($: EngineInterface, row: BandRow): Promise { + for (const file of imageFiles([row])) { + if (file.src! in (await read($, large))) { + continue; + } + const uri = (await fetchUri($, file.src!, 1100, 72)) ?? (await fetchUri($, file.src!, 760, 60)); + if (uri) { + await update($, large, (t) => ({ ...t, [file.src!]: uri })); + } + } +} + +const esc = (s: string): string => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +// The mark as vector art, for the desktop band: the same chevrons as MARK. +const MARK_SVG = (() => { + const blocks = [ + [7, 2], + [5, 3], + [9, 3], + [3, 4], + [11, 4], + ] as const; + const rows = [ + [0, 1], + [4, 0.55], + [8, 0.28], + ] as const; + const rects = rows + .flatMap(([dy, a]) => + blocks.map( + ([x, y]) => + ``, + ), + ) + .join(""); + return `${rects}`; +})(); + +// `uri`: a data URI to draw; null while loading; "" when there is no inline +// preview (not an image, or the thumbnail failed), drawn as a file card. +type Slot = { uri: string | null; chip: string | null; ext: string }; + +const extOf = (name: string): string => { + const dot = name.lastIndexOf("."); + return dot > 0 + ? name + .slice(dot + 1) + .toUpperCase() + .slice(0, 5) + : "FILE"; +}; + +// A file card: a page glyph with a folded corner and the extension under it. +const fileCard = (x: number, w: number, h: number, ext: string, path: string): string => { + const g = Math.min(h * 0.34, w * 0.3); + const gx = x + w / 2 - g * 0.4; + const gy = h / 2 - g * 0.75; + const f = g * 0.28; + const small = h < 40; + const glyph = small + ? "" + : ``; + const label = `${esc(ext)}`; + return `${glyph}${label}`; +}; + +// Images side by side with a hairline gap, each clipped to a rounded frame, so a +// pair reads as one card. A chip names each half when `chips` is set. +const tileSvg = (slots: readonly Slot[], w: number, h: number, chips: boolean): string => { + const gap = 2; + const total = slots.length * w + (slots.length - 1) * gap; + const r = h > 40 ? 6 : 3; + const body = slots + .map((s, i) => { + const x = i * (w + gap); + const left = i === 0; + const right = i === slots.length - 1; + // Outer corners rounded, inner edges square. + const path = + `M${x + (left ? r : 0)},0 H${x + w - (right ? r : 0)} ` + + (right + ? `A${r},${r} 0 0 1 ${x + w},${r} V${h - r} A${r},${r} 0 0 1 ${x + w - r},${h} ` + : `V${h} `) + + `H${x + (left ? r : 0)} ` + + (left ? `A${r},${r} 0 0 1 ${x},${h - r} V${r} A${r},${r} 0 0 1 ${x + r},0 Z` : `V0 Z`); + const img = + s.uri === "" + ? fileCard(x, w, h, s.ext, path) + : s.uri + ? `` + : ``; + const chip = + chips && s.chip + ? `` + + `${s.chip}` + : ""; + return `${img}${chip}`; + }) + .join(""); + return `${body}`; +}; + +const roleName = (row: BandRow, file: FileRef): "Before" | "After" | null => + row.kind === "pair" ? (file === row.before ? "Before" : "After") : null; + +const slotsOf = (row: BandRow, t: Record): Slot[] => + filesOf(row).map((f) => ({ + uri: !f.src || !IMAGE_EXT.test(f.name) ? "" : (t[f.src] ?? null), + chip: roleName(row, f), + ext: extOf(f.name), + })); + +const labelOf = (row: BandRow): string => (row.kind === "file" ? row.file.name : row.label); + +// Shortens the middle of a name to `max` characters, keeping the extension end. +const fit = (name: string, max: number): string => { + if (name.length <= max) { + return name; + } + const tail = Math.min(8, Math.floor((max - 1) / 2)); + return `${name.slice(0, max - 1 - tail)}…${name.slice(name.length - tail)}`; +}; +// Characters that fit beside the staged time under a tile of this many images. +const captionRoom = (row: BandRow): number => (filesOf(row).length > 1 ? 40 : 17); + +// When the row's newest file was staged, as "5m ago". +const agoOf = (row: BandRow, now: number): string => { + const times = filesOf(row) + .map((f) => (f.stagedAt ? Date.parse(f.stagedAt) : NaN)) + .filter((n) => !Number.isNaN(n)); + if (times.length === 0) { + return ""; + } + const s = Math.max(0, Math.round((now - Math.max(...times)) / 1000)); + if (s < 60) { + return "just now"; + } + if (s < 3600) { + return `${Math.floor(s / 60)}m ago`; + } + return s < 86400 ? `${Math.floor(s / 3600)}h ago` : `${Math.floor(s / 86400)}d ago`; }; +const markdownOf = (rows: readonly BandRow[]): string => + rows + .flatMap((row) => + filesOf(row).map((f) => { + const role = roleName(row, f); + const alt = role ? `${labelOf(row)} (${role.toLowerCase()})` : labelOf(row); + return f.src ? `![${alt}](${embedUrl(f.src)})` : ""; + }), + ) + .filter(Boolean) + .join("\n"); + +const BAND_TILES = 4; +const TILE_W = 168; +const TILE_H = 105; + +async function openPane($: EngineInterface, title = PANE_TITLE): Promise { + await $.ui.open({ id: PANE, title }); +} + +// Shows the pane's grid, fetching the thumbnails it will draw. +async function showGrid($: EngineInterface): Promise { + await update($, preview, () => null); + await openPane($); + const view = await read($, staged); + if (view && Array.isArray(view.rows)) { + void loadThumbs($, view.rows).catch(() => undefined); + } +} + +// The pane swaps its grid for one row, large; "All attachments" returns to the grid. +async function showRow($: EngineInterface, row: BandRow): Promise { + await update($, preview, () => row); + await openPane($, labelOf(row)); + void loadLarge($, row).catch(() => undefined); +} + +async function removeRow($: EngineInterface, row: BandRow): Promise { + for (const file of filesOf(row)) { + await run($, ["uploads", "delete", file.key], 30_000); + } + await refreshBand($); +} + +async function copyText( + $: EngineInterface, + surface: Parameters[0]["surface"], + text: string, + what: string, +): Promise { + await $.ui.copy({ text, surface }); + $.ui.toast(`Copied ${what}`); +} + +// The PR's live feed link: created on first use through the CLI, then kept. +async function copyFeedLink( + $: EngineInterface, + surface: Parameters[0]["surface"], + view: Staged, + pr: { number: number }, +): Promise { + let url = await read($, feedUrl); + if (!url) { + const out = await run( + $, + ["uploads", "feed", "create", "--repo", view.repo, "--pr", String(pr.number)], + 30_000, + ); + url = out?.trim().split("\n")[0] || null; + if (!url) { + $.ui.toast("Couldn't get the PR feed link"); + return; + } + await update($, feedUrl, () => url); + } + await copyText($, surface, url, "the PR feed link"); +} + let lastRefresh = 0; // Runs a command; null when it can't start, times out, or exits non-zero. @@ -279,10 +592,31 @@ async function fetchStaged( return stdout === null ? null : parseStaged(stdout); } +// The open PR for the session directory's branch, for the pane's feed link. +async function fetchOpenPr($: EngineInterface): Promise<{ number: number; url: string } | null> { + const stdout = await run($, ["gh", "pr", "view", "--json", "number,url,state"], 15_000); + try { + const doc = JSON.parse(stdout ?? ""); + return typeof doc?.number === "number" && doc.state === "OPEN" + ? { number: doc.number, url: String(doc.url) } + : null; + } catch { + return null; + } +} + // Refreshes what the band shows: the session directory's branch. async function refreshBand($: EngineInterface): Promise { const view = await fetchStaged($, null); await update($, staged, () => view); + const pr = view && view.count > 0 ? await fetchOpenPr($) : null; + if ((await read($, openPr))?.number !== pr?.number) { + await update($, feedUrl, () => null); + } + await update($, openPr, () => pr); + if (view) { + await loadThumbs($, view.rows.slice(0, BAND_TILES)); + } } // Schedules a band refresh outside the current dispatch, so no tool result waits on it. @@ -320,10 +654,20 @@ export const register: Register = (on, options) => { const autoPromote = options.autoPromote !== false; on("session.start", async ($, e, next) => { + await $.command.register({ + name: "uploads-staged", + description: "Show the attachments staged on uploads.sh for this branch", + }); await kick($); return next(e); }); + on("command.run", { command: "uploads-staged" }, async ($) => { + await refreshBand($); + await showGrid($); + return { text: "Opened the staged attachments pane." }; + }); + on("turn.complete", async ($, e, next) => { if ((await $.clock.now()) - lastRefresh > IDLE_REFRESH_MS) { await kick($); @@ -372,15 +716,15 @@ export const register: Register = (on, options) => { head, ]; const command = promoteArgv.join(" "); - const files = plural(view.count); + const files = attachments(view.count); let outcome: Outcome | null = null; if (view.count === 0) { // The App's webhook already promoted them between the PR opening and this read. if (prior > 0 && view.autoAttach) { outcome = { - toast: `uploads: ${plural(prior)} attached to ${pr} by the uploads-sh bot`, - context: `uploads: the GitHub App attached the ${plural(prior)} for ${head} to ${pr}. Don't re-upload them.`, + toast: `uploads: ${attachments(prior)} added to ${pr} by the uploads-sh bot`, + context: `uploads: the GitHub App added the ${attachments(prior)} for ${head} to ${pr}. Don't re-upload them.`, record: { count: prior, via: "app" }, }; } @@ -405,20 +749,20 @@ export const register: Register = (on, options) => { const result = parsePromotion(await run($, [...promoteArgv, "--json"], 60_000)); if (result === null || result.promoted === 0) { outcome = { - toast: `uploads: couldn't attach ${files} to ${pr}; run uploads attach --promote`, + toast: `uploads: couldn't add ${files} to ${pr}; run uploads attach --promote`, context: `uploads: ${files} for ${head} did not attach to ${pr}. Run \`${command}\`.`, }; } else if (result.skipped > 0) { - const moved = plural(result.promoted); + const moved = attachments(result.promoted); outcome = { - toast: `uploads: attached ${moved} to ${pr}; ${result.skipped} skipped`, - context: `uploads: attached ${moved} for ${head} to ${pr}; ${result.skipped} ${result.skipped === 1 ? "was" : "were"} skipped. Run \`${command}\` to retry them.`, + toast: `uploads: added ${moved} to ${pr}; ${result.skipped} skipped`, + context: `uploads: added ${moved} for ${head} to ${pr}; ${result.skipped} ${result.skipped === 1 ? "was" : "were"} skipped. Run \`${command}\` to retry them.`, record: { count: result.promoted, via: "cli" }, }; } else { outcome = { - toast: `uploads: attached ${plural(result.promoted)} to ${pr}`, - context: `uploads: attached ${plural(result.promoted)} for ${head} to ${pr}'s attachments comment. Don't re-upload them.`, + toast: `uploads: added ${attachments(result.promoted)} to ${pr}`, + context: `uploads: added ${attachments(result.promoted)} for ${head} to ${pr}'s attachments comment. Don't re-upload them.`, record: { count: result.promoted, via: "cli" }, }; // Nothing is left staged for the head; the scheduled refresh confirms it. @@ -452,14 +796,60 @@ export const register: Register = (on, options) => { if (line === null) { return next(e); } - - const { Box, Button, Image, Link, Text } = $.ui.resolve(e); // State saved by an older version of this module has no `rows`. const rows = Array.isArray(view.rows) ? view.rows : []; + + if (e.surface === "desktop") { + const { Box, Button, Svg, Text } = $.ui.resolve(e); + // Hidden until the staged set changes. + if (view.count > 0 && (await read($, dismissed)) === setKey(view)) { + return next(e); + } + const t = await read($, thumbs); + return ( + + + + + uploads + + + {line.text} + + + {view.count > 0 && ( + + {rows.slice(0, BAND_TILES).map((row, i) => ( + + ))} + {rows.length > BAND_TILES && +{rows.length - BAND_TILES}} +