diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 774ad51cc..910b05ad4 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -14,6 +14,7 @@ import { useLanguage } from "@/context/language" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useGlobalSDK } from "@/context/global-sdk" +import { showToast } from "@opencode-ai/ui/toast" type ServerStatus = { healthy: boolean; version?: string } @@ -40,10 +41,11 @@ interface EditRowProps { } async function checkHealth(url: string, platform: ReturnType): Promise { + const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) const sdk = createOpencodeClient({ baseUrl: url, fetch: platform.fetch, - signal: AbortSignal.timeout(3000), + signal, }) return sdk.global .health() @@ -149,9 +151,18 @@ export function DialogSelectServer() { }) const [defaultUrl, defaultUrlActions] = createResource( async () => { - const url = await platform.getDefaultServerUrl?.() - if (!url) return null - return normalizeServerUrl(url) ?? null + try { + const url = await platform.getDefaultServerUrl?.() + if (!url) return null + return normalizeServerUrl(url) ?? null + } catch (err) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + return null + } }, { initialValue: null }, ) @@ -508,8 +519,16 @@ export function DialogSelectServer() { { - await platform.setDefaultServerUrl?.(i) - defaultUrlActions.mutate(i) + try { + await platform.setDefaultServerUrl?.(i) + defaultUrlActions.mutate(i) + } catch (err) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } }} > @@ -520,8 +539,16 @@ export function DialogSelectServer() { { - await platform.setDefaultServerUrl?.(null) - defaultUrlActions.mutate(null) + try { + await platform.setDefaultServerUrl?.(null) + defaultUrlActions.mutate(null) + } catch (err) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } }} > diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4070b371e..db43b5eaf 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -9,7 +9,7 @@ import { usePlatform } from "@/context/platform" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" -import { base64Decode } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -29,7 +29,7 @@ export function SessionHeader() { const platform = usePlatform() const language = useLanguage() - const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") const project = createMemo(() => { const directory = projectDirectory() if (!directory) return diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index c2c4d268a..79511ef04 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -15,14 +15,16 @@ import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { DialogSelectServer } from "./dialog-select-server" +import { showToast } from "@opencode-ai/ui/toast" type ServerStatus = { healthy: boolean; version?: string } async function checkHealth(url: string, platform: ReturnType): Promise { + const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) const sdk = createOpencodeClient({ baseUrl: url, fetch: platform.fetch, - signal: AbortSignal.timeout(3000), + signal, }) return sdk.global .health() @@ -100,15 +102,21 @@ export function StatusPopover() { const toggleMcp = async (name: string) => { if (store.loading) return setStore("loading", name) - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) + + try { + const status = sync.data.mcp[name] + await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + } catch (err) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } finally { + setStore("loading", null) } - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) - setStore("loading", null) } const lspItems = createMemo(() => sync.data.lsp ?? []) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 02d561fba..78c33baff 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -5,6 +5,8 @@ import { monoFontFamily, useSettings } from "@/context/settings" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/terminal" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" +import { useLanguage } from "@/context/language" +import { showToast } from "@opencode-ai/ui/toast" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY @@ -40,6 +42,7 @@ export const Terminal = (props: TerminalProps) => { const sdk = useSDK() const settings = useSettings() const theme = useTheme() + const language = useLanguage() let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) let ws: WebSocket | undefined @@ -107,173 +110,185 @@ export const Terminal = (props: TerminalProps) => { focusTerminal() } - onMount(async () => { - const mod = await import("ghostty-web") - ghostty = await mod.Ghostty.load() + onMount(() => { + const run = async () => { + const mod = await import("ghostty-web") + ghostty = await mod.Ghostty.load() - const once = { value: false } + const once = { value: false } - const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) - if (window.__OPENCODE__?.serverPassword) { - url.username = "opencode" - url.password = window.__OPENCODE__?.serverPassword - } - const socket = new WebSocket(url) - ws = socket - - const t = new mod.Terminal({ - cursorBlink: true, - cursorStyle: "bar", - fontSize: 14, - fontFamily: monoFontFamily(settings.appearance.font()), - allowTransparency: true, - theme: terminalColors(), - scrollback: 10_000, - ghostty, - }) - term = t - - const copy = () => { - const selection = t.getSelection() - if (!selection) return false - - const body = document.body - if (body) { - const textarea = document.createElement("textarea") - textarea.value = selection - textarea.setAttribute("readonly", "") - textarea.style.position = "fixed" - textarea.style.opacity = "0" - body.appendChild(textarea) - textarea.select() - const copied = document.execCommand("copy") - body.removeChild(textarea) - if (copied) return true + const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) + if (window.__OPENCODE__?.serverPassword) { + url.username = "opencode" + url.password = window.__OPENCODE__?.serverPassword } + const socket = new WebSocket(url) + ws = socket - const clipboard = navigator.clipboard - if (clipboard?.writeText) { - clipboard.writeText(selection).catch(() => {}) - return true - } - - return false - } - - t.attachCustomKeyEventHandler((event) => { - const key = event.key.toLowerCase() - - if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") { - copy() - return true - } - - if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") { - if (!t.hasSelection()) return true - copy() - return true - } - - // allow for ctrl-` to toggle terminal in parent - if (event.ctrlKey && key === "`") { - return true - } - - return false - }) - - fitAddon = new mod.FitAddon() - serializeAddon = new SerializeAddon() - t.loadAddon(serializeAddon) - t.loadAddon(fitAddon) - - t.open(container) - container.addEventListener("pointerdown", handlePointerDown) - - handleTextareaFocus = () => { - t.options.cursorBlink = true - } - handleTextareaBlur = () => { - t.options.cursorBlink = false - } - - t.textarea?.addEventListener("focus", handleTextareaFocus) - t.textarea?.addEventListener("blur", handleTextareaBlur) - - focusTerminal() - - if (local.pty.buffer) { - if (local.pty.rows && local.pty.cols) { - t.resize(local.pty.cols, local.pty.rows) - } - t.write(local.pty.buffer, () => { - if (local.pty.scrollY) { - t.scrollToLine(local.pty.scrollY) - } - fitAddon.fit() + const t = new mod.Terminal({ + cursorBlink: true, + cursorStyle: "bar", + fontSize: 14, + fontFamily: monoFontFamily(settings.appearance.font()), + allowTransparency: true, + theme: terminalColors(), + scrollback: 10_000, + ghostty, }) - } + term = t - fitAddon.observeResize() - handleResize = () => fitAddon.fit() - window.addEventListener("resize", handleResize) - t.onResize(async (size) => { - if (socket.readyState === WebSocket.OPEN) { - await sdk.client.pty + const copy = () => { + const selection = t.getSelection() + if (!selection) return false + + const body = document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = selection + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return true + } + + const clipboard = navigator.clipboard + if (clipboard?.writeText) { + clipboard.writeText(selection).catch(() => {}) + return true + } + + return false + } + + t.attachCustomKeyEventHandler((event) => { + const key = event.key.toLowerCase() + + if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") { + copy() + return true + } + + if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") { + if (!t.hasSelection()) return true + copy() + return true + } + + // allow for ctrl-` to toggle terminal in parent + if (event.ctrlKey && key === "`") { + return true + } + + return false + }) + + fitAddon = new mod.FitAddon() + serializeAddon = new SerializeAddon() + t.loadAddon(serializeAddon) + t.loadAddon(fitAddon) + + t.open(container) + container.addEventListener("pointerdown", handlePointerDown) + + handleTextareaFocus = () => { + t.options.cursorBlink = true + } + handleTextareaBlur = () => { + t.options.cursorBlink = false + } + + t.textarea?.addEventListener("focus", handleTextareaFocus) + t.textarea?.addEventListener("blur", handleTextareaBlur) + + focusTerminal() + + if (local.pty.buffer) { + if (local.pty.rows && local.pty.cols) { + t.resize(local.pty.cols, local.pty.rows) + } + t.write(local.pty.buffer, () => { + if (local.pty.scrollY) { + t.scrollToLine(local.pty.scrollY) + } + fitAddon.fit() + }) + } + + fitAddon.observeResize() + handleResize = () => fitAddon.fit() + window.addEventListener("resize", handleResize) + t.onResize(async (size) => { + if (socket.readyState === WebSocket.OPEN) { + await sdk.client.pty + .update({ + ptyID: local.pty.id, + size: { + cols: size.cols, + rows: size.rows, + }, + }) + .catch(() => {}) + } + }) + t.onData((data) => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(data) + } + }) + t.onKey((key) => { + if (key.key == "Enter") { + props.onSubmit?.() + } + }) + // t.onScroll((ydisp) => { + // console.log("Scroll position:", ydisp) + // }) + socket.addEventListener("open", () => { + local.onConnect?.() + sdk.client.pty .update({ ptyID: local.pty.id, size: { - cols: size.cols, - rows: size.rows, + cols: t.cols, + rows: t.rows, }, }) .catch(() => {}) - } - }) - t.onData((data) => { - if (socket.readyState === WebSocket.OPEN) { - socket.send(data) - } - }) - t.onKey((key) => { - if (key.key == "Enter") { - props.onSubmit?.() - } - }) - // t.onScroll((ydisp) => { - // console.log("Scroll position:", ydisp) - // }) - socket.addEventListener("open", () => { - local.onConnect?.() - sdk.client.pty - .update({ - ptyID: local.pty.id, - size: { - cols: t.cols, - rows: t.rows, - }, - }) - .catch(() => {}) - }) - socket.addEventListener("message", (event) => { - t.write(event.data) - }) - socket.addEventListener("error", (error) => { - if (disposed) return - if (once.value) return - once.value = true - console.error("WebSocket error:", error) - local.onConnectError?.(error) - }) - socket.addEventListener("close", (event) => { - if (disposed) return - // Normal closure (code 1000) means PTY process exited - server event handles cleanup - // For other codes (network issues, server restart), trigger error handler - if (event.code !== 1000) { + }) + socket.addEventListener("message", (event) => { + t.write(event.data) + }) + socket.addEventListener("error", (error) => { + if (disposed) return if (once.value) return once.value = true - local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`)) - } + console.error("WebSocket error:", error) + local.onConnectError?.(error) + }) + socket.addEventListener("close", (event) => { + if (disposed) return + // Normal closure (code 1000) means PTY process exited - server event handles cleanup + // For other codes (network issues, server restart), trigger error handler + if (event.code !== 1000) { + if (once.value) return + once.value = true + local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`)) + } + }) + } + + void run().catch((err) => { + if (disposed) return + showToast({ + variant: "error", + title: language.t("terminal.connectionLost.title"), + description: err instanceof Error ? err.message : language.t("terminal.connectionLost.description"), + }) + local.onConnectError?.(err) }) }) @@ -288,7 +303,13 @@ export const Terminal = (props: TerminalProps) => { const t = term if (serializeAddon && props.onCleanup && t) { - const buffer = serializeAddon.serialize() + const buffer = (() => { + try { + return serializeAddon.serialize() + } catch { + return "" + } + })() props.onCleanup({ ...local.pty, buffer, diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 15a920584..c38ed8982 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -23,7 +23,7 @@ import { createStore, produce, reconcile, type SetStoreFunction, type Store } fr import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { useGlobalSDK } from "./global-sdk" -import { ErrorPage, type InitError } from "../pages/error" +import type { InitError } from "../pages/error" import { batch, createContext, @@ -823,11 +823,16 @@ function createGlobalSync() { .then((x) => x.data) .catch(() => undefined) if (!health?.healthy) { - setGlobalStore("error", new Error(language.t("error.globalSync.connectFailed", { url: globalSDK.url }))) + showToast({ + variant: "error", + title: language.t("dialog.server.add.error"), + description: language.t("error.globalSync.connectFailed", { url: globalSDK.url }), + }) + setGlobalStore("ready", true) return } - return Promise.all([ + const tasks = [ retry(() => globalSDK.client.path.get().then((x) => { setGlobalStore("path", x.data!) @@ -858,9 +863,22 @@ function createGlobalSync() { setGlobalStore("provider_auth", x.data ?? {}) }), ), - ]) - .then(() => setGlobalStore("ready", true)) - .catch((e) => setGlobalStore("error", e)) + ] + + const results = await Promise.allSettled(tasks) + const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) + + if (errors.length) { + const message = errors[0] instanceof Error ? errors[0].message : String(errors[0]) + const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : "" + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: message + more, + }) + } + + setGlobalStore("ready", true) } onMount(() => { @@ -926,9 +944,6 @@ export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() return ( - - - {props.children} diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 5e35f6ac0..6c110cae1 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -8,7 +8,8 @@ import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { Binary } from "@opencode-ai/util/binary" -import { base64Decode, base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSound, soundSrc } from "@/utils/sound" @@ -55,8 +56,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const empty: Notification[] = [] const currentDirectory = createMemo(() => { - if (!params.dir) return - return base64Decode(params.dir) + return decode64(params.dir) }) const currentSession = createMemo(() => params.id) diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 52878ba8f..d85f2ef24 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -6,7 +6,8 @@ import { Persist, persisted } from "@/utils/persist" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" import { useParams } from "@solidjs/router" -import { base64Decode, base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" type PermissionRespondFn = (input: { sessionID: string @@ -53,7 +54,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const globalSync = useGlobalSync() const permissionsEnabled = createMemo(() => { - const directory = params.dir ? base64Decode(params.dir) : undefined + const directory = decode64(params.dir) if (!directory) return false const [store] = globalSync.child(directory) return hasAutoAcceptPermissionConfig(store.config.permission) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 4a3f3c6d1..c307f6e72 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -95,10 +95,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const isReady = createMemo(() => ready() && !!state.active) const check = (url: string) => { + const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) const sdk = createOpencodeClient({ baseUrl: url, fetch: platform.fetch, - signal: AbortSignal.timeout(3000), + signal, }) return sdk.global .health() diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 4a73fa055..55184aa1b 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -1,5 +1,5 @@ import { useGlobalSync } from "@/context/global-sync" -import { base64Decode } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" @@ -8,7 +8,7 @@ export const popularProviders = ["opencode", "anthropic", "github-copilot", "ope export function useProviders() { const globalSync = useGlobalSync() const params = useParams() - const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const currentDirectory = createMemo(() => decode64(params.dir) ?? "") const providers = createMemo(() => { if (currentDirectory()) { const [projectStore] = globalSync.child(currentDirectory()) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index caad6c996..037b08c72 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,22 +1,36 @@ -import { createMemo, Show, type ParentProps } from "solid-js" +import { createEffect, createMemo, Show, type ParentProps } from "solid-js" import { useNavigate, useParams } from "@solidjs/router" import { SDKProvider, useSDK } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" -import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" import type { QuestionAnswer } from "@opencode-ai/sdk/v2" +import { decode64 } from "@/utils/base64" +import { showToast } from "@opencode-ai/ui/toast" +import { useLanguage } from "@/context/language" export default function Layout(props: ParentProps) { const params = useParams() const navigate = useNavigate() + const language = useLanguage() const directory = createMemo(() => { - return base64Decode(params.dir!) + return decode64(params.dir) ?? "" + }) + + createEffect(() => { + if (!params.dir) return + if (directory()) return + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: "Invalid directory in URL.", + }) + navigate("/") }) return ( - + {iife(() => { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 283144872..ae86dbb7e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -19,7 +19,8 @@ import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" -import { base64Decode, base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" @@ -420,7 +421,7 @@ export default function Layout(props: ParentProps) { } } - const currentDir = params.dir ? base64Decode(params.dir) : undefined + const currentDir = decode64(params.dir) const currentSession = params.id if (directory === currentDir && props.sessionID === currentSession) return if (directory === currentDir && session?.parentID === currentSession) return @@ -449,7 +450,7 @@ export default function Layout(props: ParentProps) { onCleanup(unsub) createEffect(() => { - const currentDir = params.dir ? base64Decode(params.dir) : undefined + const currentDir = decode64(params.dir) const currentSession = params.id if (!currentDir || !currentSession) return const sessionKey = `${currentDir}:${currentSession}` @@ -503,7 +504,7 @@ export default function Layout(props: ParentProps) { } const currentProject = createMemo(() => { - const directory = params.dir ? base64Decode(params.dir) : undefined + const directory = decode64(params.dir) if (!directory) return const projects = layout.projects.list() @@ -638,7 +639,7 @@ export default function Layout(props: ParentProps) { const compare = sortSessions(Date.now()) if (workspaceSetting()) { const dirs = workspaceIds(project) - const activeDir = params.dir ? base64Decode(params.dir) : "" + const activeDir = decode64(params.dir) ?? "" const result: Session[] = [] for (const dir of dirs) { const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree @@ -1188,7 +1189,7 @@ export default function Layout(props: ParentProps) { layout.projects.close(directory) layout.projects.open(root) - if (params.dir && base64Decode(params.dir) === directory) { + if (params.dir && decode64(params.dir) === directory) { navigateToProject(root) } } @@ -1431,7 +1432,8 @@ export default function Layout(props: ParentProps) { const dir = value.dir const id = value.id if (!dir || !id) return - const directory = base64Decode(dir) + const directory = decode64(dir) + if (!directory) return setStore("lastSession", directory, id) notification.session.markViewed(id) const expanded = untrack(() => store.workspaceExpanded[directory]) @@ -1454,7 +1456,7 @@ export default function Layout(props: ParentProps) { if (!project) return if (workspaceSetting()) { - const activeDir = params.dir ? base64Decode(params.dir) : "" + const activeDir = decode64(params.dir) ?? "" const dirs = [project.worktree, ...(project.sandboxes ?? [])] for (const directory of dirs) { const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree @@ -1504,7 +1506,7 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined + const directory = active?.worktree === project.worktree ? decode64(params.dir) : undefined const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false @@ -1930,7 +1932,7 @@ export default function Layout(props: ParentProps) { }) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => { - const current = params.dir ? base64Decode(params.dir) : "" + const current = decode64(params.dir) ?? "" return current === props.directory }) const workspaceValue = createMemo(() => { @@ -2131,7 +2133,7 @@ export default function Layout(props: ParentProps) { const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) const selected = createMemo(() => { - const current = params.dir ? base64Decode(params.dir) : "" + const current = decode64(params.dir) ?? "" return props.project.worktree === current || props.project.sandboxes?.includes(current) }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 39f5b057e..d316efef7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -28,7 +28,7 @@ import { useSync } from "@/context/sync" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" import { Terminal } from "@/components/terminal" -import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode" +import { checksum, base64Encode } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" @@ -47,6 +47,7 @@ import { useComments, type LineComment } from "@/context/comments" import { extractPromptFromParts } from "@/utils/prompt" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { usePermission } from "@/context/permission" +import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { SessionHeader, @@ -2126,8 +2127,28 @@ export default function Page() { if (!isSvg()) return const c = state()?.content if (!c) return - if (c.encoding === "base64") return base64Decode(c.content) - return c.content + if (c.encoding !== "base64") return c.content + return decode64(c.content) + }) + + const svgDecodeFailed = createMemo(() => { + if (!isSvg()) return false + const c = state()?.content + if (!c) return false + if (c.encoding !== "base64") return false + return svgContent() === undefined + }) + + const svgToast = { shown: false } + createEffect(() => { + if (!svgDecodeFailed()) return + if (svgToast.shown) return + svgToast.shown = true + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + description: "Invalid base64 content.", + }) }) const svgPreviewUrl = createMemo(() => { if (!isSvg()) return diff --git a/packages/app/src/utils/base64.ts b/packages/app/src/utils/base64.ts new file mode 100644 index 000000000..c1f9d88c6 --- /dev/null +++ b/packages/app/src/utils/base64.ts @@ -0,0 +1,10 @@ +import { base64Decode } from "@opencode-ai/util/encode" + +export function decode64(value: string | undefined) { + if (value === undefined) return + try { + return base64Decode(value) + } catch { + return + } +} diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 70884977c..129695f86 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -151,7 +151,14 @@ function localStorageWithPrefix(prefix: string): SyncStorage { const cached = cache.get(name) if (fallback.disabled && cached !== undefined) return cached - const stored = localStorage.getItem(name) + const stored = (() => { + try { + return localStorage.getItem(name) + } catch { + fallback.disabled = true + return null + } + })() if (stored === null) return cached ?? null cache.set(name, stored) return stored @@ -172,7 +179,11 @@ function localStorageWithPrefix(prefix: string): SyncStorage { const name = item(key) cache.delete(name) if (fallback.disabled) return - localStorage.removeItem(name) + try { + localStorage.removeItem(name) + } catch { + fallback.disabled = true + } }, } } @@ -183,7 +194,14 @@ function localStorageDirect(): SyncStorage { const cached = cache.get(key) if (fallback.disabled && cached !== undefined) return cached - const stored = localStorage.getItem(key) + const stored = (() => { + try { + return localStorage.getItem(key) + } catch { + fallback.disabled = true + return null + } + })() if (stored === null) return cached ?? null cache.set(key, stored) return stored @@ -202,7 +220,11 @@ function localStorageDirect(): SyncStorage { removeItem: (key) => { cache.delete(key) if (fallback.disabled) return - localStorage.removeItem(key) + try { + localStorage.removeItem(key) + } catch { + fallback.disabled = true + } }, } }