chore(app): createStore over signals

This commit is contained in:
adamelmore
2026-01-26 10:04:59 -06:00
parent 37f1a1a4ef
commit d05ed5ca83
10 changed files with 294 additions and 218 deletions

View File

@@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, createSignal, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
@@ -29,35 +29,34 @@ export function DialogEditProject(props: { project: LocalProject }) {
iconUrl: props.project.icon?.override || "", iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "", startup: props.project.commands?.start ?? "",
saving: false, saving: false,
dragOver: false,
iconHover: false,
}) })
const [dragOver, setDragOver] = createSignal(false)
const [iconHover, setIconHover] = createSignal(false)
function handleFileSelect(file: File) { function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return if (!file.type.startsWith("image/")) return
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
setStore("iconUrl", e.target?.result as string) setStore("iconUrl", e.target?.result as string)
setIconHover(false) setStore("iconHover", false)
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
function handleDrop(e: DragEvent) { function handleDrop(e: DragEvent) {
e.preventDefault() e.preventDefault()
setDragOver(false) setStore("dragOver", false)
const file = e.dataTransfer?.files[0] const file = e.dataTransfer?.files[0]
if (file) handleFileSelect(file) if (file) handleFileSelect(file)
} }
function handleDragOver(e: DragEvent) { function handleDragOver(e: DragEvent) {
e.preventDefault() e.preventDefault()
setDragOver(true) setStore("dragOver", true)
} }
function handleDragLeave() { function handleDragLeave() {
setDragOver(false) setStore("dragOver", false)
} }
function handleInputChange(e: Event) { function handleInputChange(e: Event) {
@@ -116,19 +115,23 @@ export function DialogEditProject(props: { project: LocalProject }) {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label> <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
<div class="flex gap-3 items-start"> <div class="flex gap-3 items-start">
<div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}> <div
class="relative"
onMouseEnter={() => setStore("iconHover", true)}
onMouseLeave={() => setStore("iconHover", false)}
>
<div <div
class="relative size-16 rounded-md transition-colors cursor-pointer" class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{ classList={{
"border-text-interactive-base bg-surface-info-base/20": dragOver(), "border-text-interactive-base bg-surface-info-base/20": store.dragOver,
"border-border-base hover:border-border-strong": !dragOver(), "border-border-base hover:border-border-strong": !store.dragOver,
"overflow-hidden": !!store.iconUrl, "overflow-hidden": !!store.iconUrl,
}} }}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onClick={() => { onClick={() => {
if (store.iconUrl && iconHover()) { if (store.iconUrl && store.iconHover) {
clearIcon() clearIcon()
} else { } else {
document.getElementById("icon-upload")?.click() document.getElementById("icon-upload")?.click()
@@ -166,7 +169,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
"border-radius": "6px", "border-radius": "6px",
"z-index": 10, "z-index": 10,
"pointer-events": "none", "pointer-events": "none",
opacity: iconHover() && !store.iconUrl ? 1 : 0, opacity: store.iconHover && !store.iconUrl ? 1 : 0,
display: "flex", display: "flex",
"align-items": "center", "align-items": "center",
"justify-content": "center", "justify-content": "center",
@@ -185,7 +188,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
"border-radius": "6px", "border-radius": "6px",
"z-index": 10, "z-index": 10,
"pointer-events": "none", "pointer-events": "none",
opacity: iconHover() && store.iconUrl ? 1 : 0, opacity: store.iconHover && store.iconUrl ? 1 : 0,
display: "flex", display: "flex",
"align-items": "center", "align-items": "center",
"justify-content": "center", "justify-content": "center",

View File

@@ -1,5 +1,6 @@
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { createSignal, Show } from "solid-js" import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd" import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
@@ -12,11 +13,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
const terminal = useTerminal() const terminal = useTerminal()
const language = useLanguage() const language = useLanguage()
const sortable = createSortable(props.terminal.id) const sortable = createSortable(props.terminal.id)
const [editing, setEditing] = createSignal(false) const [store, setStore] = createStore({
const [title, setTitle] = createSignal(props.terminal.title) editing: false,
const [menuOpen, setMenuOpen] = createSignal(false) title: props.terminal.title,
const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 }) menuOpen: false,
const [blurEnabled, setBlurEnabled] = createSignal(false) menuPosition: { x: 0, y: 0 },
blurEnabled: false,
})
const isDefaultTitle = () => { const isDefaultTitle = () => {
const number = props.terminal.titleNumber const number = props.terminal.titleNumber
@@ -47,7 +50,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
} }
const focus = () => { const focus = () => {
if (editing()) return if (store.editing) return
if (document.activeElement instanceof HTMLElement) { if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur() document.activeElement.blur()
@@ -71,26 +74,26 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
e.preventDefault() e.preventDefault()
} }
setBlurEnabled(false) setStore("blurEnabled", false)
setTitle(props.terminal.title) setStore("title", props.terminal.title)
setEditing(true) setStore("editing", true)
setTimeout(() => { setTimeout(() => {
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
if (!input) return if (!input) return
input.focus() input.focus()
input.select() input.select()
setTimeout(() => setBlurEnabled(true), 100) setTimeout(() => setStore("blurEnabled", true), 100)
}, 10) }, 10)
} }
const save = () => { const save = () => {
if (!blurEnabled()) return if (!store.blurEnabled) return
const value = title().trim() const value = store.title.trim()
if (value && value !== props.terminal.title) { if (value && value !== props.terminal.title) {
terminal.update({ id: props.terminal.id, title: value }) terminal.update({ id: props.terminal.id, title: value })
} }
setEditing(false) setStore("editing", false)
} }
const keydown = (e: KeyboardEvent) => { const keydown = (e: KeyboardEvent) => {
@@ -101,14 +104,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
} }
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
setEditing(false) setStore("editing", false)
} }
} }
const menu = (e: MouseEvent) => { const menu = (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
setMenuPosition({ x: e.clientX, y: e.clientY }) setStore("menuPosition", { x: e.clientX, y: e.clientY })
setMenuOpen(true) setStore("menuOpen", true)
} }
return ( return (
@@ -143,17 +146,17 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
/> />
} }
> >
<span onDblClick={edit} style={{ visibility: editing() ? "hidden" : "visible" }}> <span onDblClick={edit} style={{ visibility: store.editing ? "hidden" : "visible" }}>
{label()} {label()}
</span> </span>
</Tabs.Trigger> </Tabs.Trigger>
<Show when={editing()}> <Show when={store.editing}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto"> <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input <input
id={`terminal-title-input-${props.terminal.id}`} id={`terminal-title-input-${props.terminal.id}`}
type="text" type="text"
value={title()} value={store.title}
onInput={(e) => setTitle(e.currentTarget.value)} onInput={(e) => setStore("title", e.currentTarget.value)}
onBlur={save} onBlur={save}
onKeyDown={keydown} onKeyDown={keydown}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
@@ -161,13 +164,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
/> />
</div> </div>
</Show> </Show>
<DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}> <DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content <DropdownMenu.Content
style={{ style={{
position: "fixed", position: "fixed",
left: `${menuPosition().x}px`, left: `${store.menuPosition.x}px`,
top: `${menuPosition().y}px`, top: `${store.menuPosition.y}px`,
}} }}
> >
<DropdownMenu.Item onSelect={edit}> <DropdownMenu.Item onSelect={edit}>

