diff options
Diffstat (limited to 'packages/desktop/src')
| -rw-r--r-- | packages/desktop/src/bindings.ts | 9 | ||||
| -rw-r--r-- | packages/desktop/src/index.tsx | 638 |
2 files changed, 346 insertions, 301 deletions
diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index c7bcaba9c..3d588a171 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -10,10 +10,13 @@ export const commands = { awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }), getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"), setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }), + getWslConfig: () => __TAURI_INVOKE<WslConfig>("get_wsl_config"), + setWslConfig: (config: WslConfig) => __TAURI_INVOKE<null>("set_wsl_config", { config }), getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"), setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }), parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }), checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }), + wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE<string>("wsl_path", { path, mode }), resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }), }; @@ -34,6 +37,12 @@ export type ServerReadyData = { password: string | null, }; +export type WslConfig = { + enabled: boolean, + }; + +export type WslPathMode = "windows" | "linux"; + /* Tauri Specta runtime */ function makeEvent<T>(name: string) { const base = { diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 0e2fcb7fe..ca603da5f 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -16,7 +16,6 @@ import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" import { check, Update } from "@tauri-apps/plugin-updater" import { getCurrentWindow } from "@tauri-apps/api/window" -import { invoke } from "@tauri-apps/api/core" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { relaunch } from "@tauri-apps/plugin-process" import { AsyncStorage } from "@solid-primitives/storage" @@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater" import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" -import { commands, InitStep } from "./bindings" +import { commands, InitStep, type WslConfig } from "./bindings" import { Channel } from "@tauri-apps/api/core" import { createMenu } from "./menu" @@ -59,338 +58,374 @@ const listenForDeepLinks = async () => { await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined) } -const createPlatform = (password: Accessor<string | null>): Platform => ({ - platform: "desktop", - os: (() => { +const createPlatform = (password: Accessor<string | null>): Platform => { + const os = (() => { const type = ostype() if (type === "macos" || type === "windows" || type === "linux") return type return undefined - })(), - version: pkg.version, - - async openDirectoryPickerDialog(opts) { - const result = await open({ - directory: true, - multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFolder"), - }) - return result - }, - - async openFilePickerDialog(opts) { - const result = await open({ - directory: false, - multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFile"), - }) - return result - }, + })() - async saveFilePickerDialog(opts) { - const result = await save({ - title: opts?.title ?? t("desktop.dialog.saveFile"), - defaultPath: opts?.defaultPath, - }) - return result - }, - - openLink(url: string) { - void shellOpen(url).catch(() => undefined) - }, - - async openPath(path: string, app?: string) { - const os = ostype() - if (os === "windows" && app) { - const resolvedApp = await commands.resolveAppPath(app) - return openerOpenPath(path, resolvedApp || app) - } - return openerOpenPath(path, app) - }, - - back() { - window.history.back() - }, - - forward() { - window.history.forward() - }, - - storage: (() => { - type StoreLike = { - get(key: string): Promise<string | null | undefined> - set(key: string, value: string): Promise<unknown> - delete(key: string): Promise<unknown> - clear(): Promise<unknown> - keys(): Promise<string[]> - length(): Promise<number> + const wslHome = async () => { + if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined + return commands.wslPath("~", "windows").catch(() => undefined) + } + + const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => { + if (!result || !window.__OPENCODE__?.wsl) return result + if (Array.isArray(result)) { + return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any } + return commands.wslPath(result, "linux").catch(() => result) as any + } - const WRITE_DEBOUNCE_MS = 250 + return { + platform: "desktop", + os, + version: pkg.version, + + async openDirectoryPickerDialog(opts) { + const defaultPath = await wslHome() + const result = await open({ + directory: true, + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFolder"), + defaultPath, + }) + return await handleWslPicker(result) + }, + + async openFilePickerDialog(opts) { + const result = await open({ + directory: false, + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFile"), + }) + return handleWslPicker(result) + }, - const storeCache = new Map<string, Promise<StoreLike>>() - const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>() - const memoryCache = new Map<string, StoreLike>() + async saveFilePickerDialog(opts) { + const result = await save({ + title: opts?.title ?? t("desktop.dialog.saveFile"), + defaultPath: opts?.defaultPath, + }) + return handleWslPicker(result) + }, + + openLink(url: string) { + void shellOpen(url).catch(() => undefined) + }, + async openPath(path: string, app?: string) { + const os = ostype() + if (os === "windows") { + const resolvedApp = (app && (await commands.resolveAppPath(app))) || app + const resolvedPath = await (async () => { + if (window.__OPENCODE__?.wsl) { + const converted = await commands.wslPath(path, "windows").catch(() => null) + if (converted) return converted + } - const flushAll = async () => { - const apis = Array.from(apiCache.values()) - await Promise.all(apis.map((api) => api.flush().catch(() => undefined))) - } + return path + })() + return openerOpenPath(resolvedPath, resolvedApp) + } + return openerOpenPath(path, app) + }, + + back() { + window.history.back() + }, + + forward() { + window.history.forward() + }, + + storage: (() => { + type StoreLike = { + get(key: string): Promise<string | null | undefined> + set(key: string, value: string): Promise<unknown> + delete(key: string): Promise<unknown> + clear(): Promise<unknown> + keys(): Promise<string[]> + length(): Promise<number> + } - if ("addEventListener" in globalThis) { - const handleVisibility = () => { - if (document.visibilityState !== "hidden") return - void flushAll() + const WRITE_DEBOUNCE_MS = 250 + + const storeCache = new Map<string, Promise<StoreLike>>() + const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>() + const memoryCache = new Map<string, StoreLike>() + + const flushAll = async () => { + const apis = Array.from(apiCache.values()) + await Promise.all(apis.map((api) => api.flush().catch(() => undefined))) } - window.addEventListener("pagehide", () => void flushAll()) - document.addEventListener("visibilitychange", handleVisibility) - } + if ("addEventListener" in globalThis) { + const handleVisibility = () => { + if (document.visibilityState !== "hidden") return + void flushAll() + } - const createMemoryStore = () => { - const data = new Map<string, string>() - const store: StoreLike = { - get: async (key) => data.get(key), - set: async (key, value) => { - data.set(key, value) - }, - delete: async (key) => { - data.delete(key) - }, - clear: async () => { - data.clear() - }, - keys: async () => Array.from(data.keys()), - length: async () => data.size, + window.addEventListener("pagehide", () => void flushAll()) + document.addEventListener("visibilitychange", handleVisibility) } - return store - } - const getStore = (name: string) => { - const cached = storeCache.get(name) - if (cached) return cached + const createMemoryStore = () => { + const data = new Map<string, string>() + const store: StoreLike = { + get: async (key) => data.get(key), + set: async (key, value) => { + data.set(key, value) + }, + delete: async (key) => { + data.delete(key) + }, + clear: async () => { + data.clear() + }, + keys: async () => Array.from(data.keys()), + length: async () => data.size, + } + return store + } - const store = Store.load(name).catch(() => { - const cached = memoryCache.get(name) + const getStore = (name: string) => { + const cached = storeCache.get(name) if (cached) return cached - const memory = createMemoryStore() - memoryCache.set(name, memory) - return memory - }) - - storeCache.set(name, store) - return store - } + const store = Store.load(name).catch(() => { + const cached = memoryCache.get(name) + if (cached) return cached - const createStorage = (name: string) => { - const pending = new Map<string, string | null>() - let timer: ReturnType<typeof setTimeout> | undefined - let flushing: Promise<void> | undefined + const memory = createMemoryStore() + memoryCache.set(name, memory) + return memory + }) - const flush = async () => { - if (flushing) return flushing + storeCache.set(name, store) + return store + } - flushing = (async () => { - const store = await getStore(name) - while (pending.size > 0) { - const batch = Array.from(pending.entries()) - pending.clear() - for (const [key, value] of batch) { - if (value === null) { - await store.delete(key).catch(() => undefined) - } else { - await store.set(key, value).catch(() => undefined) + const createStorage = (name: string) => { + const pending = new Map<string, string | null>() + let timer: ReturnType<typeof setTimeout> | undefined + let flushing: Promise<void> | undefined + + const flush = async () => { + if (flushing) return flushing + + flushing = (async () => { + const store = await getStore(name) + while (pending.size > 0) { + const batch = Array.from(pending.entries()) + pending.clear() + for (const [key, value] of batch) { + if (value === null) { + await store.delete(key).catch(() => undefined) + } else { + await store.set(key, value).catch(() => undefined) + } } } - } - })().finally(() => { - flushing = undefined - }) + })().finally(() => { + flushing = undefined + }) - return flushing - } + return flushing + } - const schedule = () => { - if (timer) return - timer = setTimeout(() => { - timer = undefined - void flush() - }, WRITE_DEBOUNCE_MS) - } + const schedule = () => { + if (timer) return + timer = setTimeout(() => { + timer = undefined + void flush() + }, WRITE_DEBOUNCE_MS) + } - const api: AsyncStorage & { flush: () => Promise<void> } = { - flush, - getItem: async (key: string) => { - const next = pending.get(key) - if (next !== undefined) return next - - const store = await getStore(name) - const value = await store.get(key).catch(() => null) - if (value === undefined) return null - return value - }, - setItem: async (key: string, value: string) => { - pending.set(key, value) - schedule() - }, - removeItem: async (key: string) => { - pending.set(key, null) - schedule() - }, - clear: async () => { - pending.clear() - const store = await getStore(name) - await store.clear().catch(() => undefined) - }, - key: async (index: number) => { - const store = await getStore(name) - return (await store.keys().catch(() => []))[index] - }, - getLength: async () => { - const store = await getStore(name) - return await store.length().catch(() => 0) - }, - get length() { - return api.getLength() - }, - } + const api: AsyncStorage & { flush: () => Promise<void> } = { + flush, + getItem: async (key: string) => { + const next = pending.get(key) + if (next !== undefined) return next + + const store = await getStore(name) + const value = await store.get(key).catch(() => null) + if (value === undefined) return null + return value + }, + setItem: async (key: string, value: string) => { + pending.set(key, value) + schedule() + }, + removeItem: async (key: string) => { + pending.set(key, null) + schedule() + }, + clear: async () => { + pending.clear() + const store = await getStore(name) + await store.clear().catch(() => undefined) + }, + key: async (index: number) => { + const store = await getStore(name) + return (await store.keys().catch(() => []))[index] + }, + getLength: async () => { + const store = await getStore(name) + return await store.length().catch(() => 0) + }, + get length() { + return api.getLength() + }, + } - return api - } + return api + } - return (name = "default.dat") => { - const cached = apiCache.get(name) - if (cached) return cached + return (name = "default.dat") => { + const cached = apiCache.get(name) + if (cached) return cached - const api = createStorage(name) - apiCache.set(name, api) - return api - } - })(), - - checkUpdate: async () => { - if (!UPDATER_ENABLED) return { updateAvailable: false } - const next = await check().catch(() => null) - if (!next) return { updateAvailable: false } - const ok = await next - .download() - .then(() => true) - .catch(() => false) - if (!ok) return { updateAvailable: false } - update = next - return { updateAvailable: true, version: next.version } - }, - - update: async () => { - if (!UPDATER_ENABLED || !update) return - if (ostype() === "windows") await commands.killSidecar().catch(() => undefined) - await update.install().catch(() => undefined) - }, - - restart: async () => { - await commands.killSidecar().catch(() => undefined) - await relaunch() - }, - - notify: async (title, description, href) => { - const granted = await isPermissionGranted().catch(() => false) - const permission = granted ? "granted" : await requestPermission().catch(() => "denied") - if (permission !== "granted") return - - const win = getCurrentWindow() - const focused = await win.isFocused().catch(() => document.hasFocus()) - if (focused) return - - await Promise.resolve() - .then(() => { - const notification = new Notification(title, { - body: description ?? "", - icon: "https://opencode.ai/favicon-96x96-v3.png", - }) - notification.onclick = () => { - const win = getCurrentWindow() - void win.show().catch(() => undefined) - void win.unminimize().catch(() => undefined) - void win.setFocus().catch(() => undefined) - if (href) { - window.history.pushState(null, "", href) - window.dispatchEvent(new PopStateEvent("popstate")) + const api = createStorage(name) + apiCache.set(name, api) + return api + } + })(), + + checkUpdate: async () => { + if (!UPDATER_ENABLED) return { updateAvailable: false } + const next = await check().catch(() => null) + if (!next) return { updateAvailable: false } + const ok = await next + .download() + .then(() => true) + .catch(() => false) + if (!ok) return { updateAvailable: false } + update = next + return { updateAvailable: true, version: next.version } + }, + + update: async () => { + if (!UPDATER_ENABLED || !update) return + if (ostype() === "windows") await commands.killSidecar().catch(() => undefined) + await update.install().catch(() => undefined) + }, + + restart: async () => { + await commands.killSidecar().catch(() => undefined) + await relaunch() + }, + + notify: async (title, description, href) => { + const granted = await isPermissionGranted().catch(() => false) + const permission = granted ? "granted" : await requestPermission().catch(() => "denied") + if (permission !== "granted") return + + const win = getCurrentWindow() + const focused = await win.isFocused().catch(() => document.hasFocus()) + if (focused) return + + await Promise.resolve() + .then(() => { + const notification = new Notification(title, { + body: description ?? "", + icon: "https://opencode.ai/favicon-96x96-v3.png", + }) + notification.onclick = () => { + const win = getCurrentWindow() + void win.show().catch(() => undefined) + void win.unminimize().catch(() => undefined) + void win.setFocus().catch(() => undefined) + if (href) { + window.history.pushState(null, "", href) + window.dispatchEvent(new PopStateEvent("popstate")) + } + notification.close() } - notification.close() - } - }) - .catch(() => undefined) - }, + }) + .catch(() => undefined) + }, - fetch: (input, init) => { - const pw = password() + fetch: (input, init) => { + const pw = password() - const addHeader = (headers: Headers, password: string) => { - headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`) - } + const addHeader = (headers: Headers, password: string) => { + headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`) + } - if (input instanceof Request) { - if (pw) addHeader(input.headers, pw) - return tauriFetch(input) - } else { - const headers = new Headers(init?.headers) - if (pw) addHeader(headers, pw) - return tauriFetch(input, { - ...(init as any), - headers: headers, + if (input instanceof Request) { + if (pw) addHeader(input.headers, pw) + return tauriFetch(input) + } else { + const headers = new Headers(init?.headers) + if (pw) addHeader(headers, pw) + return tauriFetch(input, { + ...(init as any), + headers: headers, + }) + } + }, + + getWslEnabled: async () => { + const next = await commands.getWslConfig().catch(() => null) + if (next) return next.enabled + return window.__OPENCODE__!.wsl ?? false + }, + + setWslEnabled: async (enabled) => { + await commands.setWslConfig({ enabled }) + }, + + getDefaultServerUrl: async () => { + const result = await commands.getDefaultServerUrl().catch(() => null) + return result + }, + + setDefaultServerUrl: async (url: string | null) => { + await commands.setDefaultServerUrl(url) + }, + + getDisplayBackend: async () => { + const result = await commands.getDisplayBackend().catch(() => null) + return result + }, + + setDisplayBackend: async (backend) => { + await commands.setDisplayBackend(backend) + }, + + parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), + + webviewZoom, + + checkAppExists: async (appName: string) => { + return commands.checkAppExists(appName) + }, + + async readClipboardImage() { + const image = await readImage().catch(() => null) + if (!image) return null + const bytes = await image.rgba().catch(() => null) + if (!bytes || bytes.length === 0) return null + const size = await image.size().catch(() => null) + if (!size) return null + const canvas = document.createElement("canvas") + canvas.width = size.width + canvas.height = size.height + const ctx = canvas.getContext("2d") + if (!ctx) return null + const imageData = ctx.createImageData(size.width, size.height) + imageData.data.set(bytes) + ctx.putImageData(imageData, 0, 0) + return new Promise<File | null>((resolve) => { + canvas.toBlob((blob) => { + if (!blob) return resolve(null) + resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) + }, "image/png") }) - } - }, - - getDefaultServerUrl: async () => { - const result = await commands.getDefaultServerUrl().catch(() => null) - return result - }, - - setDefaultServerUrl: async (url: string | null) => { - await commands.setDefaultServerUrl(url) - }, - - getDisplayBackend: async () => { - const result = await invoke<DisplayBackend | null>("get_display_backend").catch(() => null) - return result - }, - - setDisplayBackend: async (backend) => { - await invoke("set_display_backend", { backend }).catch(() => undefined) - }, - - parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), - - webviewZoom, - - checkAppExists: async (appName: string) => { - return commands.checkAppExists(appName) - }, - - async readClipboardImage() { - const image = await readImage().catch(() => null) - if (!image) return null - const bytes = await image.rgba().catch(() => null) - if (!bytes || bytes.length === 0) return null - const size = await image.size().catch(() => null) - if (!size) return null - const canvas = document.createElement("canvas") - canvas.width = size.width - canvas.height = size.height - const ctx = canvas.getContext("2d") - if (!ctx) return null - const imageData = ctx.createImageData(size.width, size.height) - imageData.data.set(bytes) - ctx.putImageData(imageData, 0, 0) - return new Promise<File | null>((resolve) => { - canvas.toBlob((blob) => { - if (!blob) return resolve(null) - resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) - }, "image/png") - }) - }, -}) + }, + } +} let menuTrigger = null as null | ((id: string) => void) createMenu((id) => { @@ -400,6 +435,7 @@ void listenForDeepLinks() render(() => { const [serverPassword, setServerPassword] = createSignal<string | null>(null) + const platform = createPlatform(() => serverPassword()) function handleClick(e: MouseEvent) { |
