mirror of
https://github.com/aljazceru/opencode.git
synced 2026-01-01 07:04:20 +01:00
feat: add desktop/web app package (#2606)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
4
packages/app/src/context/index.ts
Normal file
4
packages/app/src/context/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { LocalProvider, useLocal } from "./local"
|
||||
export { SDKProvider, useSDK } from "./sdk"
|
||||
export { SyncProvider, useSync } from "./sync"
|
||||
export { ThemeProvider, useTheme } from "./theme"
|
||||
409
packages/app/src/context/local.tsx
Normal file
409
packages/app/src/context/local.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
|
||||
import { useSync } from "./sync"
|
||||
import { uniqueBy } from "remeda"
|
||||
import type { FileContent, FileNode } from "@opencode-ai/sdk"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
export type LocalFile = FileNode &
|
||||
Partial<{
|
||||
loaded: boolean
|
||||
pinned: boolean
|
||||
expanded: boolean
|
||||
content: FileContent
|
||||
selection: { startLine: number; startChar: number; endLine: number; endChar: number }
|
||||
scrollTop: number
|
||||
view: "raw" | "diff-unified" | "diff-split"
|
||||
folded: string[]
|
||||
selectedChange: number
|
||||
}>
|
||||
export type TextSelection = LocalFile["selection"]
|
||||
export type View = LocalFile["view"]
|
||||
|
||||
function init() {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const agent = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
current: agents()[0].name,
|
||||
})
|
||||
return {
|
||||
current() {
|
||||
return agents().find((x) => x.name === store.current)!
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
let next = agents().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
model: Record<
|
||||
string,
|
||||
{
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
>
|
||||
recent: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
}>({
|
||||
model: {},
|
||||
recent: [],
|
||||
})
|
||||
|
||||
const value = localStorage.getItem("model")
|
||||
setStore("recent", JSON.parse(value ?? "[]"))
|
||||
createEffect(() => {
|
||||
localStorage.setItem("model", JSON.stringify(store.recent))
|
||||
})
|
||||
|
||||
const fallback = createMemo(() => {
|
||||
if (store.recent.length) return store.recent[0]
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
|
||||
})
|
||||
|
||||
return {
|
||||
current,
|
||||
recent() {
|
||||
return store.recent
|
||||
},
|
||||
parsed: createMemo(() => {
|
||||
const value = current()
|
||||
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
|
||||
const model = provider.models[value.modelID]
|
||||
return {
|
||||
provider: provider.name ?? value.providerID,
|
||||
model: model.name ?? value.modelID,
|
||||
}
|
||||
}),
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model)
|
||||
if (options?.recent) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const file = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
node: Record<string, LocalFile>
|
||||
opened: string[]
|
||||
active?: string
|
||||
}>({
|
||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
opened: [],
|
||||
})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (!store.active) return undefined
|
||||
return store.node[store.active]
|
||||
})
|
||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||
const changes = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const status = (path: string) => sync.data.changes.find((f) => f.path === path)
|
||||
|
||||
const changed = (path: string) => {
|
||||
const set = changes()
|
||||
if (set.has(path)) return true
|
||||
for (const p of set) {
|
||||
if (p.startsWith(path ? path + "/" : "")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const resetNode = (path: string) => {
|
||||
setStore("node", path, {
|
||||
loaded: undefined,
|
||||
pinned: undefined,
|
||||
content: undefined,
|
||||
selection: undefined,
|
||||
scrollTop: undefined,
|
||||
folded: undefined,
|
||||
view: undefined,
|
||||
selectedChange: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const load = async (path: string) =>
|
||||
sdk.file.read({ query: { path } }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
path,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.content = x.data
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const open = async (path: string) => {
|
||||
const relative = path.replace(sync.data.path.directory + "/", "")
|
||||
if (!store.node[relative]) {
|
||||
const parent = relative.split("/").slice(0, -1).join("/")
|
||||
if (parent) {
|
||||
await list(parent)
|
||||
}
|
||||
}
|
||||
setStore("opened", (x) => {
|
||||
if (x.includes(relative)) return x
|
||||
return [
|
||||
...opened()
|
||||
.filter((x) => x.pinned)
|
||||
.map((x) => x.path),
|
||||
relative,
|
||||
]
|
||||
})
|
||||
setStore("active", relative)
|
||||
if (store.node[relative].loaded) return
|
||||
return load(relative)
|
||||
}
|
||||
|
||||
const list = async (path: string) => {
|
||||
return sdk.file.list({ query: { path: path + "/" } }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
produce((draft) => {
|
||||
x.data!.forEach((node) => {
|
||||
if (node.path in draft) return
|
||||
draft[node.path] = node
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
switch (event.type) {
|
||||
case "message.part.updated":
|
||||
const part = event.properties.part
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
switch (part.tool) {
|
||||
case "read":
|
||||
console.log("read", part.state.input)
|
||||
break
|
||||
case "edit":
|
||||
const absolute = part.state.input["filePath"] as string
|
||||
const path = absolute.replace(sync.data.path.directory + "/", "")
|
||||
load(path)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
opened,
|
||||
node: (path: string) => store.node[path],
|
||||
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
|
||||
open,
|
||||
load,
|
||||
close(path: string) {
|
||||
setStore("opened", (opened) => opened.filter((x) => x !== path))
|
||||
if (store.active === path) {
|
||||
const index = store.opened.findIndex((f) => f === path)
|
||||
const previous = store.opened[Math.max(0, index - 1)]
|
||||
setStore("active", previous)
|
||||
}
|
||||
resetNode(path)
|
||||
},
|
||||
expand(path: string) {
|
||||
setStore("node", path, "expanded", true)
|
||||
if (store.node[path].loaded) return
|
||||
setStore("node", path, "loaded", true)
|
||||
list(path)
|
||||
},
|
||||
collapse(path: string) {
|
||||
setStore("node", path, "expanded", false)
|
||||
},
|
||||
select(path: string, selection: TextSelection | undefined) {
|
||||
setStore("node", path, "selection", selection)
|
||||
},
|
||||
scroll(path: string, scrollTop: number) {
|
||||
setStore("node", path, "scrollTop", scrollTop)
|
||||
},
|
||||
move(path: string, to: number) {
|
||||
const index = store.opened.findIndex((f) => f === path)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"opened",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
setStore("node", path, "pinned", true)
|
||||
},
|
||||
view(path: string): View {
|
||||
const n = store.node[path]
|
||||
return n && n.view ? n.view : "raw"
|
||||
},
|
||||
setView(path: string, view: View) {
|
||||
setStore("node", path, "view", view)
|
||||
},
|
||||
unfold(path: string, key: string) {
|
||||
setStore("node", path, "folded", (xs) => {
|
||||
const a = xs ?? []
|
||||
if (a.includes(key)) return a
|
||||
return [...a, key]
|
||||
})
|
||||
},
|
||||
fold(path: string, key: string) {
|
||||
setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key))
|
||||
},
|
||||
folded(path: string) {
|
||||
const n = store.node[path]
|
||||
return n && n.folded ? n.folded : []
|
||||
},
|
||||
changeIndex(path: string) {
|
||||
return store.node[path]?.selectedChange
|
||||
},
|
||||
setChangeIndex(path: string, index: number | undefined) {
|
||||
setStore("node", path, "selectedChange", index)
|
||||
},
|
||||
changed,
|
||||
status,
|
||||
children(path: string) {
|
||||
return Object.values(store.node).filter(
|
||||
(x) =>
|
||||
x.path.startsWith(path) &&
|
||||
x.path !== path &&
|
||||
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
|
||||
)
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const layout = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
rightPane: boolean
|
||||
leftWidth: number
|
||||
rightWidth: number
|
||||
}>({
|
||||
rightPane: false,
|
||||
leftWidth: 200, // Default 50 * 4px (w-50 = 12.5rem = 200px)
|
||||
rightWidth: 320, // Default 80 * 4px (w-80 = 20rem = 320px)
|
||||
})
|
||||
|
||||
const value = localStorage.getItem("layout")
|
||||
if (value) {
|
||||
const v = JSON.parse(value)
|
||||
if (typeof v?.rightPane === "boolean") setStore("rightPane", v.rightPane)
|
||||
if (typeof v?.leftWidth === "number") setStore("leftWidth", Math.max(150, Math.min(400, v.leftWidth)))
|
||||
if (typeof v?.rightWidth === "number") setStore("rightWidth", Math.max(200, Math.min(500, v.rightWidth)))
|
||||
}
|
||||
createEffect(() => {
|
||||
localStorage.setItem("layout", JSON.stringify(store))
|
||||
})
|
||||
|
||||
return {
|
||||
rightPane() {
|
||||
return store.rightPane
|
||||
},
|
||||
leftWidth() {
|
||||
return store.leftWidth
|
||||
},
|
||||
rightWidth() {
|
||||
return store.rightWidth
|
||||
},
|
||||
toggleRightPane() {
|
||||
setStore("rightPane", (x) => !x)
|
||||
},
|
||||
openRightPane() {
|
||||
setStore("rightPane", true)
|
||||
},
|
||||
closeRightPane() {
|
||||
setStore("rightPane", false)
|
||||
},
|
||||
setLeftWidth(width: number) {
|
||||
setStore("leftWidth", Math.max(150, Math.min(400, width)))
|
||||
},
|
||||
setRightWidth(width: number) {
|
||||
setStore("rightWidth", Math.max(200, Math.min(500, width)))
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const session = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
active?: string
|
||||
}>({})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (!store.active) return undefined
|
||||
return sync.session.get(store.active)
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
setActive(sessionId: string | undefined) {
|
||||
setStore("active", sessionId)
|
||||
},
|
||||
clearActive() {
|
||||
setStore("active", undefined)
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
file,
|
||||
layout,
|
||||
session,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type LocalContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<LocalContext>()
|
||||
|
||||
export function LocalProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useLocal() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useLocal must be used within a LocalProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
29
packages/app/src/context/sdk.tsx
Normal file
29
packages/app/src/context/sdk.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/client"
|
||||
|
||||
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
|
||||
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
|
||||
|
||||
function init() {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: `http://${host}:${port}`,
|
||||
})
|
||||
return client
|
||||
}
|
||||
|
||||
type SDKContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<SDKContext>()
|
||||
|
||||
export function SDKProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useSDK() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useSDK must be used within a SDKProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
165
packages/app/src/context/sync.tsx
Normal file
165
packages/app/src/context/sync.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode } from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useSDK } from "./sdk"
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
||||
import { Binary } from "@/utils/binary"
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore<{
|
||||
ready: boolean
|
||||
provider: Provider[]
|
||||
agent: Agent[]
|
||||
config: Config
|
||||
path: Path
|
||||
session: Session[]
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
node: FileNode[]
|
||||
changes: File[]
|
||||
}>({
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "" },
|
||||
ready: false,
|
||||
agent: [],
|
||||
provider: [],
|
||||
session: [],
|
||||
message: {},
|
||||
part: {},
|
||||
node: [],
|
||||
changes: [],
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
switch (event.type) {
|
||||
case "session.updated": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Promise.all([
|
||||
sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
|
||||
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
sdk.session.list().then((x) =>
|
||||
setStore(
|
||||
"session",
|
||||
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
),
|
||||
),
|
||||
sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
sdk.file.status().then((x) => setStore("changes", x.data!)),
|
||||
sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
|
||||
]).then(() => setStore("ready", true))
|
||||
|
||||
return {
|
||||
data: store,
|
||||
set: setStore,
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
const [session, messages] = await Promise.all([
|
||||
sdk.session.get({ path: { id: sessionID } }),
|
||||
sdk.session.messages({ path: { id: sessionID } }),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
draft.session[match.index] = session.data!
|
||||
draft.message[sessionID] = messages
|
||||
.data!.map((x) => x.info)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SyncContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<SyncContext>()
|
||||
|
||||
export function SyncProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return (
|
||||
<Show when={value.data.ready}>
|
||||
<ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSync() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useSync must be used within a SyncProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
92
packages/app/src/context/theme.tsx
Normal file
92
packages/app/src/context/theme.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
createSignal,
|
||||
createEffect,
|
||||
onMount,
|
||||
type ParentComponent,
|
||||
onCleanup,
|
||||
} from "solid-js"
|
||||
|
||||
export interface ThemeContextValue {
|
||||
theme: string | undefined
|
||||
isDark: boolean
|
||||
setTheme: (themeName: string) => void
|
||||
setDarkMode: (isDark: boolean) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>()
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface ThemeProviderProps {
|
||||
defaultTheme?: string
|
||||
defaultDarkMode?: boolean
|
||||
}
|
||||
|
||||
const themes = ["opencode", "tokyonight", "ayu", "nord", "catppuccin"]
|
||||
|
||||
export const ThemeProvider: ParentComponent<ThemeProviderProps> = (props) => {
|
||||
const [theme, setThemeSignal] = createSignal<string | undefined>()
|
||||
const [isDark, setIsDark] = createSignal(props.defaultDarkMode ?? false)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "t" && event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
const current = theme()
|
||||
if (!current) return
|
||||
const index = themes.indexOf(current)
|
||||
const next = themes[(index + 1) % themes.length]
|
||||
setTheme(next)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const savedTheme = localStorage.getItem("theme") ?? "opencode"
|
||||
const savedDarkMode = localStorage.getItem("darkMode") ?? "true"
|
||||
setIsDark(savedDarkMode === "true")
|
||||
setTheme(savedTheme)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const currentTheme = theme()
|
||||
const darkMode = isDark()
|
||||
if (currentTheme) {
|
||||
document.documentElement.setAttribute("data-theme", currentTheme)
|
||||
document.documentElement.setAttribute("data-dark", darkMode.toString())
|
||||
}
|
||||
})
|
||||
|
||||
const setTheme = async (theme: string) => {
|
||||
setThemeSignal(theme)
|
||||
localStorage.setItem("theme", theme)
|
||||
}
|
||||
|
||||
const setDarkMode = (dark: boolean) => {
|
||||
setIsDark(dark)
|
||||
localStorage.setItem("darkMode", dark.toString())
|
||||
}
|
||||
|
||||
const contextValue: ThemeContextValue = {
|
||||
theme: theme(),
|
||||
isDark: isDark(),
|
||||
setTheme,
|
||||
setDarkMode,
|
||||
}
|
||||
|
||||
return <ThemeContext.Provider value={contextValue}>{props.children}</ThemeContext.Provider>
|
||||
}
|
||||
Reference in New Issue
Block a user