diff --git a/packages/app/src/pages/session/session-share-command.test.ts b/packages/app/src/pages/session/session-share-command.test.ts new file mode 100644 index 00000000..cbc7f4dd --- /dev/null +++ b/packages/app/src/pages/session/session-share-command.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test" +import { shareSessionCommand, unshareSessionCommand } from "./session-share-command" + +const language = { + t: (key: string) => key, +} + +function createClient(options?: { shareUrl?: string; shareReject?: boolean; unshareReject?: boolean }) { + const calls = { + share: [] as { sessionID: string }[], + unshare: [] as { sessionID: string }[], + } + return { + calls, + client: { + async share(input: { sessionID: string }) { + calls.share.push(input) + if (options?.shareReject) throw new Error("share failed") + return { data: { share: { url: options?.shareUrl } } } + }, + async unshare(input: { sessionID: string }) { + calls.unshare.push(input) + if (options?.unshareReject) throw new Error("unshare failed") + return {} + }, + }, + } +} + +describe("shareSessionCommand", () => { + test("copies an existing share url without creating a new share", async () => { + const { client, calls } = createClient({ shareUrl: "https://new.example/session" }) + const copied: string[] = [] + const toasts: unknown[] = [] + + await shareSessionCommand({ + sessionID: "ses_1", + existingUrl: "https://existing.example/session", + client, + language, + write: async (value) => { + copied.push(value) + return true + }, + toast: (toast) => toasts.push(toast), + }) + + expect(calls.share).toEqual([]) + expect(copied).toEqual(["https://existing.example/session"]) + expect(toasts).toContainEqual({ + title: "session.share.copy.copied", + description: "toast.session.share.success.description", + variant: "success", + }) + }) + + test("creates a share and copies the returned url", async () => { + const { client, calls } = createClient({ shareUrl: "https://new.example/session" }) + const copied: string[] = [] + const toasts: unknown[] = [] + + await shareSessionCommand({ + sessionID: "ses_1", + existingUrl: undefined, + client, + language, + write: async (value) => { + copied.push(value) + return true + }, + toast: (toast) => toasts.push(toast), + }) + + expect(calls.share).toEqual([{ sessionID: "ses_1" }]) + expect(copied).toEqual(["https://new.example/session"]) + expect(toasts).toContainEqual({ + title: "toast.session.share.success.title", + description: "toast.session.share.success.description", + variant: "success", + }) + }) + + test("shows a share failure toast when no url is returned", async () => { + const { client } = createClient() + const copied: string[] = [] + const toasts: unknown[] = [] + + await shareSessionCommand({ + sessionID: "ses_1", + existingUrl: undefined, + client, + language, + write: async (value) => { + copied.push(value) + return true + }, + toast: (toast) => toasts.push(toast), + }) + + expect(copied).toEqual([]) + expect(toasts).toContainEqual({ + title: "toast.session.share.failed.title", + description: "toast.session.share.failed.description", + variant: "error", + }) + }) + + test("shows a copy failure toast when clipboard write fails", async () => { + const { client } = createClient({ shareUrl: "https://new.example/session" }) + const toasts: unknown[] = [] + + await shareSessionCommand({ + sessionID: "ses_1", + existingUrl: undefined, + client, + language, + write: async () => false, + toast: (toast) => toasts.push(toast), + }) + + expect(toasts).toContainEqual({ + title: "toast.session.share.copyFailed.title", + variant: "error", + }) + }) +}) + +describe("unshareSessionCommand", () => { + test("unshares the current session and shows success", async () => { + const { client, calls } = createClient() + const toasts: unknown[] = [] + + await unshareSessionCommand({ + sessionID: "ses_1", + client, + language, + toast: (toast) => toasts.push(toast), + }) + + expect(calls.unshare).toEqual([{ sessionID: "ses_1" }]) + expect(toasts).toContainEqual({ + title: "toast.session.unshare.success.title", + description: "toast.session.unshare.success.description", + variant: "success", + }) + }) + + test("shows failure when unshare rejects", async () => { + const { client } = createClient({ unshareReject: true }) + const toasts: unknown[] = [] + + await unshareSessionCommand({ + sessionID: "ses_1", + client, + language, + toast: (toast) => toasts.push(toast), + }) + + expect(toasts).toContainEqual({ + title: "toast.session.unshare.failed.title", + description: "toast.session.unshare.failed.description", + variant: "error", + }) + }) +}) diff --git a/packages/app/src/pages/session/session-share-command.ts b/packages/app/src/pages/session/session-share-command.ts new file mode 100644 index 00000000..2e2ad22b --- /dev/null +++ b/packages/app/src/pages/session/session-share-command.ts @@ -0,0 +1,122 @@ +import { showToast, type ToastOptions } from "@opencode-ai/ui/toast" + +type Language = { + t: (key: string) => string +} + +type SessionShareClient = { + share: (input: { sessionID: string }) => Promise<{ data?: { share?: { url?: string } } }> + unshare: (input: { sessionID: string }) => Promise +} + +type Toast = (options: ToastOptions) => void + +export async function writeTextWithBrowserClipboard(value: string) { + const body = typeof document === "undefined" ? undefined : document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = value + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return true + } + + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return false + return clipboard.writeText(value).then( + () => true, + () => false, + ) +} + +async function copyShareUrl(options: { + url: string + existing: boolean + language: Language + write: (value: string) => Promise + toast: Toast +}) { + const { url, existing, language, write, toast } = options + if (!(await write(url))) { + toast({ + title: language.t("toast.session.share.copyFailed.title"), + variant: "error", + }) + return false + } + + toast({ + title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"), + description: language.t("toast.session.share.success.description"), + variant: "success", + }) + return true +} + +export async function shareSessionCommand(options: { + sessionID: string | undefined + existingUrl: string | undefined + client: SessionShareClient + language: Language + write?: (value: string) => Promise + toast?: Toast +}) { + const { sessionID, existingUrl, client, language } = options + const write = options.write ?? writeTextWithBrowserClipboard + const toast = options.toast ?? showToast + if (!sessionID) return + + if (existingUrl) { + await copyShareUrl({ url: existingUrl, existing: true, language, write, toast }) + return + } + + const url = await client + .share({ sessionID }) + .then((res) => res.data?.share?.url) + .catch(() => undefined) + if (!url) { + toast({ + title: language.t("toast.session.share.failed.title"), + description: language.t("toast.session.share.failed.description"), + variant: "error", + }) + return + } + + await copyShareUrl({ url, existing: false, language, write, toast }) +} + +export async function unshareSessionCommand(options: { + sessionID: string | undefined + client: SessionShareClient + language: Language + toast?: Toast +}) { + const { sessionID, client, language } = options + const toast = options.toast ?? showToast + if (!sessionID) return + + await client + .unshare({ sessionID }) + .then(() => + toast({ + title: language.t("toast.session.unshare.success.title"), + description: language.t("toast.session.unshare.success.description"), + variant: "success", + }), + ) + .catch(() => + toast({ + title: language.t("toast.session.unshare.failed.title"), + description: language.t("toast.session.unshare.failed.description"), + variant: "error", + }), + ) +} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 42b3c400..13f7ce36 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -22,6 +22,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { emitRendererDiagnostic, sessionAbortDiagnosticEvent } from "@/context/renderer-diagnostics" +import { shareSessionCommand, unshareSessionCommand } from "@/pages/session/session-share-command" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -129,92 +130,21 @@ export const useSessionCommands = (actions: SessionCommandContext) => { if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) return permission.isAutoAcceptingDirectory(sdk.directory) } - const write = async (value: string) => { - const body = typeof document === "undefined" ? undefined : document.body - if (body) { - const textarea = document.createElement("textarea") - textarea.value = value - textarea.setAttribute("readonly", "") - textarea.style.position = "fixed" - textarea.style.opacity = "0" - textarea.style.pointerEvents = "none" - body.appendChild(textarea) - textarea.select() - const copied = document.execCommand("copy") - body.removeChild(textarea) - if (copied) return true - } - - const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard - if (!clipboard?.writeText) return false - return clipboard.writeText(value).then( - () => true, - () => false, - ) - } - - const copyShare = async (url: string, existing: boolean) => { - if (!(await write(url))) { - showToast({ - title: language.t("toast.session.share.copyFailed.title"), - variant: "error", - }) - return - } - - showToast({ - title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), - variant: "success", - }) - } - const share = async () => { - const sessionID = params.id - if (!sessionID) return - - const existing = info()?.share?.url - if (existing) { - await copyShare(existing, true) - return - } - - const url = await sdk.client.session - .share({ sessionID }) - .then((res) => res.data?.share?.url) - .catch(() => undefined) - if (!url) { - showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), - variant: "error", - }) - return - } - - await copyShare(url, false) + await shareSessionCommand({ + sessionID: params.id, + existingUrl: info()?.share?.url, + client: sdk.client.session, + language, + }) } const unshare = async () => { - const sessionID = params.id - if (!sessionID) return - - await sdk.client.session - .unshare({ sessionID }) - .then(() => - showToast({ - title: language.t("toast.session.unshare.success.title"), - description: language.t("toast.session.unshare.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: language.t("toast.session.unshare.failed.title"), - description: language.t("toast.session.unshare.failed.description"), - variant: "error", - }), - ) + await unshareSessionCommand({ + sessionID: params.id, + client: sdk.client.session, + language, + }) } const openFile = (source?: "palette" | "keybind" | "slash") => {