summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/components/dialog-select-server.tsx43
-rw-r--r--packages/app/src/components/session/session-header.tsx4
-rw-r--r--packages/app/src/components/status-popover.tsx26
-rw-r--r--packages/app/src/components/terminal.tsx303
-rw-r--r--packages/app/src/context/global-sync.tsx33
-rw-r--r--packages/app/src/context/notification.tsx6
-rw-r--r--packages/app/src/context/permission.tsx5
-rw-r--r--packages/app/src/context/server.tsx3
-rw-r--r--packages/app/src/hooks/use-providers.ts4
-rw-r--r--packages/app/src/pages/directory-layout.tsx22
-rw-r--r--packages/app/src/pages/layout.tsx24
-rw-r--r--packages/app/src/pages/session.tsx27
-rw-r--r--packages/app/src/utils/base64.ts10
-rw-r--r--packages/app/src/utils/persist.ts30
14 files changed, 341 insertions, 199 deletions
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<typeof usePlatform>): Promise<ServerStatus> {
+ 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() {
<Show when={canDefault() && defaultUrl() !== i}>
<DropdownMenu.Item
onSelect={async () => {
- 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),
+ })
+ }
}}
>
<DropdownMenu.ItemLabel>
@@ -520,8 +539,16 @@ export function DialogSelectServer() {
<Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.Item
onSelect={async () => {
- 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),
+ })
+ }
}}
>
<DropdownMenu.ItemLabel>
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<typeof usePlatform>): Promise<ServerStatus> {
+ 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 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 clipboard = navigator.clipboard
- if (clipboard?.writeText) {
- clipboard.writeText(selection).catch(() => {})
- return true
- }
+ const clipboard = navigator.clipboard
+ if (clipboard?.writeText) {
+ clipboard.writeText(selection).catch(() => {})
+ return true
+ }
- return false
- }
+ return false
+ }
- t.attachCustomKeyEventHandler((event) => {
- const key = event.key.toLowerCase()
+ t.attachCustomKeyEventHandler((event) => {
+ const key = event.key.toLowerCase()
- if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
- copy()
- return true
- }
+ 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
- }
+ 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
- }
+ // allow for ctrl-` to toggle terminal in parent
+ if (event.ctrlKey && key === "`") {
+ return true
+ }
- return false
- })
+ return false
+ })
- fitAddon = new mod.FitAddon()
- serializeAddon = new SerializeAddon()
- t.loadAddon(serializeAddon)
- t.loadAddon(fitAddon)
+ fitAddon = new mod.FitAddon()
+ serializeAddon = new SerializeAddon()
+ t.loadAddon(serializeAddon)
+ t.loadAddon(fitAddon)
- t.open(container)
- container.addEventListener("pointerdown", handlePointerDown)
+ t.open(container)
+ container.addEventListener("pointerdown", handlePointerDown)
- handleTextareaFocus = () => {
- t.options.cursorBlink = true
- }
- handleTextareaBlur = () => {
- t.options.cursorBlink = false
- }
+ handleTextareaFocus = () => {
+ t.options.cursorBlink = true
+ }
+ handleTextareaBlur = () => {
+ t.options.cursorBlink = false
+ }
- t.textarea?.addEventListener("focus", handleTextareaFocus)
- t.textarea?.addEventListener("blur", handleTextareaBlur)
+ t.textarea?.addEventListener("focus", handleTextareaFocus)
+ t.textarea?.addEventListener("blur", handleTextareaBlur)
- focusTerminal()
+ focusTerminal()
- if (local.pty.buffer) {
- if (local.pty.rows && local.pty.cols) {
- t.resize(local.pty.cols, local.pty.rows)
+ 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()
+ })
}
- t.write(local.pty.buffer, () => {
- if (local.pty.scrollY) {
- t.scrollToLine(local.pty.scrollY)
+
+ 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(() => {})
}
- fitAddon.fit()
})
- }
-
- fitAddon.observeResize()
- handleResize = () => fitAddon.fit()
- window.addEventListener("resize", handleResize)
- t.onResize(async (size) => {
- if (socket.readyState === WebSocket.OPEN) {
- await sdk.client.pty
+ 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 (
<Switch>
- <Match when={value.error}>
- <ErrorPage error={value.error} />
- </Match>
<Match when={value.ready}>
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
</Match>
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 (
- <Show when={params.dir}>
+ <Show when={directory()}>
<SDKProvider directory={directory()}>
<SyncProvider>
{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
+ }
},
}
}