View File

@@ -1,4 +1,5 @@
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -111,24 +112,26 @@ export const SettingsKeybinds: Component = () => {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const [active, setActive] = createSignal<string | null>(null) const [store, setStore] = createStore({
const [filter, setFilter] = createSignal("") active: null as string | null,
filter: "",
})
const stop = () => { const stop = () => {
if (!active()) return if (!store.active) return
setActive(null) setStore("active", null)
command.keybinds(true) command.keybinds(true)
} }
const start = (id: string) => { const start = (id: string) => {
if (active() === id) { if (store.active === id) {
stop() stop()
return return
} }
if (active()) stop() if (store.active) stop()
setActive(id) setStore("active", id)
command.keybinds(false) command.keybinds(false)
} }
@@ -203,7 +206,7 @@ export const SettingsKeybinds: Component = () => {
}) })
const filtered = createMemo(() => { const filtered = createMemo(() => {
const query = filter().toLowerCase().trim() const query = store.filter.toLowerCase().trim()
if (!query) return grouped() if (!query) return grouped()
const map = list() const map = list()
@@ -285,7 +288,7 @@ export const SettingsKeybinds: Component = () => {
onMount(() => { onMount(() => {
const handle = (event: KeyboardEvent) => { const handle = (event: KeyboardEvent) => {
const id = active() const id = store.active
if (!id) return if (!id) return
event.preventDefault() event.preventDefault()
@@ -345,7 +348,7 @@ export const SettingsKeybinds: Component = () => {
}) })
onCleanup(() => { onCleanup(() => {
if (active()) command.keybinds(true) if (store.active) command.keybinds(true)
}) })
return ( return (
@@ -370,8 +373,8 @@ export const SettingsKeybinds: Component = () => {
<TextField <TextField
variant="ghost" variant="ghost"
type="text" type="text"
value={filter()} value={store.filter}
onChange={setFilter} onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")} placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false} spellcheck={false}
autocorrect="off" autocorrect="off"
@@ -379,8 +382,8 @@ export const SettingsKeybinds: Component = () => {
autocapitalize="off" autocapitalize="off"
class="flex-1" class="flex-1"
/> />
<Show when={filter()}> <Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} /> <IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show> </Show>
</div> </div>
</div> </div>
@@ -402,13 +405,13 @@ export const SettingsKeybinds: Component = () => {
classList={{ classList={{
"h-8 px-3 rounded-md text-12-regular": true, "h-8 px-3 rounded-md text-12-regular": true,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active": "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
active() !== id, store.active !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id, "border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
}} }}
onClick={() => start(id)} onClick={() => start(id)}
> >
<Show <Show
when={active() === id} when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")} fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
> >
{language.t("settings.shortcuts.pressKeys")} {language.t("settings.shortcuts.pressKeys")}
@@ -423,11 +426,11 @@ export const SettingsKeybinds: Component = () => {
)} )}
</For> </For>
<Show when={filter() && !hasResults()}> <Show when={store.filter && !hasResults()}>
<div class="flex flex-col items-center justify-center py-12 text-center"> <div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span> <span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
<Show when={filter()}> <Show when={store.filter}>
<span class="text-14-regular text-text-strong mt-1">"{filter()}"</span> <span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
</Show> </Show>
</div> </div>
</Show> </Show>

View File

@@ -39,9 +39,10 @@ export function StatusPopover() {
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const [loading, setLoading] = createSignal<string | null>(null)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<string, ServerStatus | undefined>, status: {} as Record<string, ServerStatus | undefined>,
loading: null as string | null,
defaultServerUrl: undefined as string | undefined,
}) })
const servers = createMemo(() => { const servers = createMemo(() => {
@@ -97,8 +98,8 @@ export function StatusPopover() {
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length) const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
const toggleMcp = async (name: string) => { const toggleMcp = async (name: string) => {
if (loading()) return if (store.loading) return
setLoading(name) setStore("loading", name)
const status = sync.data.mcp[name] const status = sync.data.mcp[name]
if (status?.status === "connected") { if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name }) await sdk.client.mcp.disconnect({ name })
@@ -107,7 +108,7 @@ export function StatusPopover() {
} }
const result = await sdk.client.mcp.status() const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data) if (result.data) sync.set("mcp", result.data)
setLoading(null) setStore("loading", null)
} }
const lspItems = createMemo(() => sync.data.lsp ?? []) const lspItems = createMemo(() => sync.data.lsp ?? [])
@@ -123,19 +124,17 @@ export function StatusPopover() {
const serverCount = createMemo(() => sortedServers().length) const serverCount = createMemo(() => sortedServers().length)
const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>()
const refreshDefaultServerUrl = () => { const refreshDefaultServerUrl = () => {
const result = platform.getDefaultServerUrl?.() const result = platform.getDefaultServerUrl?.()
if (!result) { if (!result) {
setDefaultServerUrl(undefined) setStore("defaultServerUrl", undefined)
return return
} }
if (result instanceof Promise) { if (result instanceof Promise) {
result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined)) result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
return return
} }
setDefaultServerUrl(normalizeServerUrl(result)) setStore("defaultServerUrl", normalizeServerUrl(result))
} }
createEffect(() => { createEffect(() => {
@@ -220,7 +219,7 @@ export function StatusPopover() {
<For each={sortedServers()}> <For each={sortedServers()}>
{(url) => { {(url) => {
const isActive = () => url === server.url const isActive = () => url === server.url
const isDefault = () => url === defaultServerUrl() const isDefault = () => url === store.defaultServerUrl
const status = () => store.status[url] const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false const isBlocked = () => status()?.healthy === false
const [truncated, setTruncated] = createSignal(false) const [truncated, setTruncated] = createSignal(false)
@@ -329,7 +328,7 @@ export function StatusPopover() {
type="button" type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => toggleMcp(item.name)} onClick={() => toggleMcp(item.name)}
disabled={loading() === item.name} disabled={store.loading === item.name}
> >
<div <div
classList={{ classList={{
@@ -345,7 +344,7 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}> <div onClick={(event) => event.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={loading() === item.name} disabled={store.loading === item.name}
onChange={() => toggleMcp(item.name)} onChange={() => toggleMcp(item.name)}
/> />
</div> </div>

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -165,8 +165,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const dialog = useDialog() const dialog = useDialog()
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([]) const [store, setStore] = createStore({
const [suspendCount, setSuspendCount] = createSignal(0) registrations: [] as Accessor<CommandOption[]>[],
suspendCount: 0,
})
const [catalog, setCatalog, _, catalogReady] = persisted( const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"), Persist.global("command.catalog.v1"),
@@ -184,7 +186,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const seen = new Set<string>() const seen = new Set<string>()
const all: CommandOption[] = [] const all: CommandOption[] = []
for (const reg of registrations()) { for (const reg of store.registrations) {
for (const opt of reg()) { for (const opt of reg()) {
if (seen.has(opt.id)) continue if (seen.has(opt.id)) continue
seen.add(opt.id) seen.add(opt.id)
@@ -230,7 +232,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
] ]
}) })
const suspended = () => suspendCount() > 0 const suspended = () => store.suspendCount > 0
const palette = createMemo(() => { const palette = createMemo(() => {
const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
@@ -297,9 +299,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return { return {
register(cb: () => CommandOption[]) { register(cb: () => CommandOption[]) {
const results = createMemo(cb) const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr]) setStore("registrations", (arr) => [results, ...arr])
onCleanup(() => { onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results)) setStore("registrations", (arr) => arr.filter((x) => x !== results))
}) })
}, },
trigger(id: string, source?: "palette" | "keybind" | "slash") { trigger(id: string, source?: "palette" | "keybind" | "slash") {
@@ -321,7 +323,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}, },
show: showPalette, show: showPalette,
keybinds(enabled: boolean) { keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1)) setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
}, },
suspended, suspended,
get catalog() { get catalog() {

View File

@@ -1,4 +1,4 @@
import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js" import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
@@ -37,8 +37,16 @@ function createCommentSession(dir: string, id: string | undefined) {
}), }),
) )
const [focus, setFocus] = createSignal<CommentFocus | null>(null) const [state, setState] = createStore({
const [active, setActive] = createSignal<CommentFocus | null>(null) focus: null as CommentFocus | null,
active: null as CommentFocus | null,
})
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
setState("focus", value)
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
setState("active", value)
const list = (file: string) => store.comments[file] ?? [] const list = (file: string) => store.comments[file] ?? []
@@ -74,10 +82,10 @@ function createCommentSession(dir: string, id: string | undefined) {
all, all,
add, add,
remove, remove,
focus: createMemo(() => focus()), focus: createMemo(() => state.focus),
setFocus, setFocus,
clearFocus: () => setFocus(null), clearFocus: () => setFocus(null),
active: createMemo(() => active()), active: createMemo(() => state.active),
setActive, setActive,
clearActive: () => setActive(null), clearActive: () => setActive(null),
} }

View File

@@ -1,6 +1,6 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -40,12 +40,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}), }),
) )
const [active, setActiveRaw] = createSignal("") const [state, setState] = createStore({
active: "",
healthy: undefined as boolean | undefined,
})
const healthy = () => state.healthy
function setActive(input: string) { function setActive(input: string) {
const url = normalizeServerUrl(input) const url = normalizeServerUrl(input)
if (!url) return if (!url) return
setActiveRaw(url) setState("active", url)
} }
function add(input: string) { function add(input: string) {
@@ -54,7 +59,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const fallback = normalizeServerUrl(props.defaultUrl) const fallback = normalizeServerUrl(props.defaultUrl)
if (fallback && url === fallback) { if (fallback && url === fallback) {
setActiveRaw(url) setState("active", url)
return return
} }
@@ -62,7 +67,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (!store.list.includes(url)) { if (!store.list.includes(url)) {
setStore("list", store.list.length, url) setStore("list", store.list.length, url)
} }
setActiveRaw(url) setState("active", url)
}) })
} }
@@ -71,25 +76,23 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (!url) return if (!url) return
const list = store.list.filter((x) => x !== url) const list = store.list.filter((x) => x !== url)
const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active() const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
batch(() => { batch(() => {
setStore("list", list) setStore("list", list)
setActiveRaw(next) setState("active", next)
}) })
} }
createEffect(() => { createEffect(() => {
if (!ready()) return if (!ready()) return
if (active()) return if (state.active) return
const url = normalizeServerUrl(props.defaultUrl) const url = normalizeServerUrl(props.defaultUrl)
if (!url) return if (!url) return
setActiveRaw(url) setState("active", url)
}) })
const isReady = createMemo(() => ready() && !!active()) const isReady = createMemo(() => ready() && !!state.active)
const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
const check = (url: string) => { const check = (url: string) => {
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
@@ -104,10 +107,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
createEffect(() => { createEffect(() => {
const url = active() const url = state.active
if (!url) return if (!url) return
setHealthy(undefined) setState("healthy", undefined)
let alive = true let alive = true
let busy = false let busy = false
@@ -118,7 +121,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
void check(url) void check(url)
.then((next) => { .then((next) => {
if (!alive) return if (!alive) return
setHealthy(next) setState("healthy", next)
}) })
.finally(() => { .finally(() => {
busy = false busy = false
@@ -134,7 +137,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}) })
}) })
const origin = createMemo(() => projectsKey(active())) const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? []) const projectsList = createMemo(() => store.projects[origin()] ?? [])
const isLocal = createMemo(() => origin() === "local") const isLocal = createMemo(() => origin() === "local")
@@ -143,10 +146,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
healthy, healthy,
isLocal, isLocal,
get url() { get url() {
return active() return state.active
}, },
get name() { get name() {
return serverDisplayName(active()) return serverDisplayName(state.active)
}, },
get list() { get list() {
return store.list return store.list

View File

@@ -91,7 +91,6 @@ export default function Layout(props: ParentProps) {
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
const params = useParams() const params = useParams()
const [autoselect, setAutoselect] = createSignal(!params.dir)
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const layout = useLayout() const layout = useLayout()
@@ -117,27 +116,31 @@ export default function Layout(props: ParentProps) {
} }
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const [state, setState] = createStore({
autoselect: !params.dir,
busyWorkspaces: new Set<string>(),
hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
nav: undefined as HTMLElement | undefined,
})
const [editor, setEditor] = createStore({ const [editor, setEditor] = createStore({
active: "" as string, active: "" as string,
value: "", value: "",
}) })
const [busyWorkspaces, setBusyWorkspaces] = createSignal<Set<string>>(new Set())
const setBusy = (directory: string, value: boolean) => { const setBusy = (directory: string, value: boolean) => {
const key = workspaceKey(directory) const key = workspaceKey(directory)
setBusyWorkspaces((prev) => { setState("busyWorkspaces", (prev) => {
const next = new Set(prev) const next = new Set(prev)
if (value) next.add(key) if (value) next.add(key)
else next.delete(key) else next.delete(key)
return next return next
}) })
} }
const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory)) const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory))
const editorRef = { current: undefined as HTMLInputElement | undefined } const editorRef = { current: undefined as HTMLInputElement | undefined }
const [hoverSession, setHoverSession] = createSignal<string | undefined>()
const [hoverProject, setHoverProject] = createSignal<string | undefined>()
const [nav, setNav] = createSignal<HTMLElement | undefined>(undefined)
const navLeave = { current: undefined as number | undefined } const navLeave = { current: undefined as number | undefined }
onCleanup(() => { onCleanup(() => {
@@ -145,18 +148,18 @@ export default function Layout(props: ParentProps) {
clearTimeout(navLeave.current) clearTimeout(navLeave.current)
}) })
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && hoverProject() !== undefined) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
const hoverProjectData = createMemo(() => { const hoverProjectData = createMemo(() => {
const id = hoverProject() const id = state.hoverProject
if (!id) return if (!id) return
return layout.projects.list().find((project) => project.worktree === id) return layout.projects.list().find((project) => project.worktree === id)
}) })
createEffect(() => { createEffect(() => {
if (!layout.sidebar.opened()) return if (!layout.sidebar.opened()) return
setHoverProject(undefined) setState("hoverProject", undefined)
}) })
createEffect( createEffect(
@@ -164,9 +167,9 @@ export default function Layout(props: ParentProps) {
() => ({ dir: params.dir, id: params.id }), () => ({ dir: params.dir, id: params.id }),
() => { () => {
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
if (!hoverProject()) return if (!state.hoverProject) return
setHoverSession(undefined) setState("hoverSession", undefined)
setHoverProject(undefined) setState("hoverProject", undefined)
}, },
{ defer: true }, { defer: true },
), ),
@@ -175,7 +178,7 @@ export default function Layout(props: ParentProps) {
const autoselecting = createMemo(() => { const autoselecting = createMemo(() => {
if (params.dir) return false if (params.dir) return false
if (initialDir) return false if (initialDir) return false
if (!autoselect()) return false if (!state.autoselect) return false
if (!pageReady()) return true if (!pageReady()) return true
if (!layoutReady()) return true if (!layoutReady()) return true
const list = layout.projects.list() const list = layout.projects.list()
@@ -483,20 +486,18 @@ export default function Layout(props: ParentProps) {
} }
} }
const [scrollSessionKey, setScrollSessionKey] = createSignal<string | undefined>(undefined)
function scrollToSession(sessionId: string, sessionKey: string) { function scrollToSession(sessionId: string, sessionKey: string) {
if (!scrollContainerRef) return if (!scrollContainerRef) return
if (scrollSessionKey() === sessionKey) return if (state.scrollSessionKey === sessionKey) return
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
if (!element) return if (!element) return
const containerRect = scrollContainerRef.getBoundingClientRect() const containerRect = scrollContainerRef.getBoundingClientRect()
const elementRect = element.getBoundingClientRect() const elementRect = element.getBoundingClientRect()
if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) { if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) {
setScrollSessionKey(sessionKey) setState("scrollSessionKey", sessionKey)
return return
} }
setScrollSessionKey(sessionKey) setState("scrollSessionKey", sessionKey)
element.scrollIntoView({ block: "nearest", behavior: "smooth" }) element.scrollIntoView({ block: "nearest", behavior: "smooth" })
} }
@@ -544,7 +545,7 @@ export default function Layout(props: ParentProps) {
(value) => { (value) => {
if (!value.ready) return if (!value.ready) return
if (!value.layoutReady) return if (!value.layoutReady) return
if (!autoselect()) return if (!state.autoselect) return
if (initialDir) return if (initialDir) return
if (value.dir) return if (value.dir) return
if (value.list.length === 0) return if (value.list.length === 0) return
@@ -552,7 +553,7 @@ export default function Layout(props: ParentProps) {
const last = server.projects.last() const last = server.projects.last()
const next = value.list.find((project) => project.worktree === last) ?? value.list[0] const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
if (!next) return if (!next) return
setAutoselect(false) setState("autoselect", false)
openProject(next.worktree, false) openProject(next.worktree, false)
navigateToProject(next.worktree) navigateToProject(next.worktree)
}, },
@@ -1066,8 +1067,8 @@ export default function Layout(props: ParentProps) {
function navigateToProject(directory: string | undefined) { function navigateToProject(directory: string | undefined) {
if (!directory) return if (!directory) return
if (!layout.sidebar.opened()) { if (!layout.sidebar.opened()) {
setHoverSession(undefined) setState("hoverSession", undefined)
setHoverProject(undefined) setState("hoverProject", undefined)
} }
server.projects.touch(directory) server.projects.touch(directory)
const lastSession = store.lastSession[directory] const lastSession = store.lastSession[directory]
@@ -1078,8 +1079,8 @@ export default function Layout(props: ParentProps) {
function navigateToSession(session: Session | undefined) { function navigateToSession(session: Session | undefined) {
if (!session) return if (!session) return
if (!layout.sidebar.opened()) { if (!layout.sidebar.opened()) {
setHoverSession(undefined) setState("hoverSession", undefined)
setHoverProject(undefined) setState("hoverProject", undefined)
} }
navigate(`/${base64Encode(session.directory)}/session/${session.id}`) navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
layout.mobileSidebar.hide() layout.mobileSidebar.hide()
@@ -1472,7 +1473,7 @@ export default function Layout(props: ParentProps) {
function handleDragStart(event: unknown) { function handleDragStart(event: unknown) {
const id = getDraggableId(event) const id = getDraggableId(event)
if (!id) return if (!id) return
setHoverProject(undefined) setState("hoverProject", undefined)
setStore("activeProject", id) setStore("activeProject", id)
} }
@@ -1632,8 +1633,10 @@ export default function Layout(props: ParentProps) {
const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id) const isActive = createMemo(() => props.session.id === params.id)
const [menuOpen, setMenuOpen] = createSignal(false) const [menu, setMenu] = createStore({
const [pendingRename, setPendingRename] = createSignal(false) open: false,
pendingRename: false,
})
const messageLabel = (message: Message) => { const messageLabel = (message: Message) => {
const parts = sessionStore.part[message.id] ?? [] const parts = sessionStore.part[message.id] ?? []
@@ -1644,13 +1647,13 @@ export default function Layout(props: ParentProps) {
const item = ( const item = (
<A <A
href={`${props.slug}/session/${props.session.id}`} href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menuOpen() ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menu.open ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")} onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")} onFocus={() => prefetchSession(props.session, "high")}
onClick={() => { onClick={() => {
setHoverSession(undefined) setState("hoverSession", undefined)
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
queueMicrotask(() => setHoverProject(undefined)) queueMicrotask(() => setState("hoverProject", undefined))
}} }}
> >
<div class="flex items-center gap-1 w-full"> <div class="flex items-center gap-1 w-full">
@@ -1713,9 +1716,9 @@ export default function Layout(props: ParentProps) {
gutter={16} gutter={16}
shift={-2} shift={-2}
trigger={item} trigger={item}
mount={!props.mobile ? nav() : undefined} mount={!props.mobile ? state.nav : undefined}
open={hoverSession() === props.session.id} open={state.hoverSession === props.session.id}
onOpenChange={(open) => setHoverSession(open ? props.session.id : undefined)} onOpenChange={(open) => setState("hoverSession", open ? props.session.id : undefined)}
> >
<Show <Show
when={hoverReady()} when={hoverReady()}
@@ -1745,13 +1748,13 @@ export default function Layout(props: ParentProps) {
<div <div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`} class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{ classList={{
"opacity-100 pointer-events-auto": menuOpen(), "opacity-100 pointer-events-auto": menu.open,
"opacity-0 pointer-events-none": !menuOpen(), "opacity-0 pointer-events-none": !menu.open,
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true, "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}} }}
> >
<DropdownMenu modal={!sidebarHovering()} open={menuOpen()} onOpenChange={setMenuOpen}> <DropdownMenu modal={!sidebarHovering()} open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
<Tooltip value={language.t("common.moreOptions")} placement="top"> <Tooltip value={language.t("common.moreOptions")} placement="top">
<DropdownMenu.Trigger <DropdownMenu.Trigger
as={IconButton} as={IconButton}
@@ -1761,19 +1764,19 @@ export default function Layout(props: ParentProps) {
aria-label={language.t("common.moreOptions")} aria-label={language.t("common.moreOptions")}
/> />
</Tooltip> </Tooltip>
<DropdownMenu.Portal mount={!props.mobile ? nav() : undefined}> <DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
<DropdownMenu.Content <DropdownMenu.Content
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
if (!pendingRename()) return if (!menu.pendingRename) return
event.preventDefault() event.preventDefault()
setPendingRename(false) setMenu("pendingRename", false)
openEditor(`session:${props.session.id}`, props.session.title) openEditor(`session:${props.session.id}`, props.session.title)
}} }}
> >
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
setPendingRename(true) setMenu("pendingRename", true)
setMenuOpen(false) setMenu("open", false)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
@@ -1802,9 +1805,9 @@ export default function Layout(props: ParentProps) {
end end
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => { onClick={() => {
setHoverSession(undefined) setState("hoverSession", undefined)
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
queueMicrotask(() => setHoverProject(undefined)) queueMicrotask(() => setState("hoverProject", undefined))
}} }}
> >
<div class="flex items-center gap-1 w-full"> <div class="flex items-center gap-1 w-full">
@@ -1884,8 +1887,10 @@ export default function Layout(props: ParentProps) {
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.directory) const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
const [menuOpen, setMenuOpen] = createSignal(false) const [menu, setMenu] = createStore({
const [pendingRename, setPendingRename] = createSignal(false) open: false,
pendingRename: false,
})
const slug = createMemo(() => base64Encode(props.directory)) const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => const sessions = createMemo(() =>
workspaceStore.session workspaceStore.session
@@ -1995,13 +2000,17 @@ export default function Layout(props: ParentProps) {
<div <div
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity" class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
classList={{ classList={{
"opacity-100 pointer-events-auto": menuOpen(), "opacity-100 pointer-events-auto": menu.open,
"opacity-0 pointer-events-none": !menuOpen(), "opacity-0 pointer-events-none": !menu.open,
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true, "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true, "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
}} }}
> >
<DropdownMenu modal={!sidebarHovering()} open={menuOpen()} onOpenChange={setMenuOpen}> <DropdownMenu
modal={!sidebarHovering()}
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<Tooltip value={language.t("common.moreOptions")} placement="top"> <Tooltip value={language.t("common.moreOptions")} placement="top">
<DropdownMenu.Trigger <DropdownMenu.Trigger
as={IconButton} as={IconButton}
@@ -2011,20 +2020,20 @@ export default function Layout(props: ParentProps) {
aria-label={language.t("common.moreOptions")} aria-label={language.t("common.moreOptions")}
/> />
</Tooltip> </Tooltip>
<DropdownMenu.Portal mount={!props.mobile ? nav() : undefined}> <DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
<DropdownMenu.Content <DropdownMenu.Content
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
if (!pendingRename()) return if (!menu.pendingRename) return
event.preventDefault() event.preventDefault()
setPendingRename(false) setMenu("pendingRename", false)
openEditor(`workspace:${props.directory}`, workspaceValue()) openEditor(`workspace:${props.directory}`, workspaceValue())
}} }}
> >
<DropdownMenu.Item <DropdownMenu.Item
disabled={local()} disabled={local()}
onSelect={() => { onSelect={() => {
setPendingRename(true) setMenu("pendingRename", true)
setMenuOpen(false) setMenu("open", false)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
@@ -2103,7 +2112,7 @@ export default function Layout(props: ParentProps) {
const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
const active = createMemo(() => (preview() ? open() : overlay() && hoverProject() === props.project.worktree)) const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree))
createEffect(() => { createEffect(() => {
if (preview()) return if (preview()) return
@@ -2155,14 +2164,14 @@ export default function Layout(props: ParentProps) {
onMouseEnter={() => { onMouseEnter={() => {
if (!overlay()) return if (!overlay()) return
globalSync.child(props.project.worktree) globalSync.child(props.project.worktree)
setHoverProject(props.project.worktree) setState("hoverProject", props.project.worktree)
setHoverSession(undefined) setState("hoverSession", undefined)
}} }}
onFocus={() => { onFocus={() => {
if (!overlay()) return if (!overlay()) return
globalSync.child(props.project.worktree) globalSync.child(props.project.worktree)
setHoverProject(props.project.worktree) setState("hoverProject", props.project.worktree)
setHoverSession(undefined) setState("hoverSession", undefined)
}} }}
onClick={() => navigateToProject(props.project.worktree)} onClick={() => navigateToProject(props.project.worktree)}
onBlur={() => setOpen(false)} onBlur={() => setOpen(false)}
@@ -2184,7 +2193,7 @@ export default function Layout(props: ParentProps) {
trigger={trigger} trigger={trigger}
onOpenChange={(value) => { onOpenChange={(value) => {
setOpen(value) setOpen(value)
if (value) setHoverSession(undefined) if (value) setState("hoverSession", undefined)
}} }}
> >
<div class="-m-3 p-2 flex flex-col w-72"> <div class="-m-3 p-2 flex flex-col w-72">
@@ -2323,8 +2332,8 @@ export default function Layout(props: ParentProps) {
const createWorkspace = async (project: LocalProject) => { const createWorkspace = async (project: LocalProject) => {
if (!layout.sidebar.opened()) { if (!layout.sidebar.opened()) {
setHoverSession(undefined) setState("hoverSession", undefined)
setHoverProject(undefined) setState("hoverProject", undefined)
} }
const created = await globalSDK.client.worktree const created = await globalSDK.client.worktree
.create({ directory: project.worktree }) .create({ directory: project.worktree })
@@ -2427,7 +2436,7 @@ export default function Layout(props: ParentProps) {
class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")} aria-label={language.t("common.moreOptions")}
/> />
<DropdownMenu.Portal mount={!panelProps.mobile ? nav() : undefined}> <DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1"> <DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}> <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
@@ -2476,8 +2485,8 @@ export default function Layout(props: ParentProps) {
class="w-full" class="w-full"
onClick={() => { onClick={() => {
if (!layout.sidebar.opened()) { if (!layout.sidebar.opened()) {
setHoverSession(undefined) setState("hoverSession", undefined)
setHoverProject(undefined) setState("hoverProject", undefined)
} }
navigate(`/${base64Encode(p.worktree)}/session`) navigate(`/${base64Encode(p.worktree)}/session`)
layout.mobileSidebar.hide() layout.mobileSidebar.hide()
@@ -2668,7 +2677,7 @@ export default function Layout(props: ParentProps) {
}} }}
style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }} style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
ref={(el) => { ref={(el) => {
setNav(el) setState("nav", el)
}} }}
onMouseEnter={() => { onMouseEnter={() => {
if (navLeave.current === undefined) return if (navLeave.current === undefined) return
@@ -2681,8 +2690,8 @@ export default function Layout(props: ParentProps) {
if (navLeave.current !== undefined) clearTimeout(navLeave.current) if (navLeave.current !== undefined) clearTimeout(navLeave.current)
navLeave.current = window.setTimeout(() => { navLeave.current = window.setTimeout(() => {
navLeave.current = undefined navLeave.current = undefined
setHoverProject(undefined) setState("hoverProject", undefined)
setHoverSession(undefined) setState("hoverSession", undefined)
}, 300) }, 300)
}} }}
> >

View File

@@ -1,4 +1,4 @@
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js" import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
@@ -198,12 +198,17 @@ export default function Page() {
return next return next
}) })
const [responding, setResponding] = createSignal(false) const [ui, setUi] = createStore({
responding: false,
pendingMessage: undefined as string | undefined,
scrollGesture: 0,
autoCreated: false,
})
createEffect( createEffect(
on( on(
() => request()?.id, () => request()?.id,
() => setResponding(false), () => setUi("responding", false),
{ defer: true }, { defer: true },
), ),
) )
@@ -211,18 +216,17 @@ export default function Page() {
const decide = (response: "once" | "always" | "reject") => { const decide = (response: "once" | "always" | "reject") => {
const perm = request() const perm = request()
if (!perm) return if (!perm) return
if (responding()) return if (ui.responding) return
setResponding(true) setUi("responding", true)
sdk.client.permission sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => { .catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
}) })
.finally(() => setResponding(false)) .finally(() => setUi("responding", false))
} }
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey)) const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
@@ -439,7 +443,6 @@ export default function Page() {
let promptDock: HTMLDivElement | undefined let promptDock: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined
const [scrollGesture, setScrollGesture] = createSignal(0)
const scrollGestureWindowMs = 250 const scrollGestureWindowMs = 250
const markScrollGesture = (target?: EventTarget | null) => { const markScrollGesture = (target?: EventTarget | null) => {
@@ -450,26 +453,24 @@ export default function Page() {
const nested = el?.closest("[data-scrollable]") const nested = el?.closest("[data-scrollable]")
if (nested && nested !== root) return if (nested && nested !== root) return
setScrollGesture(Date.now()) setUi("scrollGesture", Date.now())
} }
const hasScrollGesture = () => Date.now() - scrollGesture() < scrollGestureWindowMs const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
createEffect(() => { createEffect(() => {
if (!params.id) return if (!params.id) return
sync.session.sync(params.id) sync.session.sync(params.id)
}) })
const [autoCreated, setAutoCreated] = createSignal(false)
createEffect(() => { createEffect(() => {
if (!view().terminal.opened()) { if (!view().terminal.opened()) {
setAutoCreated(false) setUi("autoCreated", false)
return return
} }
if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
terminal.new() terminal.new()
setAutoCreated(true) setUi("autoCreated", true)
}) })
createEffect( createEffect(
@@ -1019,9 +1020,18 @@ export default function Page() {
const showTabs = createMemo(() => view().reviewPanel.opened()) const showTabs = createMemo(() => view().reviewPanel.opened())
const [fileTreeTab, setFileTreeTab] = createSignal<"changes" | "all">("changes") const [tree, setTree] = createStore({
const [reviewScroll, setReviewScroll] = createSignal<HTMLDivElement | undefined>(undefined) fileTreeTab: "changes" as "changes" | "all",
const [pendingDiff, setPendingDiff] = createSignal<string | undefined>(undefined) reviewScroll: undefined as HTMLDivElement | undefined,
pendingDiff: undefined as string | undefined,
})
const fileTreeTab = () => tree.fileTreeTab
const setFileTreeTab = (value: "changes" | "all") => setTree("fileTreeTab", value)
const reviewScroll = () => tree.reviewScroll
const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
const pendingDiff = () => tree.pendingDiff
const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
createEffect(() => { createEffect(() => {
if (!layout.fileTree.opened()) return if (!layout.fileTree.opened()) return
@@ -1316,7 +1326,7 @@ export default function Page() {
if (pendingSessionID !== sessionID) return if (pendingSessionID !== sessionID) return
sessionStorage.removeItem("opencode.pendingMessage") sessionStorage.removeItem("opencode.pendingMessage")
setPendingMessage(messageID) setUi("pendingMessage", messageID)
}) })
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -1484,7 +1494,7 @@ export default function Page() {
store.turnStart store.turnStart
const targetId = const targetId =
pendingMessage() ?? ui.pendingMessage ??
(() => { (() => {
const hash = window.location.hash.slice(1) const hash = window.location.hash.slice(1)
const match = hash.match(/^message-(.+)$/) const match = hash.match(/^message-(.+)$/)
@@ -1496,7 +1506,7 @@ export default function Page() {
const msg = visibleUserMessages().find((m) => m.id === targetId) const msg = visibleUserMessages().find((m) => m.id === targetId)
if (!msg) return if (!msg) return
if (pendingMessage() === targetId) setPendingMessage(undefined) if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined)
requestAnimationFrame(() => scrollToMessage(msg, "auto")) requestAnimationFrame(() => scrollToMessage(msg, "auto"))
}) })
@@ -1877,18 +1887,18 @@ export default function Page() {
</BasicTool> </BasicTool>
<div data-component="permission-prompt"> <div data-component="permission-prompt">
<div data-slot="permission-actions"> <div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={responding()}> <Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={ui.responding}>
{language.t("ui.permission.deny")} {language.t("ui.permission.deny")}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="small" size="small"
onClick={() => decide("always")} onClick={() => decide("always")}
disabled={responding()} disabled={ui.responding}
> >
{language.t("ui.permission.allowAlways")} {language.t("ui.permission.allowAlways")}
</Button> </Button>
<Button variant="primary" size="small" onClick={() => decide("once")} disabled={responding()}> <Button variant="primary" size="small" onClick={() => decide("once")} disabled={ui.responding}>
{language.t("ui.permission.allowOnce")} {language.t("ui.permission.allowOnce")}
</Button> </Button>
</div> </div>
@@ -2144,11 +2154,40 @@ export default function Page() {
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [openedComment, setOpenedComment] = createSignal<string | null>(null) const [note, setNote] = createStore({
const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null) openedComment: null as string | null,
const [draft, setDraft] = createSignal("") commenting: null as SelectedLineRange | null,
const [positions, setPositions] = createSignal<Record<string, number>>({}) draft: "",
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined) positions: {} as Record<string, number>,
draftTop: undefined as number | undefined,
})
const openedComment = () => note.openedComment
const setOpenedComment = (
value:
| typeof note.openedComment
| ((value: typeof note.openedComment) => typeof note.openedComment),
) => setNote("openedComment", value)
const commenting = () => note.commenting
const setCommenting = (
value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting),
) => setNote("commenting", value)
const draft = () => note.draft
const setDraft = (
value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft),
) => setNote("draft", value)
const positions = () => note.positions
const setPositions = (
value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions),
) => setNote("positions", value)
const draftTop = () => note.draftTop
const setDraftTop = (
value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop),
) => setNote("draftTop", value)
const commentLabel = (range: SelectedLineRange) => { const commentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end) const start = Math.min(range.start, range.end)
@@ -2695,7 +2734,7 @@ export default function Page() {
terminal={pty} terminal={pty}
onClose={() => { onClose={() => {
view().terminal.close() view().terminal.close()
setAutoCreated(false) setUi("autoCreated", false)
}} }}
/> />
)} )}

View File

@@ -1,4 +1,5 @@
import { createSignal, onCleanup } from "solid-js" import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
// Minimal types to avoid relying on non-standard DOM typings // Minimal types to avoid relying on non-standard DOM typings
type RecognitionResult = { type RecognitionResult = {
@@ -59,9 +60,15 @@ export function createSpeechRecognition(opts?: {
typeof window !== "undefined" && typeof window !== "undefined" &&
Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition) Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
const [isRecording, setIsRecording] = createSignal(false) const [store, setStore] = createStore({
const [committed, setCommitted] = createSignal("") isRecording: false,
const [interim, setInterim] = createSignal("") committed: "",
interim: "",
})
const isRecording = () => store.isRecording
const committed = () => store.committed
const interim = () => store.interim
let recognition: Recognition | undefined let recognition: Recognition | undefined
let shouldContinue = false let shouldContinue = false
@@ -82,7 +89,7 @@ export function createSpeechRecognition(opts?: {
const nextCommitted = appendSegment(committedText, segment) const nextCommitted = appendSegment(committedText, segment)
if (nextCommitted === committedText) return if (nextCommitted === committedText) return
committedText = nextCommitted committedText = nextCommitted
setCommitted(committedText) setStore("committed", committedText)
if (opts?.onFinal) opts.onFinal(segment.trim()) if (opts?.onFinal) opts.onFinal(segment.trim())
} }
@@ -98,7 +105,7 @@ export function createSpeechRecognition(opts?: {
pendingHypothesis = "" pendingHypothesis = ""
lastInterimSuffix = "" lastInterimSuffix = ""
shrinkCandidate = undefined shrinkCandidate = undefined
setInterim("") setStore("interim", "")
if (opts?.onInterim) opts.onInterim("") if (opts?.onInterim) opts.onInterim("")
} }
@@ -107,7 +114,7 @@ export function createSpeechRecognition(opts?: {
pendingHypothesis = hypothesis pendingHypothesis = hypothesis
lastInterimSuffix = suffix lastInterimSuffix = suffix
shrinkCandidate = undefined shrinkCandidate = undefined
setInterim(suffix) setStore("interim", suffix)
if (opts?.onInterim) { if (opts?.onInterim) {
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "") opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
} }
@@ -122,7 +129,7 @@ export function createSpeechRecognition(opts?: {
pendingHypothesis = "" pendingHypothesis = ""
lastInterimSuffix = "" lastInterimSuffix = ""
shrinkCandidate = undefined shrinkCandidate = undefined
setInterim("") setStore("interim", "")
if (opts?.onInterim) opts.onInterim("") if (opts?.onInterim) opts.onInterim("")
}, COMMIT_DELAY) }, COMMIT_DELAY)
} }
@@ -162,7 +169,7 @@ export function createSpeechRecognition(opts?: {
pendingHypothesis = "" pendingHypothesis = ""
lastInterimSuffix = "" lastInterimSuffix = ""
shrinkCandidate = undefined shrinkCandidate = undefined
setInterim("") setStore("interim", "")
if (opts?.onInterim) opts.onInterim("") if (opts?.onInterim) opts.onInterim("")
return return
} }
@@ -211,7 +218,7 @@ export function createSpeechRecognition(opts?: {
lastInterimSuffix = "" lastInterimSuffix = ""
shrinkCandidate = undefined shrinkCandidate = undefined
if (e.error === "no-speech" && shouldContinue) { if (e.error === "no-speech" && shouldContinue) {
setInterim("") setStore("interim", "")
if (opts?.onInterim) opts.onInterim("") if (opts?.onInterim) opts.onInterim("")
setTimeout(() => { setTimeout(() => {
try { try {
@@ -221,7 +228,7 @@ export function createSpeechRecognition(opts?: {
return return
} }
shouldContinue = false shouldContinue = false
setIsRecording(false) setStore("isRecording", false)
} }
recognition.onstart = () => { recognition.onstart = () => {
@@ -230,16 +237,16 @@ export function createSpeechRecognition(opts?: {
cancelPendingCommit() cancelPendingCommit()
lastInterimSuffix = "" lastInterimSuffix = ""
shrinkCandidate = undefined shrinkCandidate = undefined
setInterim("") setStore("interim", "")
if (opts?.onInterim) opts.onInterim("") if (opts?.onInterim) opts.onInterim("")
setIsRecording(true) setStore("isRecording", true)
} }
recognition.onend = () => { recognition.onend = () => {
cancelPendingCommit() cancelPendingCommit()
lastInterimSuffix = "" lastInterimSuffix = ""
shrinkCandidate = undefined shrinkCandidate = undefined
setIsRecording(false) setStore("isRecording", false)
if (shouldContinue) { if (shouldContinue) {
setTimeout(() => { setTimeout(() => {
try { try {
@@ -258,7 +265,7 @@ export function createSpeechRecognition(opts?: {
cancelPendingCommit() cancelPendingCommit()
lastInterimSuffix = "" lastInterimSuffix = ""
shrinkCandidate = undefined shrinkCandidate = undefined
setInterim("") setStore("interim", "")
try { try {
recognition.start() recognition.start()
} catch {} } catch {}
@@ -271,7 +278,7 @@ export function createSpeechRecognition(opts?: {
cancelPendingCommit() cancelPendingCommit()
lastInterimSuffix = "" lastInterimSuffix = ""
shrinkCandidate = undefined shrinkCandidate = undefined
setInterim("") setStore("interim", "")
if (opts?.onInterim) opts.onInterim("") if (opts?.onInterim) opts.onInterim("")
try { try {
recognition.stop() recognition.stop()
@@ -284,7 +291,7 @@ export function createSpeechRecognition(opts?: {
cancelPendingCommit() cancelPendingCommit()
lastInterimSuffix = "" lastInterimSuffix = ""
shrinkCandidate = undefined shrinkCandidate = undefined
setInterim("") setStore("interim", "")
if (opts?.onInterim) opts.onInterim("") if (opts?.onInterim) opts.onInterim("")
try { try {
recognition?.stop() recognition?.stop()