index.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. // @refresh reload
  2. import { render } from "solid-js/web"
  3. import { App, 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 { AsyncStorage } from "@solid-primitives/storage"
  8. import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
  9. import { Store } from "@tauri-apps/plugin-store"
  10. import { UPDATER_ENABLED } from "./updater"
  11. import { createMenu } from "./menu"
  12. import { check, Update } from "@tauri-apps/plugin-updater"
  13. import { invoke } from "@tauri-apps/api/core"
  14. import { getCurrentWindow } from "@tauri-apps/api/window"
  15. import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
  16. import { relaunch } from "@tauri-apps/plugin-process"
  17. import pkg from "../package.json"
  18. const root = document.getElementById("root")
  19. if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
  20. throw new Error(
  21. "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
  22. )
  23. }
  24. let update: Update | null = null
  25. const platform: Platform = {
  26. platform: "desktop",
  27. version: pkg.version,
  28. async openDirectoryPickerDialog(opts) {
  29. const result = await open({
  30. directory: true,
  31. multiple: opts?.multiple ?? false,
  32. title: opts?.title ?? "Choose a folder",
  33. })
  34. return result
  35. },
  36. async openFilePickerDialog(opts) {
  37. const result = await open({
  38. directory: false,
  39. multiple: opts?.multiple ?? false,
  40. title: opts?.title ?? "Choose a file",
  41. })
  42. return result
  43. },
  44. async saveFilePickerDialog(opts) {
  45. const result = await save({
  46. title: opts?.title ?? "Save file",
  47. defaultPath: opts?.defaultPath,
  48. })
  49. return result
  50. },
  51. openLink(url: string) {
  52. void shellOpen(url).catch(() => undefined)
  53. },
  54. storage: (() => {
  55. type StoreLike = {
  56. get(key: string): Promise<string | null | undefined>
  57. set(key: string, value: string): Promise<unknown>
  58. delete(key: string): Promise<unknown>
  59. clear(): Promise<unknown>
  60. keys(): Promise<string[]>
  61. length(): Promise<number>
  62. }
  63. const WRITE_DEBOUNCE_MS = 250
  64. const storeCache = new Map<string, Promise<StoreLike>>()
  65. const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
  66. const memoryCache = new Map<string, StoreLike>()
  67. const createMemoryStore = () => {
  68. const data = new Map<string, string>()
  69. const store: StoreLike = {
  70. get: async (key) => data.get(key),
  71. set: async (key, value) => {
  72. data.set(key, value)
  73. },
  74. delete: async (key) => {
  75. data.delete(key)
  76. },
  77. clear: async () => {
  78. data.clear()
  79. },
  80. keys: async () => Array.from(data.keys()),
  81. length: async () => data.size,
  82. }
  83. return store
  84. }
  85. const getStore = (name: string) => {
  86. const cached = storeCache.get(name)
  87. if (cached) return cached
  88. const store = Store.load(name).catch(() => {
  89. const cached = memoryCache.get(name)
  90. if (cached) return cached
  91. const memory = createMemoryStore()
  92. memoryCache.set(name, memory)
  93. return memory
  94. })
  95. storeCache.set(name, store)
  96. return store
  97. }
  98. const createStorage = (name: string) => {
  99. const pending = new Map<string, string | null>()
  100. let timer: ReturnType<typeof setTimeout> | undefined
  101. let flushing: Promise<void> | undefined
  102. const flush = async () => {
  103. if (flushing) return flushing
  104. flushing = (async () => {
  105. const store = await getStore(name)
  106. while (pending.size > 0) {
  107. const batch = Array.from(pending.entries())
  108. pending.clear()
  109. for (const [key, value] of batch) {
  110. if (value === null) {
  111. await store.delete(key).catch(() => undefined)
  112. } else {
  113. await store.set(key, value).catch(() => undefined)
  114. }
  115. }
  116. }
  117. })().finally(() => {
  118. flushing = undefined
  119. })
  120. return flushing
  121. }
  122. const schedule = () => {
  123. if (timer) return
  124. timer = setTimeout(() => {
  125. timer = undefined
  126. void flush()
  127. }, WRITE_DEBOUNCE_MS)
  128. }
  129. const api: AsyncStorage & { flush: () => Promise<void> } = {
  130. flush,
  131. getItem: async (key: string) => {
  132. const next = pending.get(key)
  133. if (next !== undefined) return next
  134. const store = await getStore(name)
  135. const value = await store.get(key).catch(() => null)
  136. if (value === undefined) return null
  137. return value
  138. },
  139. setItem: async (key: string, value: string) => {
  140. pending.set(key, value)
  141. schedule()
  142. },
  143. removeItem: async (key: string) => {
  144. pending.set(key, null)
  145. schedule()
  146. },
  147. clear: async () => {
  148. pending.clear()
  149. const store = await getStore(name)
  150. await store.clear().catch(() => undefined)
  151. },
  152. key: async (index: number) => {
  153. const store = await getStore(name)
  154. return (await store.keys().catch(() => []))[index]
  155. },
  156. getLength: async () => {
  157. const store = await getStore(name)
  158. return await store.length().catch(() => 0)
  159. },
  160. get length() {
  161. return api.getLength()
  162. },
  163. }
  164. return api
  165. }
  166. return (name = "default.dat") => {
  167. const cached = apiCache.get(name)
  168. if (cached) return cached
  169. const api = createStorage(name)
  170. apiCache.set(name, api)
  171. return api
  172. }
  173. })(),
  174. checkUpdate: async () => {
  175. if (!UPDATER_ENABLED) return { updateAvailable: false }
  176. const next = await check().catch(() => null)
  177. if (!next) return { updateAvailable: false }
  178. const ok = await next
  179. .download()
  180. .then(() => true)
  181. .catch(() => false)
  182. if (!ok) return { updateAvailable: false }
  183. update = next
  184. return { updateAvailable: true, version: next.version }
  185. },
  186. update: async () => {
  187. if (!UPDATER_ENABLED || !update) return
  188. if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
  189. await update.install().catch(() => undefined)
  190. },
  191. restart: async () => {
  192. await invoke("kill_sidecar").catch(() => undefined)
  193. await relaunch()
  194. },
  195. notify: async (title, description, href) => {
  196. const granted = await isPermissionGranted().catch(() => false)
  197. const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
  198. if (permission !== "granted") return
  199. const win = getCurrentWindow()
  200. const focused = await win.isFocused().catch(() => document.hasFocus())
  201. if (focused) return
  202. await Promise.resolve()
  203. .then(() => {
  204. const notification = new Notification(title, {
  205. body: description ?? "",
  206. icon: "https://opencode.ai/favicon-96x96.png",
  207. })
  208. notification.onclick = () => {
  209. const win = getCurrentWindow()
  210. void win.show().catch(() => undefined)
  211. void win.unminimize().catch(() => undefined)
  212. void win.setFocus().catch(() => undefined)
  213. if (href) {
  214. window.history.pushState(null, "", href)
  215. window.dispatchEvent(new PopStateEvent("popstate"))
  216. }
  217. notification.close()
  218. }
  219. })
  220. .catch(() => undefined)
  221. },
  222. // @ts-expect-error
  223. fetch: tauriFetch,
  224. }
  225. createMenu()
  226. // Stops mousewheel events from reaching Tauri's pinch-to-zoom handler
  227. root?.addEventListener("mousewheel", (e) => {
  228. e.stopPropagation()
  229. })
  230. render(() => {
  231. return (
  232. <PlatformProvider value={platform}>
  233. {ostype() === "macos" && (
  234. <div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
  235. )}
  236. <App />
  237. </PlatformProvider>
  238. )
  239. }, root!)