index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. // @refresh reload
  2. import { render } from "solid-js/web"
  3. import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
  4. import { open, save } from "@tauri-apps/plugin-dialog"
  5. import { open as shellOpen } from "@tauri-apps/plugin-shell"
  6. import { type as ostype } from "@tauri-apps/plugin-os"
  7. import { check, Update } from "@tauri-apps/plugin-updater"
  8. import { invoke } from "@tauri-apps/api/core"
  9. import { getCurrentWindow } from "@tauri-apps/api/window"
  10. import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
  11. import { relaunch } from "@tauri-apps/plugin-process"
  12. import { AsyncStorage } from "@solid-primitives/storage"
  13. import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
  14. import { Store } from "@tauri-apps/plugin-store"
  15. import { Logo } from "@opencode-ai/ui/logo"
  16. import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
  17. import { UPDATER_ENABLED } from "./updater"
  18. import { createMenu } from "./menu"
  19. import pkg from "../package.json"
  20. const root = document.getElementById("root")
  21. if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
  22. throw new Error(
  23. "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
  24. )
  25. }
  26. let update: Update | null = null
  27. const createPlatform = (password: Accessor<string | null>): Platform => ({
  28. platform: "desktop",
  29. os: (() => {
  30. const type = ostype()
  31. if (type === "macos" || type === "windows" || type === "linux") return type
  32. return undefined
  33. })(),
  34. version: pkg.version,
  35. async openDirectoryPickerDialog(opts) {
  36. const result = await open({
  37. directory: true,
  38. multiple: opts?.multiple ?? false,
  39. title: opts?.title ?? "Choose a folder",
  40. })
  41. return result
  42. },
  43. async openFilePickerDialog(opts) {
  44. const result = await open({
  45. directory: false,
  46. multiple: opts?.multiple ?? false,
  47. title: opts?.title ?? "Choose a file",
  48. })
  49. return result
  50. },
  51. async saveFilePickerDialog(opts) {
  52. const result = await save({
  53. title: opts?.title ?? "Save file",
  54. defaultPath: opts?.defaultPath,
  55. })
  56. return result
  57. },
  58. openLink(url: string) {
  59. void shellOpen(url).catch(() => undefined)
  60. },
  61. storage: (() => {
  62. type StoreLike = {
  63. get(key: string): Promise<string | null | undefined>
  64. set(key: string, value: string): Promise<unknown>
  65. delete(key: string): Promise<unknown>
  66. clear(): Promise<unknown>
  67. keys(): Promise<string[]>
  68. length(): Promise<number>
  69. }
  70. const WRITE_DEBOUNCE_MS = 250
  71. const storeCache = new Map<string, Promise<StoreLike>>()
  72. const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
  73. const memoryCache = new Map<string, StoreLike>()
  74. const createMemoryStore = () => {
  75. const data = new Map<string, string>()
  76. const store: StoreLike = {
  77. get: async (key) => data.get(key),
  78. set: async (key, value) => {
  79. data.set(key, value)
  80. },
  81. delete: async (key) => {
  82. data.delete(key)
  83. },
  84. clear: async () => {
  85. data.clear()
  86. },
  87. keys: async () => Array.from(data.keys()),
  88. length: async () => data.size,
  89. }
  90. return store
  91. }
  92. const getStore = (name: string) => {
  93. const cached = storeCache.get(name)
  94. if (cached) return cached
  95. const store = Store.load(name).catch(() => {
  96. const cached = memoryCache.get(name)
  97. if (cached) return cached
  98. const memory = createMemoryStore()
  99. memoryCache.set(name, memory)
  100. return memory
  101. })
  102. storeCache.set(name, store)
  103. return store
  104. }
  105. const createStorage = (name: string) => {
  106. const pending = new Map<string, string | null>()
  107. let timer: ReturnType<typeof setTimeout> | undefined
  108. let flushing: Promise<void> | undefined
  109. const flush = async () => {
  110. if (flushing) return flushing
  111. flushing = (async () => {
  112. const store = await getStore(name)
  113. while (pending.size > 0) {
  114. const batch = Array.from(pending.entries())
  115. pending.clear()
  116. for (const [key, value] of batch) {
  117. if (value === null) {
  118. await store.delete(key).catch(() => undefined)
  119. } else {
  120. await store.set(key, value).catch(() => undefined)
  121. }
  122. }
  123. }
  124. })().finally(() => {
  125. flushing = undefined
  126. })
  127. return flushing
  128. }
  129. const schedule = () => {
  130. if (timer) return
  131. timer = setTimeout(() => {
  132. timer = undefined
  133. void flush()
  134. }, WRITE_DEBOUNCE_MS)
  135. }
  136. const api: AsyncStorage & { flush: () => Promise<void> } = {
  137. flush,
  138. getItem: async (key: string) => {
  139. const next = pending.get(key)
  140. if (next !== undefined) return next
  141. const store = await getStore(name)
  142. const value = await store.get(key).catch(() => null)
  143. if (value === undefined) return null
  144. return value
  145. },
  146. setItem: async (key: string, value: string) => {
  147. pending.set(key, value)
  148. schedule()
  149. },
  150. removeItem: async (key: string) => {
  151. pending.set(key, null)
  152. schedule()
  153. },
  154. clear: async () => {
  155. pending.clear()
  156. const store = await getStore(name)
  157. await store.clear().catch(() => undefined)
  158. },
  159. key: async (index: number) => {
  160. const store = await getStore(name)
  161. return (await store.keys().catch(() => []))[index]
  162. },
  163. getLength: async () => {
  164. const store = await getStore(name)
  165. return await store.length().catch(() => 0)
  166. },
  167. get length() {
  168. return api.getLength()
  169. },
  170. }
  171. return api
  172. }
  173. return (name = "default.dat") => {
  174. const cached = apiCache.get(name)
  175. if (cached) return cached
  176. const api = createStorage(name)
  177. apiCache.set(name, api)
  178. return api
  179. }
  180. })(),
  181. checkUpdate: async () => {
  182. if (!UPDATER_ENABLED) return { updateAvailable: false }
  183. const next = await check().catch(() => null)
  184. if (!next) return { updateAvailable: false }
  185. const ok = await next
  186. .download()
  187. .then(() => true)
  188. .catch(() => false)
  189. if (!ok) return { updateAvailable: false }
  190. update = next
  191. return { updateAvailable: true, version: next.version }
  192. },
  193. update: async () => {
  194. if (!UPDATER_ENABLED || !update) return
  195. if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
  196. await update.install().catch(() => undefined)
  197. },
  198. restart: async () => {
  199. await invoke("kill_sidecar").catch(() => undefined)
  200. await relaunch()
  201. },
  202. notify: async (title, description, href) => {
  203. const granted = await isPermissionGranted().catch(() => false)
  204. const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
  205. if (permission !== "granted") return
  206. const win = getCurrentWindow()
  207. const focused = await win.isFocused().catch(() => document.hasFocus())
  208. if (focused) return
  209. await Promise.resolve()
  210. .then(() => {
  211. const notification = new Notification(title, {
  212. body: description ?? "",
  213. icon: "https://opencode.ai/favicon-96x96.png",
  214. })
  215. notification.onclick = () => {
  216. const win = getCurrentWindow()
  217. void win.show().catch(() => undefined)
  218. void win.unminimize().catch(() => undefined)
  219. void win.setFocus().catch(() => undefined)
  220. if (href) {
  221. window.history.pushState(null, "", href)
  222. window.dispatchEvent(new PopStateEvent("popstate"))
  223. }
  224. notification.close()
  225. }
  226. })
  227. .catch(() => undefined)
  228. },
  229. // @ts-expect-error
  230. fetch: (input, init) => {
  231. const pw = password()
  232. const addHeader = (headers: Headers, password: string) => {
  233. headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
  234. }
  235. if (input instanceof Request) {
  236. if (pw) addHeader(input.headers, pw)
  237. return tauriFetch(input)
  238. } else {
  239. const headers = new Headers(init?.headers)
  240. if (pw) addHeader(headers, pw)
  241. return tauriFetch(input, {
  242. ...(init as any),
  243. headers: headers,
  244. })
  245. }
  246. },
  247. getDefaultServerUrl: async () => {
  248. const result = await invoke<string | null>("get_default_server_url").catch(() => null)
  249. return result
  250. },
  251. setDefaultServerUrl: async (url: string | null) => {
  252. await invoke("set_default_server_url", { url })
  253. },
  254. })
  255. createMenu()
  256. // Stops mousewheel events from reaching Tauri's pinch-to-zoom handler
  257. root?.addEventListener("mousewheel", (e) => {
  258. e.stopPropagation()
  259. })
  260. render(() => {
  261. const [serverPassword, setServerPassword] = createSignal<string | null>(null)
  262. const platform = createPlatform(() => serverPassword())
  263. function handleClick(e: MouseEvent) {
  264. const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
  265. if (link?.href) {
  266. e.preventDefault()
  267. platform.openLink(link.href)
  268. }
  269. }
  270. onMount(() => {
  271. document.addEventListener("click", handleClick)
  272. onCleanup(() => {
  273. document.removeEventListener("click", handleClick)
  274. })
  275. })
  276. return (
  277. <PlatformProvider value={platform}>
  278. <AppBaseProviders>
  279. <ServerGate>
  280. {(data) => {
  281. setServerPassword(data().password)
  282. window.__OPENCODE__ ??= {}
  283. window.__OPENCODE__.serverPassword = data().password ?? undefined
  284. return <AppInterface defaultUrl={data().url} />
  285. }}
  286. </ServerGate>
  287. </AppBaseProviders>
  288. </PlatformProvider>
  289. )
  290. }, root!)
  291. type ServerReadyData = { url: string; password: string | null }
  292. // Gate component that waits for the server to be ready
  293. function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
  294. const [serverData] = createResource<ServerReadyData>(() => invoke("ensure_server_ready"))
  295. return (
  296. // Not using suspense as not all components are compatible with it (undefined refs)
  297. <Show
  298. when={serverData.state !== "pending" && serverData()}
  299. fallback={
  300. <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
  301. <Logo class="w-xl opacity-12 animate-pulse" />
  302. <div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
  303. </div>
  304. }
  305. >
  306. {(data) => props.children(data)}
  307. </Show>
  308. )
  309. }