diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts index 3f014a568..e9b793e45 100644 --- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts +++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts @@ -1450,6 +1450,7 @@ export class ReviewCanvasEditorPane extends EditorPane { | "onDidChangeDiffLayout" | "setupTooltip" | "notify" + | "confirm" | "ready" | "reportDiagnostic" | "reportSidebar" @@ -1472,6 +1473,7 @@ export class ReviewCanvasEditorPane extends EditorPane { setDiffLayout: (scope, layout) => this.diffViews.layout(scope).set(layout), onDidChangeDiffLayout: (scope, listener) => this.diffViews.layout(scope).onDidChange(listener), notify: (notification) => showReviewNotification(this.notificationService, notification), + confirm: async (request) => (await this.dialogService.confirm({ type: 'warning', ...request })).confirmed, setupTooltip: this.setupTooltip, ready: () => { if (generation !== this.loadGeneration || !this.targetDocument) return; diff --git a/apps/review-desktop/scripts/e2e/journeys/share-link.mjs b/apps/review-desktop/scripts/e2e/journeys/share-link.mjs index 64ec90d1d..f35662338 100644 --- a/apps/review-desktop/scripts/e2e/journeys/share-link.mjs +++ b/apps/review-desktop/scripts/e2e/journeys/share-link.mjs @@ -1,4 +1,4 @@ -/** A board shared, edited and shared again keeps one link, with a revision per version. */ +/** A board shared, edited and shared again keeps one link, with a revision per version, until it is revoked. */ import assert from "node:assert/strict"; import { writeFile } from "node:fs/promises"; import path from "node:path"; @@ -11,6 +11,7 @@ export const name = "share-link"; export const phase = 1; export const options = { + settings: { "window.dialogStyle": "custom" }, async beforeLaunch(ctx) { ctx.service = await startShareService(); await writeFile( @@ -90,6 +91,56 @@ export async function run(ctx) { assert.equal(service.posts.length, 2); assert.equal(await input.inputValue(), u1); ctx.check("reopening an unchanged board uploads nothing"); + + const rows = page.locator("[data-share-revision]"); + + await ctx.until(async () => (await rows.count()) === 2, "two history rows"); + + const [latest, older] = await rows.allTextContents(); + + assert.match( + latest, + new RegExp(` · v${revisions.items[0].version} · .* · Latest$`), + ); + assert.match(older, new RegExp(` · v${revisions.items[1].version} · `)); + assert.doesNotMatch(older, /Latest/); + ctx.check("the popover lists both revisions, the newest marked Latest"); + + const revokeButton = page.getByRole("button", { name: "Revoke link" }); + + const dialog = ctx.page + .getByRole("dialog") + .filter({ hasText: "Revoke this share link?" }); + + const notice = page.getByText( + "Link revoked. Sharing this board again creates a new link.", + ); + + await revokeButton.click(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await dialog.waitFor({ state: "hidden" }); + await input.waitFor(); + assert.equal(service.links.get(reviewId)?.shareId, shareId); + ctx.check("cancelling the revoke keeps the link and the popover"); + + await revokeButton.click(); + await dialog.getByRole("button", { name: "Revoke", exact: true }).click(); + await notice.waitFor(); + assert.equal(service.links.has(reviewId), false); + ctx.check("revoking deletes the link at the service"); + + await page.keyboard.press("Escape"); + await notice.waitFor({ state: "detached" }); + await button.click(); + await ctx.until( + async () => + service.posts.length === 3 && + Boolean(await input.inputValue().catch(() => "")), + "the new link", + ); + assert.notEqual(await input.inputValue(), u1); + assert.notEqual(service.links.get(reviewId).shareId, shareId); + ctx.check("sharing a revoked board again uploads a new link"); } finally { await service.stop(); } diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts index 2c75e63a5..c07355642 100644 --- a/packages/review-protocol/src/contracts.ts +++ b/packages/review-protocol/src/contracts.ts @@ -404,6 +404,12 @@ export interface ReviewCanvasBridge { listener: (show: boolean) => void, ): ReviewDisposable; notify?(notification: ReviewNotification): ReviewDisposable; + /** Asks in the host's modal dialog; resolves true when the primary button is chosen. */ + confirm?(request: { + message: string; + detail?: string; + primaryButton: string; + }): Promise; setupTooltip?( target: HTMLElement, text: string, diff --git a/packages/review/app/src/canvas-query.tsx b/packages/review/app/src/canvas-query.tsx index 88b6aeda1..172964a35 100644 --- a/packages/review/app/src/canvas-query.tsx +++ b/packages/review/app/src/canvas-query.tsx @@ -35,6 +35,7 @@ export function createCanvasQueryClient(): QueryClient { export const canvasQueryKeys = { sharingAccount: () => ["sharing", "account"] as const, shareLink: (board: string) => ["sharing", "link", board] as const, + shareHistory: (shareId: string) => ["sharing", "history", shareId] as const, traceList: ( board: string, version: number | undefined, diff --git a/packages/review/app/src/format.test.ts b/packages/review/app/src/format.test.ts index ed42d8828..0e2fd5223 100644 --- a/packages/review/app/src/format.test.ts +++ b/packages/review/app/src/format.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; -import { formatRelativeTime } from "./format"; +import { formatBytes, formatRelativeTime } from "./format"; const now = Date.parse("2026-10-15T12:00:00Z"); @@ -27,3 +27,9 @@ it("writes hours and days in short for a narrow row", () => { "2d ago", ); }); + +it("writes a size in B, KB or MB", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(2048)).toBe("2 KB"); + expect(formatBytes(1_572_864)).toBe("1.5 MB"); +}); diff --git a/packages/review/app/src/format.ts b/packages/review/app/src/format.ts index 4046d1432..a63e4636c 100644 --- a/packages/review/app/src/format.ts +++ b/packages/review/app/src/format.ts @@ -65,3 +65,13 @@ export function formatDuration(durationMs: number): string { return rest ? `${hours}h ${rest}m` : `${hours}h`; } + +/** A byte count to one decimal: "512 B", "2 KB", "1.5 MB". */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kilobytes = bytes / 1024; + + if (kilobytes < 1024) return `${Number(kilobytes.toFixed(1))} KB`; + + return `${Number((kilobytes / 1024).toFixed(1))} MB`; +} diff --git a/packages/review/app/src/share-control.test.tsx b/packages/review/app/src/share-control.test.tsx index b285cc6c3..aec293ac1 100644 --- a/packages/review/app/src/share-control.test.tsx +++ b/packages/review/app/src/share-control.test.tsx @@ -11,6 +11,16 @@ import { ShareControl, SharingContext } from "./share-control"; let dispose: (() => void) | undefined; +const shareId = "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a"; + +const revision5 = "0b8d6c3e-3a9f-4c1e-8f2a-5d7e9b1c4a60"; + +const revision4 = "1c9e7d4f-4b0a-4d2f-9a3b-6e8f0c2d5b71"; + +const u5 = `https://app.dev.fast/s/capability?rev=${revision5}`; + +const u4 = `https://app.dev.fast/s/capability?rev=${revision4}`; + afterEach(async () => { await act(async () => dispose?.()); document.body.replaceChildren(); @@ -28,12 +38,15 @@ function mount(options: { holdPublish?: Promise; holdVersion?: { version: number; until: Promise }; sender?: string; + confirm?: () => Promise; + historyStatus?: number; }) { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const state = { ...options }; const posts: Array<{ path: string; body: unknown }> = []; const telemetry: string[] = []; let accountReads = 0; + let historyReads = 0; const session = testReviewSession( {}, @@ -44,6 +57,7 @@ function mount(options: { return Response.json({ ok: true }); }, + confirm: options.confirm, }, ); @@ -75,6 +89,43 @@ function mount(options: { }); } + if (path.includes("/history/")) { + historyReads += 1; + + if (state.historyStatus === 401) state.signedIn = false; + + if (state.historyStatus) + return Response.json( + { error: "History is unavailable." }, + { status: state.historyStatus }, + ); + + return Response.json({ + latest: revision5, + items: [ + { + id: revision5, + version: 5, + size: 2048, + createdAt: Date.now(), + url: u5, + }, + { + id: revision4, + version: 4, + size: 1024, + createdAt: Date.now() - 2 * 3_600_000, + url: u4, + }, + ], + }); + } + + if (path.endsWith("/open")) return Response.json({ opened: true }); + + if (path.endsWith("/revoke")) + return Response.json({ shareId, revoked: true }); + if (state.holdPublish) await state.holdPublish; if ( @@ -101,7 +152,7 @@ function mount(options: { ); return Response.json({ - shareId: "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a", + shareId, url: "https://app.dev.fast/s/capability", version: JSON.parse(String(init!.body)).version, }); @@ -163,6 +214,7 @@ function mount(options: { click, settle, accountReads: () => accountReads, + historyReads: () => historyReads, }; } @@ -410,6 +462,152 @@ it("shows a newer version's link when the popover opens on an older version", as expect(container.querySelector("input")?.value).toContain("/s/capability"); }); +it("lists the link's revisions newest first, marks the latest and opens one in the browser", async () => { + const harness = mount({ signedIn: true }); + const { container } = harness; + + await harness.render(5); + await harness.click("Share review"); + await harness.settle(); + expect( + [...container.querySelectorAll("[role=dialog] [data-share-revision]")].map( + (row) => row.textContent, + ), + ).toEqual(["just now · v5 · 2 KB · Latest", "2 hours ago · v4 · 1 KB"]); + await harness.click("2 hours ago · v4 · 1 KB"); + expect(harness.posts.at(-1)).toEqual({ + path: "/reviews-api/sharing/open", + body: { url: u4 }, + }); + expect(container.textContent).not.toContain("Revoke link"); + await harness.click("Share review"); + await harness.render(6); + await harness.click("Share review"); + await harness.settle(); + expect(publishes(harness)).toHaveLength(2); + expect(harness.historyReads()).toBe(2); +}); + +it("revokes the link after the host confirms, and the next opening shares a new one", async () => { + let answer = false; + const confirm = vi.fn<() => Promise>(async () => answer); + const harness = mount({ signedIn: true, confirm }); + const { container } = harness; + + const posts = (suffix: string) => + harness.posts.filter((post) => post.path.endsWith(suffix)); + + await harness.render(5); + await harness.click("Share review"); + await harness.settle(); + await harness.click("Revoke link"); + expect(confirm).toHaveBeenCalledTimes(1); + expect(posts("/revoke")).toHaveLength(0); + answer = true; + await harness.click("Revoke link"); + await harness.settle(); + expect(posts("/revoke").map((post) => post.body)).toEqual([{ shareId }]); + expect(container.textContent).toContain( + "Link revoked. Sharing this board again creates a new link.", + ); + expect(container.querySelector("input")).toBeNull(); + expect(publishes(harness)).toHaveLength(1); + await harness.click("Share review"); + await harness.click("Share review"); + await harness.settle(); + expect(publishes(harness)).toHaveLength(2); + expect(container.querySelector("input")?.value).toContain("/s/capability"); + expect(container.textContent).not.toContain("Link revoked"); +}); + +it("shows a history failure under the link and keeps it usable", async () => { + const harness = mount({ signedIn: true, historyStatus: 500 }); + const { container } = harness; + + await harness.render(5); + await harness.click("Share review"); + await vi.waitFor(async () => { + await harness.settle(); + expect(container.textContent).toContain("Could not load share history."); + }); + expect(container.querySelector("[role=alert]")).toBeNull(); + expect(container.querySelector("input")?.value).toContain("/s/capability"); + const dialog = container.querySelector("[role=dialog]")!; + expect( + [...dialog.children].findIndex((child) => + child.textContent?.includes("Could not load share history."), + ), + ).toBeGreaterThan( + [...dialog.children].findIndex((child) => child.tagName === "INPUT"), + ); + expect( + [...dialog.querySelectorAll("button")].map((button) => button.textContent), + ).toContain("Copy link"); +}); + +it("shows sign-in when the history read finds the login expired", async () => { + const harness = mount({ signedIn: true, historyStatus: 401 }); + const { container } = harness; + + await harness.render(5); + await harness.settle(); + const reads = harness.accountReads(); + await harness.click("Share review"); + await vi.waitFor(async () => { + await harness.settle(); + expect(harness.historyReads()).toBe(1); + expect(harness.accountReads()).toBeGreaterThan(reads); + expect( + [...container.querySelectorAll("[role=dialog] button")].map( + (button) => button.textContent, + ), + ).toContain("Sign in to share"); + }); +}); + +it("opens one confirmation while the first is unanswered", async () => { + let answer: (confirmed: boolean) => void = () => {}; + + const confirm = vi.fn<() => Promise>( + () => new Promise((resolve) => (answer = resolve)), + ); + + const harness = mount({ signedIn: true, confirm }); + + await harness.render(5); + await harness.click("Share review"); + await harness.settle(); + await harness.click("Revoke link"); + await harness.click("Revoke link"); + expect(confirm).toHaveBeenCalledTimes(1); + await act(async () => answer(false)); + await harness.click("Revoke link"); + expect(confirm).toHaveBeenCalledTimes(2); +}); + +it("shares again from the revoked state without closing", async () => { + const harness = mount({ signedIn: true, confirm: async () => true }); + const { container } = harness; + + await harness.render(5); + await harness.click("Share review"); + await harness.settle(); + await harness.click("Revoke link"); + await harness.settle(); + expect(container.textContent).toContain("Link revoked"); + await harness.click("Share again"); + await harness.settle(); + + const [first, second] = publishes(harness).map( + (post) => (post.body as { requestId: string }).requestId, + ); + + expect(second).toBeDefined(); + expect(second).not.toBe(first); + expect(container.querySelector("input")?.value).toContain("/s/capability"); + expect(container.textContent).not.toContain("Link revoked"); +}); + it("polls only while sign-in is pending and stops when the control unmounts", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"], diff --git a/packages/review/app/src/share-control.tsx b/packages/review/app/src/share-control.tsx index dbf6b150e..f19f5d5ca 100644 --- a/packages/review/app/src/share-control.tsx +++ b/packages/review/app/src/share-control.tsx @@ -1,12 +1,15 @@ import { layer, space } from "@canvas/scale.stylex"; -import { Button, IconButton } from "@canvas/ui/button"; +import { Button, IconButton, buttonStyles } from "@canvas/ui/button"; import { useCopy } from "@canvas/ui/copy"; +import { focusStyles } from "@canvas/ui/focus"; import { iconStyles } from "@canvas/ui/icon-styles"; import { ShareIcon } from "@canvas/ui/icons"; +import { listRowStyles } from "@canvas/ui/list-row"; import { Popover } from "@canvas/ui/popover"; import { textStyles } from "@canvas/ui/text"; import { TextField } from "@canvas/ui/text-field"; import { useTooltip } from "@canvas/ui/tooltip"; +import type { ShareRevisions } from "@dev.fast/review-share-protocol"; import { type ReviewApiClient, ReviewApiError, @@ -29,6 +32,7 @@ import { } from "react"; import { canvasQueryKeys } from "./canvas-query"; +import { formatBytes, formatRelativeTime } from "./format"; import { useOptionalReviewSession } from "./host/review-session"; import { topbarActionsMarker } from "./markers.stylex"; import { useSharingAccount, useSignIn } from "./sharing-account"; @@ -63,6 +67,9 @@ const actionError = (error: Error | null) => ? error.message : "Could not complete this action. Please retry."); +// Keeps focus in the popover when the control that had it goes away. +const focusOnMount = (element: HTMLElement | null) => element?.focus(); + /** Whether this review can be shared, so a menu may offer Share. */ export function useCanShare(): boolean { return useContext(SharingContext) !== null; @@ -83,6 +90,7 @@ export function ShareControl({ const [open, setOpen] = useState(false); const [target, setTarget] = useState(); const [copyError, setCopyError] = useState(); + const [revoked, setRevoked] = useState(false); const control = useRef(null); const accountKey = canvasQueryKeys.sharingAccount(); @@ -119,10 +127,14 @@ export function ShareControl({ requestId, }), // An older version's upload can land after a newer one's. - onSuccess: (shared) => + onSuccess: (shared) => { queryClient.setQueryData(linkKey, (cached) => cached && cached.version > shared.version ? cached : shared, - ), + ); + void queryClient.invalidateQueries({ + queryKey: canvasQueryKeys.shareHistory(shared.shareId), + }); + }, onError: (error) => { // The host forgot a stale login; show sign-in and upload again after it. if (error instanceof ReviewApiError && error.status === 401) @@ -130,22 +142,63 @@ export function ShareControl({ }, }); + const openRevision = useMutation({ + mutationFn: (url: string) => + context!.client.post<{ opened: true }>("/sharing/open", { url }), + }); + + const revoke = useMutation({ + mutationFn: (shareId: string) => + context!.client.post("/sharing/revoke", { shareId }), + onSuccess: (_, shareId) => { + queryClient.removeQueries({ queryKey: linkKey }); + queryClient.removeQueries({ + queryKey: canvasQueryKeys.shareHistory(shareId), + }); + setRevoked(true); + }, + }); + + const confirm = session?.bridge.confirm; + const confirming = useRef(false); + + const revokeLink = async (shareId: string) => { + if (!confirm || confirming.current || revoke.isPending) return; + confirming.current = true; + + const confirmed = await confirm({ + message: "Revoke this share link?", + detail: + "Anyone with the link loses access to every version shared behind it.", + primaryButton: "Revoke", + }).finally(() => (confirming.current = false)); + + // The host's dialog can dismiss the popover; bring it back. + setOpen(true); + + if (confirmed) revoke.mutate(shareId); + }; + const upload = (next: ShareTarget) => { login.reset(); publish.mutate(next); }; + const shareAgain = () => { + setTarget({ version: context!.version, requestId: crypto.randomUUID() }); + publish.reset(); + setRevoked(false); + }; + const toggle = () => { if (!context) return; // An upload of this version in flight keeps its request. if (!open && !(publish.isPending && target?.version === context.version)) { - setTarget({ - version: context.version, - requestId: crypto.randomUUID(), - }); - publish.reset(); + shareAgain(); login.reset(); + openRevision.reset(); + revoke.reset(); setCopyError(undefined); } @@ -164,15 +217,41 @@ export function ShareControl({ const current = link && target && link.version >= target.version ? link : undefined; + const historyQuery = useQuery({ + queryKey: canvasQueryKeys.shareHistory(current?.shareId ?? ""), + queryFn: + open && current + ? ({ signal }) => + context!.client.read( + `/sharing/history/${current.shareId}`, + signal, + ) + : skipToken, + }); + + const history = historyQuery.data; + + useEffect(() => { + if ( + historyQuery.error instanceof ReviewApiError && + historyQuery.error.status === 401 + ) + void queryClient.invalidateQueries({ queryKey: accountKey }); + }, [historyQuery.error]); + // Upload once per opening; a failure waits for Retry or a new sign-in. useEffect(() => { - if (open && signedIn && target && publish.isIdle && !current) + if (open && signedIn && target && publish.isIdle && !current && !revoked) upload(target); - }, [open, signedIn, target, publish.isIdle, current]); + }, [open, signedIn, target, publish.isIdle, current, revoked]); if (!context) return null; - const error = copyError ?? actionError(publish.error ?? login.error); + const error = + copyError ?? + actionError( + publish.error ?? login.error ?? openRevision.error ?? revoke.error, + ); const accountError = accountQuery.isError ? "Could not read sign-in status." @@ -218,7 +297,21 @@ export function ShareControl({

)} {signedIn ? ( - current ? ( + revoked ? ( + <> +

+ Link revoked. Sharing this board again creates a new link. +

+ + + ) : current ? ( <> copy(current.url)}> {copied === current.url ? "Copied" : "Copy link"} + {history && history.items.length > 0 && ( +
    + {history.items.map((revision) => ( +
  • + +
  • + ))} +
+ )} + {historyQuery.isError && ( +

+ Could not load share history. +

+ )} + {confirm && ( + + )} ) : error ? (