fix(app): non-fatal error handling
This commit is contained in:
@@ -14,6 +14,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
|
||||||
type ServerStatus = { healthy: boolean; version?: string }
|
type ServerStatus = { healthy: boolean; version?: string }
|
||||||
|
|
||||||
@@ -40,10 +41,11 @@ interface EditRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
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({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: url,
|
baseUrl: url,
|
||||||
fetch: platform.fetch,
|
fetch: platform.fetch,
|
||||||
signal: AbortSignal.timeout(3000),
|
signal,
|
||||||
})
|
})
|
||||||
return sdk.global
|
return sdk.global
|
||||||
.health()
|
.health()
|
||||||
@@ -149,9 +151,18 @@ export function DialogSelectServer() {
|
|||||||
})
|
})
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
const [defaultUrl, defaultUrlActions] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
const url = await platform.getDefaultServerUrl?.()
|
try {
|
||||||
if (!url) return null
|
const url = await platform.getDefaultServerUrl?.()
|
||||||
return normalizeServerUrl(url) ?? null
|
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 },
|
{ initialValue: null },
|
||||||
)
|
)
|
||||||
@@ -508,8 +519,16 @@ export function DialogSelectServer() {
|
|||||||
<Show when={canDefault() && defaultUrl() !== i}>
|
<Show when={canDefault() && defaultUrl() !== i}>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={async () => {
|
||||||
await platform.setDefaultServerUrl?.(i)
|
try {
|
||||||
defaultUrlActions.mutate(i)
|
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>
|
<DropdownMenu.ItemLabel>
|
||||||
@@ -520,8 +539,16 @@ export function DialogSelectServer() {
|
|||||||
<Show when={canDefault() && defaultUrl() === i}>
|
<Show when={canDefault() && defaultUrl() === i}>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={async () => {
|
||||||
await platform.setDefaultServerUrl?.(null)
|
try {
|
||||||
defaultUrlActions.mutate(null)
|
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>
|
<DropdownMenu.ItemLabel>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
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 { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -29,7 +29,7 @@ export function SessionHeader() {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
const directory = projectDirectory()
|
const directory = projectDirectory()
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { DialogSelectServer } from "./dialog-select-server"
|
import { DialogSelectServer } from "./dialog-select-server"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
|
||||||
type ServerStatus = { healthy: boolean; version?: string }
|
type ServerStatus = { healthy: boolean; version?: string }
|
||||||
|
|
||||||
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
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({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: url,
|
baseUrl: url,
|
||||||
fetch: platform.fetch,
|
fetch: platform.fetch,
|
||||||
signal: AbortSignal.timeout(3000),
|
signal,
|
||||||
})
|
})
|
||||||
return sdk.global
|
return sdk.global
|
||||||
.health()
|
.health()
|
||||||
@@ -100,15 +102,21 @@ export function StatusPopover() {
|
|||||||
const toggleMcp = async (name: string) => {
|
const toggleMcp = async (name: string) => {
|
||||||
if (store.loading) return
|
if (store.loading) return
|
||||||
setStore("loading", name)
|
setStore("loading", name)
|
||||||
const status = sync.data.mcp[name]
|
|
||||||
if (status?.status === "connected") {
|
try {
|
||||||
await sdk.client.mcp.disconnect({ name })
|
const status = sync.data.mcp[name]
|
||||||
} else {
|
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||||
await 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 ?? [])
|
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { monoFontFamily, useSettings } from "@/context/settings"
|
|||||||
import { SerializeAddon } from "@/addons/serialize"
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
import { LocalPTY } from "@/context/terminal"
|
import { LocalPTY } from "@/context/terminal"
|
||||||
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
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"> {
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
pty: LocalPTY
|
pty: LocalPTY
|
||||||
@@ -40,6 +42,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const language = useLanguage()
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
||||||
let ws: WebSocket | undefined
|
let ws: WebSocket | undefined
|
||||||
@@ -107,173 +110,185 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
focusTerminal()
|
focusTerminal()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
const mod = await import("ghostty-web")
|
const run = async () => {
|
||||||
ghostty = await mod.Ghostty.load()
|
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)}`)
|
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||||
if (window.__OPENCODE__?.serverPassword) {
|
if (window.__OPENCODE__?.serverPassword) {
|
||||||
url.username = "opencode"
|
url.username = "opencode"
|
||||||
url.password = window.__OPENCODE__?.serverPassword
|
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 socket = new WebSocket(url)
|
||||||
|
ws = socket
|
||||||
|
|
||||||
const clipboard = navigator.clipboard
|
const t = new mod.Terminal({
|
||||||
if (clipboard?.writeText) {
|
cursorBlink: true,
|
||||||
clipboard.writeText(selection).catch(() => {})
|
cursorStyle: "bar",
|
||||||
return true
|
fontSize: 14,
|
||||||
}
|
fontFamily: monoFontFamily(settings.appearance.font()),
|
||||||
|
allowTransparency: true,
|
||||||
return false
|
theme: terminalColors(),
|
||||||
}
|
scrollback: 10_000,
|
||||||
|
ghostty,
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
}
|
term = t
|
||||||
|
|
||||||
fitAddon.observeResize()
|
const copy = () => {
|
||||||
handleResize = () => fitAddon.fit()
|
const selection = t.getSelection()
|
||||||
window.addEventListener("resize", handleResize)
|
if (!selection) return false
|
||||||
t.onResize(async (size) => {
|
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
const body = document.body
|
||||||
await sdk.client.pty
|
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({
|
.update({
|
||||||
ptyID: local.pty.id,
|
ptyID: local.pty.id,
|
||||||
size: {
|
size: {
|
||||||
cols: size.cols,
|
cols: t.cols,
|
||||||
rows: size.rows,
|
rows: t.rows,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
})
|
||||||
})
|
socket.addEventListener("message", (event) => {
|
||||||
t.onData((data) => {
|
t.write(event.data)
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
})
|
||||||
socket.send(data)
|
socket.addEventListener("error", (error) => {
|
||||||
}
|
if (disposed) return
|
||||||
})
|
|
||||||
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) {
|
|
||||||
if (once.value) return
|
if (once.value) return
|
||||||
once.value = true
|
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
|
const t = term
|
||||||
if (serializeAddon && props.onCleanup && t) {
|
if (serializeAddon && props.onCleanup && t) {
|
||||||
const buffer = serializeAddon.serialize()
|
const buffer = (() => {
|
||||||
|
try {
|
||||||
|
return serializeAddon.serialize()
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
})()
|
||||||
props.onCleanup({
|
props.onCleanup({
|
||||||
...local.pty,
|
...local.pty,
|
||||||
buffer,
|
buffer,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { createStore, produce, reconcile, type SetStoreFunction, type Store } fr
|
|||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { ErrorPage, type InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import {
|
import {
|
||||||
batch,
|
batch,
|
||||||
createContext,
|
createContext,
|
||||||
@@ -823,11 +823,16 @@ function createGlobalSync() {
|
|||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!health?.healthy) {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([
|
const tasks = [
|
||||||
retry(() =>
|
retry(() =>
|
||||||
globalSDK.client.path.get().then((x) => {
|
globalSDK.client.path.get().then((x) => {
|
||||||
setGlobalStore("path", x.data!)
|
setGlobalStore("path", x.data!)
|
||||||
@@ -858,9 +863,22 @@ function createGlobalSync() {
|
|||||||
setGlobalStore("provider_auth", x.data ?? {})
|
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(() => {
|
onMount(() => {
|
||||||
@@ -926,9 +944,6 @@ export function GlobalSyncProvider(props: ParentProps) {
|
|||||||
const value = createGlobalSync()
|
const value = createGlobalSync()
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={value.error}>
|
|
||||||
<ErrorPage error={value.error} />
|
|
||||||
</Match>
|
|
||||||
<Match when={value.ready}>
|
<Match when={value.ready}>
|
||||||
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
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 { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { playSound, soundSrc } from "@/utils/sound"
|
import { playSound, soundSrc } from "@/utils/sound"
|
||||||
@@ -55,8 +56,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
const empty: Notification[] = []
|
const empty: Notification[] = []
|
||||||
|
|
||||||
const currentDirectory = createMemo(() => {
|
const currentDirectory = createMemo(() => {
|
||||||
if (!params.dir) return
|
return decode64(params.dir)
|
||||||
return base64Decode(params.dir)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentSession = createMemo(() => params.id)
|
const currentSession = createMemo(() => params.id)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useParams } from "@solidjs/router"
|
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: {
|
type PermissionRespondFn = (input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -53,7 +54,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
|
|
||||||
const permissionsEnabled = createMemo(() => {
|
const permissionsEnabled = createMemo(() => {
|
||||||
const directory = params.dir ? base64Decode(params.dir) : undefined
|
const directory = decode64(params.dir)
|
||||||
if (!directory) return false
|
if (!directory) return false
|
||||||
const [store] = globalSync.child(directory)
|
const [store] = globalSync.child(directory)
|
||||||
return hasAutoAcceptPermissionConfig(store.config.permission)
|
return hasAutoAcceptPermissionConfig(store.config.permission)
|
||||||
|
|||||||
@@ -95,10 +95,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
const isReady = createMemo(() => ready() && !!state.active)
|
const isReady = createMemo(() => ready() && !!state.active)
|
||||||
|
|
||||||
const check = (url: string) => {
|
const check = (url: string) => {
|
||||||
|
const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: url,
|
baseUrl: url,
|
||||||
fetch: platform.fetch,
|
fetch: platform.fetch,
|
||||||
signal: AbortSignal.timeout(3000),
|
signal,
|
||||||
})
|
})
|
||||||
return sdk.global
|
return sdk.global
|
||||||
.health()
|
.health()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ export const popularProviders = ["opencode", "anthropic", "github-copilot", "ope
|
|||||||
export function useProviders() {
|
export function useProviders() {
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const providers = createMemo(() => {
|
const providers = createMemo(() => {
|
||||||
if (currentDirectory()) {
|
if (currentDirectory()) {
|
||||||
const [projectStore] = globalSync.child(currentDirectory())
|
const [projectStore] = globalSync.child(currentDirectory())
|
||||||
|
|||||||
@@ -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 { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@/context/sync"
|
import { SyncProvider, useSync } from "@/context/sync"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
|
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import { DataProvider } from "@opencode-ai/ui/context"
|
import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
|
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) {
|
export default function Layout(props: ParentProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const language = useLanguage()
|
||||||
const directory = createMemo(() => {
|
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 (
|
return (
|
||||||
<Show when={params.dir}>
|
<Show when={directory()}>
|
||||||
<SDKProvider directory={directory()}>
|
<SDKProvider directory={directory()}>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
{iife(() => {
|
{iife(() => {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { A, useNavigate, useParams } from "@solidjs/router"
|
|||||||
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
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 { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
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
|
const currentSession = params.id
|
||||||
if (directory === currentDir && props.sessionID === currentSession) return
|
if (directory === currentDir && props.sessionID === currentSession) return
|
||||||
if (directory === currentDir && session?.parentID === currentSession) return
|
if (directory === currentDir && session?.parentID === currentSession) return
|
||||||
@@ -449,7 +450,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
const currentDir = decode64(params.dir)
|
||||||
const currentSession = params.id
|
const currentSession = params.id
|
||||||
if (!currentDir || !currentSession) return
|
if (!currentDir || !currentSession) return
|
||||||
const sessionKey = `${currentDir}:${currentSession}`
|
const sessionKey = `${currentDir}:${currentSession}`
|
||||||
@@ -503,7 +504,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentProject = createMemo(() => {
|
const currentProject = createMemo(() => {
|
||||||
const directory = params.dir ? base64Decode(params.dir) : undefined
|
const directory = decode64(params.dir)
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
|
|
||||||
const projects = layout.projects.list()
|
const projects = layout.projects.list()
|
||||||
@@ -638,7 +639,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const compare = sortSessions(Date.now())
|
const compare = sortSessions(Date.now())
|
||||||
if (workspaceSetting()) {
|
if (workspaceSetting()) {
|
||||||
const dirs = workspaceIds(project)
|
const dirs = workspaceIds(project)
|
||||||
const activeDir = params.dir ? base64Decode(params.dir) : ""
|
const activeDir = decode64(params.dir) ?? ""
|
||||||
const result: Session[] = []
|
const result: Session[] = []
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
|
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
|
||||||
@@ -1188,7 +1189,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
layout.projects.close(directory)
|
layout.projects.close(directory)
|
||||||
layout.projects.open(root)
|
layout.projects.open(root)
|
||||||
|
|
||||||
if (params.dir && base64Decode(params.dir) === directory) {
|
if (params.dir && decode64(params.dir) === directory) {
|
||||||
navigateToProject(root)
|
navigateToProject(root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1431,7 +1432,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
const dir = value.dir
|
const dir = value.dir
|
||||||
const id = value.id
|
const id = value.id
|
||||||
if (!dir || !id) return
|
if (!dir || !id) return
|
||||||
const directory = base64Decode(dir)
|
const directory = decode64(dir)
|
||||||
|
if (!directory) return
|
||||||
setStore("lastSession", directory, id)
|
setStore("lastSession", directory, id)
|
||||||
notification.session.markViewed(id)
|
notification.session.markViewed(id)
|
||||||
const expanded = untrack(() => store.workspaceExpanded[directory])
|
const expanded = untrack(() => store.workspaceExpanded[directory])
|
||||||
@@ -1454,7 +1456,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!project) return
|
if (!project) return
|
||||||
|
|
||||||
if (workspaceSetting()) {
|
if (workspaceSetting()) {
|
||||||
const activeDir = params.dir ? base64Decode(params.dir) : ""
|
const activeDir = decode64(params.dir) ?? ""
|
||||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||||
for (const directory of dirs) {
|
for (const directory of dirs) {
|
||||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||||
@@ -1504,7 +1506,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const local = project.worktree
|
const local = project.worktree
|
||||||
const dirs = [local, ...(project.sandboxes ?? [])]
|
const dirs = [local, ...(project.sandboxes ?? [])]
|
||||||
const active = currentProject()
|
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 extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
||||||
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
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 local = createMemo(() => props.directory === props.project.worktree)
|
||||||
const active = createMemo(() => {
|
const active = createMemo(() => {
|
||||||
const current = params.dir ? base64Decode(params.dir) : ""
|
const current = decode64(params.dir) ?? ""
|
||||||
return current === props.directory
|
return current === props.directory
|
||||||
})
|
})
|
||||||
const workspaceValue = createMemo(() => {
|
const workspaceValue = createMemo(() => {
|
||||||
@@ -2131,7 +2133,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
const selected = createMemo(() => {
|
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)
|
return props.project.worktree === current || props.project.sandboxes?.includes(current)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { Terminal } from "@/components/terminal"
|
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 { findLast } from "@opencode-ai/util/array"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||||
@@ -47,6 +47,7 @@ import { useComments, type LineComment } from "@/context/comments"
|
|||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import {
|
import {
|
||||||
SessionHeader,
|
SessionHeader,
|
||||||
@@ -2126,8 +2127,28 @@ export default function Page() {
|
|||||||
if (!isSvg()) return
|
if (!isSvg()) return
|
||||||
const c = state()?.content
|
const c = state()?.content
|
||||||
if (!c) return
|
if (!c) return
|
||||||
if (c.encoding === "base64") return base64Decode(c.content)
|
if (c.encoding !== "base64") return c.content
|
||||||
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(() => {
|
const svgPreviewUrl = createMemo(() => {
|
||||||
if (!isSvg()) return
|
if (!isSvg()) return
|
||||||
|
|||||||
10
packages/app/src/utils/base64.ts
Normal file
10
packages/app/src/utils/base64.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,7 +151,14 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
|
|||||||
const cached = cache.get(name)
|
const cached = cache.get(name)
|
||||||
if (fallback.disabled && cached !== undefined) return cached
|
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
|
if (stored === null) return cached ?? null
|
||||||
cache.set(name, stored)
|
cache.set(name, stored)
|
||||||
return stored
|
return stored
|
||||||
@@ -172,7 +179,11 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
|
|||||||
const name = item(key)
|
const name = item(key)
|
||||||
cache.delete(name)
|
cache.delete(name)
|
||||||
if (fallback.disabled) return
|
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)
|
const cached = cache.get(key)
|
||||||
if (fallback.disabled && cached !== undefined) return cached
|
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
|
if (stored === null) return cached ?? null
|
||||||
cache.set(key, stored)
|
cache.set(key, stored)
|
||||||
return stored
|
return stored
|
||||||
@@ -202,7 +220,11 @@ function localStorageDirect(): SyncStorage {
|
|||||||
removeItem: (key) => {
|
removeItem: (key) => {
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
if (fallback.disabled) return
|
if (fallback.disabled) return
|
||||||
localStorage.removeItem(key)
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
fallback.disabled = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user