| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
- import { createStore } from "solid-js/store"
- import { Button } from "@opencode-ai/ui/button"
- import { Icon } from "@opencode-ai/ui/icon"
- import { Select } from "@opencode-ai/ui/select"
- import { Switch } from "@opencode-ai/ui/switch"
- import { Tooltip } from "@opencode-ai/ui/tooltip"
- import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
- import { showToast } from "@opencode-ai/ui/toast"
- import { useLanguage } from "@/context/language"
- import { usePlatform } from "@/context/platform"
- import { useSettings, monoFontFamily } from "@/context/settings"
- import { playSound, SOUND_OPTIONS } from "@/utils/sound"
- import { Link } from "./link"
- let demoSoundState = {
- cleanup: undefined as (() => void) | undefined,
- timeout: undefined as NodeJS.Timeout | undefined,
- }
- // To prevent audio from overlapping/playing very quickly when navigating the settings menus,
- // delay the playback by 100ms during quick selection changes and pause existing sounds.
- const playDemoSound = (src: string) => {
- if (demoSoundState.cleanup) {
- demoSoundState.cleanup()
- }
- clearTimeout(demoSoundState.timeout)
- demoSoundState.timeout = setTimeout(() => {
- demoSoundState.cleanup = playSound(src)
- }, 100)
- }
- export const SettingsGeneral: Component = () => {
- const theme = useTheme()
- const language = useLanguage()
- const platform = usePlatform()
- const settings = useSettings()
- const [store, setStore] = createStore({
- checking: false,
- })
- const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
- const check = () => {
- if (!platform.checkUpdate) return
- setStore("checking", true)
- void platform
- .checkUpdate()
- .then((result) => {
- if (!result.updateAvailable) {
- showToast({
- variant: "success",
- icon: "circle-check",
- title: language.t("settings.updates.toast.latest.title"),
- description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
- })
- return
- }
- const actions =
- platform.update && platform.restart
- ? [
- {
- label: language.t("toast.update.action.installRestart"),
- onClick: async () => {
- await platform.update!()
- await platform.restart!()
- },
- },
- {
- label: language.t("toast.update.action.notYet"),
- onClick: "dismiss" as const,
- },
- ]
- : [
- {
- label: language.t("toast.update.action.notYet"),
- onClick: "dismiss" as const,
- },
- ]
- showToast({
- persistent: true,
- icon: "download",
- title: language.t("toast.update.title"),
- description: language.t("toast.update.description", { version: result.version ?? "" }),
- actions,
- })
- })
- .catch((err: unknown) => {
- const message = err instanceof Error ? err.message : String(err)
- showToast({ title: language.t("common.requestFailed"), description: message })
- })
- .finally(() => setStore("checking", false))
- }
- const themeOptions = createMemo(() =>
- Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
- )
- const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
- { value: "system", label: language.t("theme.scheme.system") },
- { value: "light", label: language.t("theme.scheme.light") },
- { value: "dark", label: language.t("theme.scheme.dark") },
- ])
- const languageOptions = createMemo(() =>
- language.locales.map((locale) => ({
- value: locale,
- label: language.label(locale),
- })),
- )
- const fontOptions = [
- { value: "ibm-plex-mono", label: "font.option.ibmPlexMono" },
- { value: "cascadia-code", label: "font.option.cascadiaCode" },
- { value: "fira-code", label: "font.option.firaCode" },
- { value: "hack", label: "font.option.hack" },
- { value: "inconsolata", label: "font.option.inconsolata" },
- { value: "intel-one-mono", label: "font.option.intelOneMono" },
- { value: "iosevka", label: "font.option.iosevka" },
- { value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
- { value: "meslo-lgs", label: "font.option.mesloLgs" },
- { value: "roboto-mono", label: "font.option.robotoMono" },
- { value: "source-code-pro", label: "font.option.sourceCodePro" },
- { value: "ubuntu-mono", label: "font.option.ubuntuMono" },
- ] as const
- const fontOptionsList = [...fontOptions]
- const soundOptions = [...SOUND_OPTIONS]
- return (
- <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
- <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
- <div class="flex flex-col gap-1 pt-6 pb-8">
- <h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
- </div>
- </div>
- <div class="flex flex-col gap-8 w-full">
- {/* Appearance Section */}
- <div class="flex flex-col gap-1">
- <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
- <div class="bg-surface-raised-base px-4 rounded-lg">
- <SettingsRow
- title={language.t("settings.general.row.language.title")}
- description={language.t("settings.general.row.language.description")}
- >
- <Select
- data-action="settings-language"
- options={languageOptions()}
- current={languageOptions().find((o) => o.value === language.locale())}
- value={(o) => o.value}
- label={(o) => o.label}
- onSelect={(option) => option && language.setLocale(option.value)}
- variant="secondary"
- size="small"
- triggerVariant="settings"
- />
- </SettingsRow>
- <SettingsRow
- title={language.t("settings.general.row.appearance.title")}
- description={language.t("settings.general.row.appearance.description")}
- >
- <Select
- data-action="settings-color-scheme"
- options={colorSchemeOptions()}
- current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
- value={(o) => o.value}
- label={(o) => o.label}
- onSelect={(option) => option && theme.setColorScheme(option.value)}
- onHighlight={(option) => {
- if (!option) return
- theme.previewColorScheme(option.value)
- return () => theme.cancelPreview()
- }}
- variant="secondary"
- size="small"
- triggerVariant="settings"
- />
- </SettingsRow>
- <SettingsRow
- title={language.t("settings.general.row.theme.title")}
- description={
- <>
- {language.t("settings.general.row.theme.description")}{" "}
- <Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
- </>
- }
- >
- <Select
- data-action="settings-theme"
- options={themeOptions()}
- current={themeOptions().find((o) => o.id === theme.themeId())}
- value={(o) => o.id}
- label={(o) => o.name}
- onSelect={(option) => {
- if (!option) return
- theme.setTheme(option.id)
- }}
- onHighlight={(option) => {
- if (!option) return
- theme.previewTheme(option.id)
- return () => theme.cancelPreview()
- }}
- variant="secondary"
- size="small"
- triggerVariant="settings"
- />
- </SettingsRow>
- <SettingsRow
- title={language.t("settings.general.row.font.title")}
- description={language.t("settings.general.row.font.description")}
- >
- <Select
- data-action="settings-font"
- options={fontOptionsList}
- current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
- value={(o) => o.value}
- label={(o) => language.t(o.label)}
- onSelect={(option) => option && settings.appearance.setFont(option.value)}
- variant="secondary"
- size="small"
- triggerVariant="settings"
- triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
- >
- {(option) => (
- <span style={{ "font-family": monoFontFamily(option?.value) }}>
- {option ? language.t(option.label) : ""}
- </span>
- )}
- </Select>
- </SettingsRow>
- </div>
- </div>
- {/* System notifications Section */}
- <div class="flex flex-col gap-1">
- <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
- <div class="bg-surface-raised-base px-4 rounded-lg">
- <SettingsRow
- title={language.t("settings.general.notifications.agent.title")}
- description={language.t("settings.general.notifications.agent.description")}
- >
- <div data-action="settings-notifications-agent">
- <Switch
- checked={settings.notifications.agent()}
- onChange={(checked) => settings.notifications.setAgent(checked)}
- />
- </div>
- </SettingsRow>
- <SettingsRow
- title={language.t("settings.general.notifications.permissions.title")}
- description={language.t("settings.general.notifications.permissions.description")}
- >
- <div data-action="settings-notifications-permissions">
- <Switch
- checked={settings.notifications.permissions()}
- onChange={(checked) => settings.notifications.setPermissions(checked)}
- />
- </div>
- </SettingsRow>
- <SettingsRow
- title={language.t("settings.general.notifications.errors.title")}
- description={language.t("settings.general.notifications.errors.description")}
- >
- <div data-action="settings-notifications-errors">
- <Switch
- checked={settings.notifications.errors()}
- onChange={(checked) => settings.notifications.setErrors(checked)}
- />
- </div>
- </SettingsRow>
- </div>
- </div>
- {/* Sound effects Section */}
- <div class="flex flex-col gap-1">
- <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
- <div class="bg-surface-raised-base px-4 rounded-lg">
- <SettingsRow
- title={language.t("settings.general.sounds.agent.title")}
- description={language.t("settings.general.sounds.agent.description")}
- >
- <Select
- data-action="settings-sounds-agent"
- options={soundOptions}
- current={soundOptions.find((o) => o.id === settings.sounds.agent())}
- value={(o) => o.id}
- label={(o) => language.t(o.label)}
- onHighlight={(option) => {
- if (!option) return
- playDemoSound(option.src)
- }}
- onSelect={(option) => {
- if (!option) return
- settings.sounds.setAgent(option.id)
- playDemoSound(option.src)
- }}
- variant="secondary"
- size="small"
- triggerVariant="settings"
- />
- </SettingsRow>
- <SettingsRow
- title={language.t("settings.general.sounds.permissions.title")}
- description={language.t("settings.general.sounds.permissions.description")}
- >
- <Select
- data-action="settings-sounds-permissions"
- options={soundOptions}
- current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
- value={(o) => o.id}
- label={(o) => language.t(o.label)}
- onHighlight={(option) => {
- if (!option) return
- playDemoSound(option.src)
- }}
- onSelect={(option) => {
- if (!option) return
- settings.sounds.setPermissions(option.id)
- playDemoSound(option.src)
- }}
- variant="secondary"
- size="small"
- triggerVariant="settings"
- />
- </SettingsRow>
- <SettingsRow
- title={language.t("settings.general.sounds.errors.title")}
- description={language.t("settings.general.sounds.errors.description")}
- >
- <Select
- data-action="settings-sounds-errors"
- options={soundOptions}
- current={soundOptions.find((o) => o.id === settings.sounds.errors())}
- value={(o) => o.id}
- label={(o) => language.t(o.label)}
- onHighlight={(option) => {
- if (!option) return
- playDemoSound(option.src)
- }}
- onSelect={(option) => {
- if (!option) return
- settings.sounds.setErrors(option.id)
- playDemoSound(option.src)
- }}
- variant="secondary"
- size="small"
- triggerVariant="settings"
- />
- </SettingsRow>
- </div>
- </div>
- {/* Updates Section */}
- <div class="flex flex-col gap-1">
- <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
- <div class="bg-surface-raised-base px-4 rounded-lg">
- <SettingsRow
- title={language.t("settings.updates.row.startup.title")}
- description={language.t("settings.updates.row.startup.description")}
- >
- <div data-action="settings-updates-startup">
- <Switch
- checked={settings.updates.startup()}
- disabled={!platform.checkUpdate}
- onChange={(checked) => settings.updates.setStartup(checked)}
- />
- </div>
- </SettingsRow>
- <SettingsRow
- title={language.t("settings.general.row.releaseNotes.title")}
- description={language.t("settings.general.row.releaseNotes.description")}
- >
- <div data-action="settings-release-notes">
- <Switch
- checked={settings.general.releaseNotes()}
- onChange={(checked) => settings.general.setReleaseNotes(checked)}
- />
- </div>
- </SettingsRow>
- <SettingsRow
- title={language.t("settings.updates.row.check.title")}
- description={language.t("settings.updates.row.check.description")}
- >
- <Button
- size="small"
- variant="secondary"
- disabled={store.checking || !platform.checkUpdate}
- onClick={check}
- >
- {store.checking
- ? language.t("settings.updates.action.checking")
- : language.t("settings.updates.action.checkNow")}
- </Button>
- </SettingsRow>
- </div>
- </div>
- <Show when={linux()}>
- {(_) => {
- const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
- const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
- const onChange = (checked: boolean) =>
- platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
- return (
- <div class="flex flex-col gap-1">
- <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
- <div class="bg-surface-raised-base px-4 rounded-lg">
- <SettingsRow
- title={
- <div class="flex items-center gap-2">
- <span>{language.t("settings.general.row.wayland.title")}</span>
- <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
- <span class="text-text-weak">
- <Icon name="help" size="small" />
- </span>
- </Tooltip>
- </div>
- }
- description={language.t("settings.general.row.wayland.description")}
- >
- <div data-action="settings-wayland">
- <Switch checked={value() === "wayland"} onChange={onChange} />
- </div>
- </SettingsRow>
- </div>
- </div>
- )
- }}
- </Show>
- </div>
- </div>
- )
- }
- interface SettingsRowProps {
- title: string | JSX.Element
- description: string | JSX.Element
- children: JSX.Element
- }
- const SettingsRow: Component<SettingsRowProps> = (props) => {
- return (
- <div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
- <div class="flex flex-col gap-0.5 min-w-0">
- <span class="text-14-medium text-text-strong">{props.title}</span>
- <span class="text-12-regular text-text-weak">{props.description}</span>
- </div>
- <div class="flex-shrink-0">{props.children}</div>
- </div>
- )
- }
|