settings-general.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. import { Button } from "@opencode-ai/ui/button"
  4. import { Icon } from "@opencode-ai/ui/icon"
  5. import { Select } from "@opencode-ai/ui/select"
  6. import { Switch } from "@opencode-ai/ui/switch"
  7. import { Tooltip } from "@opencode-ai/ui/tooltip"
  8. import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
  9. import { showToast } from "@opencode-ai/ui/toast"
  10. import { useLanguage } from "@/context/language"
  11. import { usePlatform } from "@/context/platform"
  12. import { useSettings, monoFontFamily } from "@/context/settings"
  13. import { playSound, SOUND_OPTIONS } from "@/utils/sound"
  14. import { Link } from "./link"
  15. let demoSoundState = {
  16. cleanup: undefined as (() => void) | undefined,
  17. timeout: undefined as NodeJS.Timeout | undefined,
  18. }
  19. // To prevent audio from overlapping/playing very quickly when navigating the settings menus,
  20. // delay the playback by 100ms during quick selection changes and pause existing sounds.
  21. const playDemoSound = (src: string) => {
  22. if (demoSoundState.cleanup) {
  23. demoSoundState.cleanup()
  24. }
  25. clearTimeout(demoSoundState.timeout)
  26. demoSoundState.timeout = setTimeout(() => {
  27. demoSoundState.cleanup = playSound(src)
  28. }, 100)
  29. }
  30. export const SettingsGeneral: Component = () => {
  31. const theme = useTheme()
  32. const language = useLanguage()
  33. const platform = usePlatform()
  34. const settings = useSettings()
  35. const [store, setStore] = createStore({
  36. checking: false,
  37. })
  38. const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
  39. const check = () => {
  40. if (!platform.checkUpdate) return
  41. setStore("checking", true)
  42. void platform
  43. .checkUpdate()
  44. .then((result) => {
  45. if (!result.updateAvailable) {
  46. showToast({
  47. variant: "success",
  48. icon: "circle-check",
  49. title: language.t("settings.updates.toast.latest.title"),
  50. description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
  51. })
  52. return
  53. }
  54. const actions =
  55. platform.update && platform.restart
  56. ? [
  57. {
  58. label: language.t("toast.update.action.installRestart"),
  59. onClick: async () => {
  60. await platform.update!()
  61. await platform.restart!()
  62. },
  63. },
  64. {
  65. label: language.t("toast.update.action.notYet"),
  66. onClick: "dismiss" as const,
  67. },
  68. ]
  69. : [
  70. {
  71. label: language.t("toast.update.action.notYet"),
  72. onClick: "dismiss" as const,
  73. },
  74. ]
  75. showToast({
  76. persistent: true,
  77. icon: "download",
  78. title: language.t("toast.update.title"),
  79. description: language.t("toast.update.description", { version: result.version ?? "" }),
  80. actions,
  81. })
  82. })
  83. .catch((err: unknown) => {
  84. const message = err instanceof Error ? err.message : String(err)
  85. showToast({ title: language.t("common.requestFailed"), description: message })
  86. })
  87. .finally(() => setStore("checking", false))
  88. }
  89. const themeOptions = createMemo(() =>
  90. Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
  91. )
  92. const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
  93. { value: "system", label: language.t("theme.scheme.system") },
  94. { value: "light", label: language.t("theme.scheme.light") },
  95. { value: "dark", label: language.t("theme.scheme.dark") },
  96. ])
  97. const languageOptions = createMemo(() =>
  98. language.locales.map((locale) => ({
  99. value: locale,
  100. label: language.label(locale),
  101. })),
  102. )
  103. const fontOptions = [
  104. { value: "ibm-plex-mono", label: "font.option.ibmPlexMono" },
  105. { value: "cascadia-code", label: "font.option.cascadiaCode" },
  106. { value: "fira-code", label: "font.option.firaCode" },
  107. { value: "hack", label: "font.option.hack" },
  108. { value: "inconsolata", label: "font.option.inconsolata" },
  109. { value: "intel-one-mono", label: "font.option.intelOneMono" },
  110. { value: "iosevka", label: "font.option.iosevka" },
  111. { value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
  112. { value: "meslo-lgs", label: "font.option.mesloLgs" },
  113. { value: "roboto-mono", label: "font.option.robotoMono" },
  114. { value: "source-code-pro", label: "font.option.sourceCodePro" },
  115. { value: "ubuntu-mono", label: "font.option.ubuntuMono" },
  116. ] as const
  117. const fontOptionsList = [...fontOptions]
  118. const soundOptions = [...SOUND_OPTIONS]
  119. return (
  120. <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
  121. <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
  122. <div class="flex flex-col gap-1 pt-6 pb-8">
  123. <h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
  124. </div>
  125. </div>
  126. <div class="flex flex-col gap-8 w-full">
  127. {/* Appearance Section */}
  128. <div class="flex flex-col gap-1">
  129. <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
  130. <div class="bg-surface-raised-base px-4 rounded-lg">
  131. <SettingsRow
  132. title={language.t("settings.general.row.language.title")}
  133. description={language.t("settings.general.row.language.description")}
  134. >
  135. <Select
  136. data-action="settings-language"
  137. options={languageOptions()}
  138. current={languageOptions().find((o) => o.value === language.locale())}
  139. value={(o) => o.value}
  140. label={(o) => o.label}
  141. onSelect={(option) => option && language.setLocale(option.value)}
  142. variant="secondary"
  143. size="small"
  144. triggerVariant="settings"
  145. />
  146. </SettingsRow>
  147. <SettingsRow
  148. title={language.t("settings.general.row.appearance.title")}
  149. description={language.t("settings.general.row.appearance.description")}
  150. >
  151. <Select
  152. data-action="settings-color-scheme"
  153. options={colorSchemeOptions()}
  154. current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
  155. value={(o) => o.value}
  156. label={(o) => o.label}
  157. onSelect={(option) => option && theme.setColorScheme(option.value)}
  158. onHighlight={(option) => {
  159. if (!option) return
  160. theme.previewColorScheme(option.value)
  161. return () => theme.cancelPreview()
  162. }}
  163. variant="secondary"
  164. size="small"
  165. triggerVariant="settings"
  166. />
  167. </SettingsRow>
  168. <SettingsRow
  169. title={language.t("settings.general.row.theme.title")}
  170. description={
  171. <>
  172. {language.t("settings.general.row.theme.description")}{" "}
  173. <Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
  174. </>
  175. }
  176. >
  177. <Select
  178. data-action="settings-theme"
  179. options={themeOptions()}
  180. current={themeOptions().find((o) => o.id === theme.themeId())}
  181. value={(o) => o.id}
  182. label={(o) => o.name}
  183. onSelect={(option) => {
  184. if (!option) return
  185. theme.setTheme(option.id)
  186. }}
  187. onHighlight={(option) => {
  188. if (!option) return
  189. theme.previewTheme(option.id)
  190. return () => theme.cancelPreview()
  191. }}
  192. variant="secondary"
  193. size="small"
  194. triggerVariant="settings"
  195. />
  196. </SettingsRow>
  197. <SettingsRow
  198. title={language.t("settings.general.row.font.title")}
  199. description={language.t("settings.general.row.font.description")}
  200. >
  201. <Select
  202. data-action="settings-font"
  203. options={fontOptionsList}
  204. current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
  205. value={(o) => o.value}
  206. label={(o) => language.t(o.label)}
  207. onSelect={(option) => option && settings.appearance.setFont(option.value)}
  208. variant="secondary"
  209. size="small"
  210. triggerVariant="settings"
  211. triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
  212. >
  213. {(option) => (
  214. <span style={{ "font-family": monoFontFamily(option?.value) }}>
  215. {option ? language.t(option.label) : ""}
  216. </span>
  217. )}
  218. </Select>
  219. </SettingsRow>
  220. </div>
  221. </div>
  222. {/* System notifications Section */}
  223. <div class="flex flex-col gap-1">
  224. <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
  225. <div class="bg-surface-raised-base px-4 rounded-lg">
  226. <SettingsRow
  227. title={language.t("settings.general.notifications.agent.title")}
  228. description={language.t("settings.general.notifications.agent.description")}
  229. >
  230. <div data-action="settings-notifications-agent">
  231. <Switch
  232. checked={settings.notifications.agent()}
  233. onChange={(checked) => settings.notifications.setAgent(checked)}
  234. />
  235. </div>
  236. </SettingsRow>
  237. <SettingsRow
  238. title={language.t("settings.general.notifications.permissions.title")}
  239. description={language.t("settings.general.notifications.permissions.description")}
  240. >
  241. <div data-action="settings-notifications-permissions">
  242. <Switch
  243. checked={settings.notifications.permissions()}
  244. onChange={(checked) => settings.notifications.setPermissions(checked)}
  245. />
  246. </div>
  247. </SettingsRow>
  248. <SettingsRow
  249. title={language.t("settings.general.notifications.errors.title")}
  250. description={language.t("settings.general.notifications.errors.description")}
  251. >
  252. <div data-action="settings-notifications-errors">
  253. <Switch
  254. checked={settings.notifications.errors()}
  255. onChange={(checked) => settings.notifications.setErrors(checked)}
  256. />
  257. </div>
  258. </SettingsRow>
  259. </div>
  260. </div>
  261. {/* Sound effects Section */}
  262. <div class="flex flex-col gap-1">
  263. <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
  264. <div class="bg-surface-raised-base px-4 rounded-lg">
  265. <SettingsRow
  266. title={language.t("settings.general.sounds.agent.title")}
  267. description={language.t("settings.general.sounds.agent.description")}
  268. >
  269. <Select
  270. data-action="settings-sounds-agent"
  271. options={soundOptions}
  272. current={soundOptions.find((o) => o.id === settings.sounds.agent())}
  273. value={(o) => o.id}
  274. label={(o) => language.t(o.label)}
  275. onHighlight={(option) => {
  276. if (!option) return
  277. playDemoSound(option.src)
  278. }}
  279. onSelect={(option) => {
  280. if (!option) return
  281. settings.sounds.setAgent(option.id)
  282. playDemoSound(option.src)
  283. }}
  284. variant="secondary"
  285. size="small"
  286. triggerVariant="settings"
  287. />
  288. </SettingsRow>
  289. <SettingsRow
  290. title={language.t("settings.general.sounds.permissions.title")}
  291. description={language.t("settings.general.sounds.permissions.description")}
  292. >
  293. <Select
  294. data-action="settings-sounds-permissions"
  295. options={soundOptions}
  296. current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
  297. value={(o) => o.id}
  298. label={(o) => language.t(o.label)}
  299. onHighlight={(option) => {
  300. if (!option) return
  301. playDemoSound(option.src)
  302. }}
  303. onSelect={(option) => {
  304. if (!option) return
  305. settings.sounds.setPermissions(option.id)
  306. playDemoSound(option.src)
  307. }}
  308. variant="secondary"
  309. size="small"
  310. triggerVariant="settings"
  311. />
  312. </SettingsRow>
  313. <SettingsRow
  314. title={language.t("settings.general.sounds.errors.title")}
  315. description={language.t("settings.general.sounds.errors.description")}
  316. >
  317. <Select
  318. data-action="settings-sounds-errors"
  319. options={soundOptions}
  320. current={soundOptions.find((o) => o.id === settings.sounds.errors())}
  321. value={(o) => o.id}
  322. label={(o) => language.t(o.label)}
  323. onHighlight={(option) => {
  324. if (!option) return
  325. playDemoSound(option.src)
  326. }}
  327. onSelect={(option) => {
  328. if (!option) return
  329. settings.sounds.setErrors(option.id)
  330. playDemoSound(option.src)
  331. }}
  332. variant="secondary"
  333. size="small"
  334. triggerVariant="settings"
  335. />
  336. </SettingsRow>
  337. </div>
  338. </div>
  339. {/* Updates Section */}
  340. <div class="flex flex-col gap-1">
  341. <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
  342. <div class="bg-surface-raised-base px-4 rounded-lg">
  343. <SettingsRow
  344. title={language.t("settings.updates.row.startup.title")}
  345. description={language.t("settings.updates.row.startup.description")}
  346. >
  347. <div data-action="settings-updates-startup">
  348. <Switch
  349. checked={settings.updates.startup()}
  350. disabled={!platform.checkUpdate}
  351. onChange={(checked) => settings.updates.setStartup(checked)}
  352. />
  353. </div>
  354. </SettingsRow>
  355. <SettingsRow
  356. title={language.t("settings.general.row.releaseNotes.title")}
  357. description={language.t("settings.general.row.releaseNotes.description")}
  358. >
  359. <div data-action="settings-release-notes">
  360. <Switch
  361. checked={settings.general.releaseNotes()}
  362. onChange={(checked) => settings.general.setReleaseNotes(checked)}
  363. />
  364. </div>
  365. </SettingsRow>
  366. <SettingsRow
  367. title={language.t("settings.updates.row.check.title")}
  368. description={language.t("settings.updates.row.check.description")}
  369. >
  370. <Button
  371. size="small"
  372. variant="secondary"
  373. disabled={store.checking || !platform.checkUpdate}
  374. onClick={check}
  375. >
  376. {store.checking
  377. ? language.t("settings.updates.action.checking")
  378. : language.t("settings.updates.action.checkNow")}
  379. </Button>
  380. </SettingsRow>
  381. </div>
  382. </div>
  383. <Show when={linux()}>
  384. {(_) => {
  385. const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
  386. const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
  387. const onChange = (checked: boolean) =>
  388. platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
  389. return (
  390. <div class="flex flex-col gap-1">
  391. <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
  392. <div class="bg-surface-raised-base px-4 rounded-lg">
  393. <SettingsRow
  394. title={
  395. <div class="flex items-center gap-2">
  396. <span>{language.t("settings.general.row.wayland.title")}</span>
  397. <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
  398. <span class="text-text-weak">
  399. <Icon name="help" size="small" />
  400. </span>
  401. </Tooltip>
  402. </div>
  403. }
  404. description={language.t("settings.general.row.wayland.description")}
  405. >
  406. <div data-action="settings-wayland">
  407. <Switch checked={value() === "wayland"} onChange={onChange} />
  408. </div>
  409. </SettingsRow>
  410. </div>
  411. </div>
  412. )
  413. }}
  414. </Show>
  415. </div>
  416. </div>
  417. )
  418. }
  419. interface SettingsRowProps {
  420. title: string | JSX.Element
  421. description: string | JSX.Element
  422. children: JSX.Element
  423. }
  424. const SettingsRow: Component<SettingsRowProps> = (props) => {
  425. return (
  426. <div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
  427. <div class="flex flex-col gap-0.5 min-w-0">
  428. <span class="text-14-medium text-text-strong">{props.title}</span>
  429. <span class="text-12-regular text-text-weak">{props.description}</span>
  430. </div>
  431. <div class="flex-shrink-0">{props.children}</div>
  432. </div>
  433. )
  434. }