Skip to content

Commit 8c81f9a

Browse files
authored
refactor(app): extract v2 settings controllers (anomalyco#39228)
1 parent 95636bd commit 8c81f9a

4 files changed

Lines changed: 554 additions & 410 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { onCleanup } from "solid-js"
2+
3+
export type ShellOption = {
4+
path: string
5+
name: string
6+
acceptable: boolean
7+
}
8+
9+
export type ShellSelectOption = {
10+
id: string
11+
value: string
12+
name: string
13+
terminalOnly: boolean
14+
}
15+
16+
export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) {
17+
const counts = input.shells.reduce((result, shell) => {
18+
result.set(shell.name, (result.get(shell.name) ?? 0) + 1)
19+
return result
20+
}, new Map<string, number>())
21+
const options: ShellSelectOption[] = [
22+
{ id: "auto", value: "", name: "", terminalOnly: false },
23+
...input.shells.map((shell) => {
24+
const ambiguous = (counts.get(shell.name) ?? 0) > 1
25+
const name = ambiguous ? shell.path : shell.name
26+
return {
27+
id: shell.path,
28+
value: ambiguous ? shell.path : shell.name,
29+
name,
30+
terminalOnly: !shell.acceptable,
31+
}
32+
}),
33+
]
34+
if (input.current && !options.some((option) => option.value === input.current)) {
35+
options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false })
36+
}
37+
return options
38+
}
39+
40+
export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) {
41+
let cleanup: (() => void) | undefined
42+
let timeout: ReturnType<typeof setTimeout> | undefined
43+
let run = 0
44+
45+
const stop = () => {
46+
run += 1
47+
cleanup?.()
48+
clearTimeout(timeout)
49+
cleanup = undefined
50+
timeout = undefined
51+
}
52+
const play = (id: string | undefined) => {
53+
stop()
54+
if (!id) return
55+
const current = ++run
56+
timeout = setTimeout(() => {
57+
timeout = undefined
58+
void player(id).then((next) => {
59+
if (run === current) {
60+
cleanup = next
61+
return
62+
}
63+
next?.()
64+
})
65+
}, 100)
66+
}
67+
68+
onCleanup(stop)
69+
return { play, stop }
70+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, test, vi } from "bun:test"
2+
import { createRoot } from "solid-js"
3+
import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
4+
5+
describe("settings v2 controllers", () => {
6+
test("normalizes shell names and preserves an unavailable configured shell", () => {
7+
expect(
8+
createShellOptions({
9+
shells: [
10+
{ path: "/bin/bash", name: "bash", acceptable: true },
11+
{ path: "/opt/bash", name: "bash", acceptable: false },
12+
{ path: "/bin/zsh", name: "zsh", acceptable: true },
13+
],
14+
current: "fish",
15+
}),
16+
).toEqual([
17+
{ id: "auto", value: "", name: "", terminalOnly: false },
18+
{ id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false },
19+
{ id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true },
20+
{ id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false },
21+
{ id: "fish", value: "fish", name: "fish", terminalOnly: false },
22+
])
23+
})
24+
25+
test("debounces previews and stops owned audio on disposal", async () => {
26+
vi.useFakeTimers()
27+
try {
28+
const played: string[] = []
29+
const stopped: string[] = []
30+
const owned = createRoot((dispose) => ({
31+
dispose,
32+
preview: createSoundPreviewController(async (id) => {
33+
played.push(id ?? "")
34+
return () => stopped.push(id ?? "")
35+
}),
36+
}))
37+
38+
owned.preview.play("first")
39+
vi.advanceTimersByTime(99)
40+
expect(played).toEqual([])
41+
42+
owned.preview.play("second")
43+
vi.advanceTimersByTime(100)
44+
await Promise.resolve()
45+
expect(played).toEqual(["second"])
46+
47+
owned.dispose()
48+
expect(stopped).toEqual(["second"])
49+
} finally {
50+
vi.useRealTimers()
51+
}
52+
})
53+
})
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
2+
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
3+
import { useTheme } from "@opencode-ai/ui/theme/context"
4+
import { usePermission } from "@/context/permission"
5+
import { useServerSDK } from "@/context/server-sdk"
6+
import { useServerSync } from "@/context/server-sync"
7+
import {
8+
monoDefault,
9+
monoFontFamily,
10+
monoInput,
11+
sansDefault,
12+
sansFontFamily,
13+
sansInput,
14+
terminalDefault,
15+
terminalFontFamily,
16+
terminalInput,
17+
useSettings,
18+
} from "@/context/settings"
19+
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
20+
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
21+
22+
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
23+
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
24+
25+
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
26+
const permission = usePermission()
27+
const serverSync = useServerSync()
28+
const directory = createMemo(() => {
29+
const id = sessionID()
30+
if (!id) return undefined
31+
return serverSync().session.lineage.peek(id)?.session.directory
32+
})
33+
34+
return {
35+
accepting: createMemo(() => {
36+
const id = sessionID()
37+
const dir = directory()
38+
if (!id || !dir) return false
39+
return permission.isAutoAccepting(id, dir)
40+
}),
41+
enabled: createMemo(() => !!directory()),
42+
set: (checked: boolean) => {
43+
const id = sessionID()
44+
const dir = directory()
45+
if (!id || !dir) return
46+
if (checked) return permission.enableAutoAccept(id, dir)
47+
permission.disableAutoAccept(id, dir)
48+
},
49+
}
50+
}
51+
52+
export function createShellSettingsController() {
53+
const serverSdk = useServerSDK()
54+
const serverSync = useServerSync()
55+
const [shells] = createResource(
56+
async () => {
57+
const sdk = serverSdk()
58+
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
59+
return [] as ShellOption[]
60+
},
61+
{ initialValue: [] as ShellOption[] },
62+
)
63+
const current = createMemo(() => serverSync().data.config.shell ?? "")
64+
65+
return {
66+
shells: () => shells.latest,
67+
current,
68+
select: (value: string) => {
69+
if (value === current()) return
70+
void serverSync().updateConfig({ shell: value })
71+
},
72+
}
73+
}
74+
75+
export function createAppearanceSettingsController() {
76+
const settings = useSettings()
77+
const theme = useTheme()
78+
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
79+
80+
onMount(() => void theme.loadThemes())
81+
82+
return {
83+
scheme: {
84+
current: theme.colorScheme,
85+
select: (value: ColorScheme) => theme.setColorScheme(value),
86+
},
87+
theme: {
88+
options: themes,
89+
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
90+
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
91+
},
92+
fonts: {
93+
ui: createMemo(() => ({
94+
value: sansInput(settings.appearance.uiFont()),
95+
family: sansFontFamily(settings.appearance.uiFont()),
96+
placeholder: sansDefault,
97+
})),
98+
code: createMemo(() => ({
99+
value: monoInput(settings.appearance.font()),
100+
family: monoFontFamily(settings.appearance.font()),
101+
placeholder: monoDefault,
102+
})),
103+
terminal: createMemo(() => ({
104+
value: terminalInput(settings.appearance.terminalFont()),
105+
family: terminalFontFamily(settings.appearance.terminalFont()),
106+
placeholder: terminalDefault,
107+
})),
108+
setUI: (value: string) => settings.appearance.setUIFont(value),
109+
setCode: (value: string) => settings.appearance.setFont(value),
110+
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
111+
},
112+
}
113+
}
114+
115+
const noneSound = { id: "none", label: "sound.option.none" } as const
116+
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
117+
export type SoundSelectOption = (typeof soundOptions)[number]
118+
119+
export function createSoundSettingsController() {
120+
const settings = useSettings()
121+
const preview = createSoundPreviewController(playSoundById)
122+
const channel = (
123+
enabled: Accessor<boolean>,
124+
current: Accessor<string>,
125+
setEnabled: (value: boolean) => void,
126+
set: (id: string) => void,
127+
) => ({
128+
current: createMemo(() =>
129+
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
130+
),
131+
highlight: (option: SoundSelectOption | undefined) => {
132+
if (!option) return
133+
preview.play(option.id === "none" ? undefined : option.id)
134+
},
135+
select: (option: SoundSelectOption | null) => {
136+
if (!option) return
137+
if (option.id === "none") {
138+
setEnabled(false)
139+
preview.stop()
140+
return
141+
}
142+
setEnabled(true)
143+
set(option.id)
144+
preview.play(option.id)
145+
},
146+
})
147+
148+
return {
149+
agent: channel(
150+
settings.sounds.agentEnabled,
151+
settings.sounds.agent,
152+
(value) => settings.sounds.setAgentEnabled(value),
153+
(id) => settings.sounds.setAgent(id),
154+
),
155+
permissions: channel(
156+
settings.sounds.permissionsEnabled,
157+
settings.sounds.permissions,
158+
(value) => settings.sounds.setPermissionsEnabled(value),
159+
(id) => settings.sounds.setPermissions(id),
160+
),
161+
errors: channel(
162+
settings.sounds.errorsEnabled,
163+
settings.sounds.errors,
164+
(value) => settings.sounds.setErrorsEnabled(value),
165+
(id) => settings.sounds.setErrors(id),
166+
),
167+
}
168+
}
169+
170+
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
171+
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
172+
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
173+
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>

0 commit comments

Comments
 (0)