fix(app): improve tab handling (#30669)

This commit is contained in:
Brendan Allan
2026-06-05 11:41:30 +08:00
committed by GitHub
parent 3003867c25
commit 5426478e46
12 changed files with 238 additions and 203 deletions

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js" import { createEffect, createMemo, createResource, For, Match, Show, startTransition, Switch, untrack } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router" import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -9,7 +9,7 @@ import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -17,7 +17,6 @@ import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu" import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife" import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
@@ -29,6 +28,9 @@ import {
SESSION_TABS_REMOVED_EVENT, SESSION_TABS_REMOVED_EVENT,
type SessionTabsRemovedDetail, type SessionTabsRemovedDetail,
} from "@/components/titlebar-session-events" } from "@/components/titlebar-session-events"
import { Persist, persisted } from "@/utils/persist"
import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -243,6 +245,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const serverSync = useServerSync() const serverSync = useServerSync()
const navigate = useNavigate() const navigate = useNavigate()
const homeMatch = useMatch(() => "/") const homeMatch = useMatch(() => "/")
const layout = useLayout()
const newSessionHref = () => { const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session` if (params.dir) return `/${params.dir}/session`
@@ -253,41 +256,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return `/${base64Encode(project.worktree)}/session` return `/${base64Encode(project.worktree)}/session`
} }
type Tab = { dir: string; sessionId: string; href: string } type SessionTab = { type: "session"; dirBase64: string; sessionId: string }
type Tab = SessionTab
const tabHref = (tab: Tab) => {
if (tab.type === "session") {
return makeSessionHref(tab.dirBase64, tab.sessionId)
}
return "/"
}
const [tabsStore, tabsStoreActions] = iife(() => { const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = createStore<Tab[]>( const [store, setStore] = persisted(Persist.global("tabs"), createStore<Tab[]>([]))
iife(() => {
if (!params.dir || !params.id) return []
return [
{
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
href: makeSessionHref(params.dir, params.id),
},
]
}),
)
const actions = { const actions = {
addTab: (tab: Tab) => { addSessionTab: (tab: Omit<SessionTab, "type">) => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
if (tabs.some((t) => t.href === tab.href)) return if (tabs.some((t) => t.type === "session" && tabHref(t) === tabHref({ type: "session", ...tab })))
return
tabs.push(tab) tabs.push({ type: "session", ...tab })
}), }),
) )
}, },
removeTab: (href: string) => { removeTab: (index: number) => {
if (index < 0) return
void startTransition(() => { void startTransition(() => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
const index = tabs.findIndex((t) => t.href === href) const nextTab = tabs[index + 1] ?? tabs[index - 1]
if (index === -1) return
tabs.splice(index, 1) tabs.splice(index, 1)
const nextTab = tabs[index] ?? tabs[tabs.length - 1] if (nextTab) navigate(tabHref(nextTab))
if (nextTab) navigate(nextTab.href)
else navigate("/") else navigate("/")
}), }),
) )
@@ -299,23 +299,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
produce((tabs) => { produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs) const sessionIDs = new Set(input.sessionIDs)
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1 const currentIndex = currentHref
? tabs.findIndex((tab) => tab.type === "session" && tabHref(tab) === currentHref)
: -1
const currentTab = tabs[currentIndex]
const removedCurrent = const removedCurrent =
currentIndex !== -1 && currentTab?.type === "session" &&
tabs[currentIndex]?.dir === input.directory && atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "") sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) { for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i] const tab = tabs[i]
if (!tab) continue if (!tab || tab.type !== "session") continue
if (tab.dir !== input.directory) continue if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1) tabs.splice(i, 1)
} }
if (!removedCurrent) return if (!removedCurrent) return
const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1] const nextTab =
if (nextTab) navigate(nextTab.href) tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
if (nextTab) navigate(tabHref(nextTab))
else navigate("/") else navigate("/")
}), }),
) )
@@ -326,55 +331,54 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return [store, actions] return [store, actions]
}) })
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
if (route.type === "dir-new-sesssion") {
}
if (route.type === "session") {
const main = tabsStore.find((s) => s.type === "session" && s.sessionId === route.sessionId)
if (main) return main
const sync = serverSync.createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (session?.parentID) {
const parentID = session.parentID
const parent = tabsStore.find((s) => s.type === "session" && s.sessionId === parentID)
if (parent) return parent
}
}
}
const currentTab = () => matchRoute(layout.route())
createEffect(() => {
const route = layout.route()
const tab = currentTab()
if (tab) return
if (route.type === "session") {
const sync = serverSync.createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (!session) return
const sessionId = session.parentID ?? session.id
tabsStoreActions.addSessionTab({
dirBase64: route.dirBase64,
sessionId,
})
}
})
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => { makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
const detail = readSessionTabsRemovedDetail(event) const detail = readSessionTabsRemovedDetail(event)
if (!detail) return if (!detail) return
tabsStoreActions.removeSessions(detail) tabsStoreActions.removeSessions(detail)
}) })
createEffect(() => {
const params = useParams()
if (!(params.dir && params.id)) return
tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
href: makeSessionHref(params.dir, params.id),
})
})
const projects = createMemo(() => layout.projects.list())
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const currentSessionTab = () => {
if (!params.dir || !params.id) return
const href = makeSessionHref(params.dir, params.id)
return tabsStore.find((tab) => tab.href === href)
}
const closeCurrentSessionTab = () => {
const tab = currentSessionTab()
if (!tab) return false
tabsStoreActions.removeTab(tab.href)
return true
}
const closeNewSessionTab = () => {
if (!(params.dir && !params.id)) return false
const last = tabsStore[tabsStore.length - 1]
if (last) navigate(last.href)
else navigate("/")
return true
}
const openNewTab = () => navigate(newSessionHref()) const openNewTab = () => navigate(newSessionHref())
const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab() command.register("tabs", () => {
const current = currentTab()
command.register(() => { return [
const commands = [
{ {
id: "tab.new", id: "tab.new",
category: "tab", category: "tab",
@@ -383,13 +387,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true, hidden: true,
onSelect: openNewTab, onSelect: openNewTab,
}, },
{ current && {
id: "tab.close", id: "tab.close",
category: "tab", category: "tab",
title: language.t("command.tab.close"), title: language.t("command.tab.close"),
keybind: "mod+w", keybind: "mod+w",
hidden: true, hidden: true,
onSelect: closeActiveTab, onSelect: () => {
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
},
}, },
{ {
id: `tab.prev`, id: `tab.prev`,
@@ -398,14 +404,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: `mod+option+ArrowLeft`, keybind: `mod+option+ArrowLeft`,
hidden: true, hidden: true,
onSelect: () => { onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href) let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return if (index === -1) return
index -= 1 index -= 1
if (index === -1) index = tabsStore.length - 1 if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index] const next = tabsStore[index]
if (next) navigate(next.href) if (next) navigate(tabHref(next))
}, },
}, },
{ {
@@ -415,14 +421,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
keybind: `mod+option+ArrowRight`, keybind: `mod+option+ArrowRight`,
hidden: true, hidden: true,
onSelect: () => { onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href) let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return if (index === -1) return
index += 1 index += 1
if (index === tabsStore.length) index = 0 if (index === tabsStore.length) index = 0
const next = tabsStore[index] const next = tabsStore[index]
if (next) navigate(next.href) if (next) navigate(tabHref(next))
}, },
}, },
...Array.from({ length: 9 }, (_, i) => { ...Array.from({ length: 9 }, (_, i) => {
@@ -437,26 +443,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true, hidden: true,
onSelect: () => { onSelect: () => {
const tab = tabsStore[index] const tab = tabsStore[index]
if (tab) navigate(tab.href) if (tab) navigate(tabHref(tab))
}, },
} }
}), }),
] ].filter((v) => v !== undefined)
return commands
})
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = serverSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
)
return () => base().flatMap((s) => (s ? [s] : []))
}) })
return ( return (
@@ -483,19 +474,18 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden"> <div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden"> <div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
<For each={tabsEnriched()}> <For each={tabsStore}>
{(tab, i) => ( {(tab, i) => (
<> <>
{i() !== 0 && ( {i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" /> <div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)} )}
<TabNavItem <TabNavItem
href={tab.href} href={tabHref(tab)}
title={tab.info.title} directory={decode64(tab.dirBase64)!}
project={projectForSession(tab.info, projects(), projectByID())} sessionId={tab.sessionId}
directory={tab.dir} onClose={() => tabsStoreActions.removeTab(i())}
sessionId={tab.info.id} active={currentTab() === tab}
onClose={() => tabsStoreActions.removeTab(tab.href)}
/> />
</> </>
)} )}
@@ -519,7 +509,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<NewSessionTabItem <NewSessionTabItem
href={`/${params.dir}/session`} href={`/${params.dir}/session`}
title={language.t("command.session.new")} title={language.t("command.session.new")}
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")} onClose={() => navigate(tabsStore.at(-1) ? tabHref(tabsStore.at(-1)!) : "/")}
/> />
</Show> </Show>
<div class="min-w-0 flex-1" /> <div class="min-w-0 flex-1" />
@@ -746,38 +736,58 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
function TabNavItem(props: { function TabNavItem(props: {
href: string href: string
title: string
project?: LocalProject
directory: string directory: string
sessionId: string sessionId?: string
hideClose?: boolean hideClose?: boolean
onClose: () => void onClose: () => void
active?: boolean
}) { }) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
props.onClose() props.onClose()
} }
const global = useGlobal()
const serverCtx = global.createServerCtx(global.servers.default())
const [session] = createResource(
() => {
const dirSyncCtx = serverCtx.sync.createDirSyncContext(props.directory)
return props.sessionId ? ([props.sessionId, dirSyncCtx] as const) : undefined
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
)
return ( return (
<div <div
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={isActive()} data-active={props.active}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
closeTab(event) closeTab(event)
}} }}
> >
<a <Show when={session()}>
href={props.href} {(session) => {
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base" const layout = useLayout()
> const project = createMemo(() => projectForSession(session(), layout.projects.list()))
<span data-slot="project-avatar-slot">
<ProjectTabAvatar project={props.project} directory={props.directory} sessionId={props.sessionId} /> return (
</span> <a
<span class="min-w-0 flex-1">{props.title}</span> href={props.href}
</a> class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<span data-slot="project-avatar-slot">
<ProjectTabAvatar project={project()} directory={props.directory} sessionId={session().id} />
</span>
<span class="min-w-0 flex-1">{session().title}</span>
</a>
)
}}
</Show>
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"> <div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div <div

View File

@@ -8,11 +8,11 @@ import {
getSessionPrefetchPromise, getSessionPrefetchPromise,
setSessionPrefetch, setSessionPrefetch,
} from "./global-sync/session-prefetch" } from "./global-sync/session-prefetch"
import { createServerSyncContext } from "./server-sync"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs" import { diffs as list, message as clean } from "@/utils/diffs"
import { useServerSDK } from "./server-sdk" import { createServerSdkContext, useServerSDK } from "./server-sdk"
import { type createServerSyncContextInner } from "./server-sync"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -171,8 +171,11 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
}) })
} }
export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => { export const createDirSyncContext = (
const serverSDK = useServerSDK() directory: string,
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
) => {
const client = serverSDK.createClient({ directory, throwOnError: true }) const client = serverSDK.createClient({ directory, throwOnError: true })
type Child = ReturnType<(typeof serverSync)["child"]> type Child = ReturnType<(typeof serverSync)["child"]>

View File

@@ -73,6 +73,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
servers: { servers: {
list: allServers, list: allServers,
health: serverHealth, health: serverHealth,
default: () => allServers().find((s) => ServerConnection.key(s) === props.defaultServer) ?? allServers()[0]!,
}, },
settings: { settings: {
server: { server: {

View File

@@ -0,0 +1,38 @@
import type { Accessor } from "solid-js"
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
touch(key)
seed(key)
return key
}
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
return () => {
const value = key()
ensure(value)
return value
}
}
export function pruneSessionKeys(input: {
keep?: string
max: number
used: Map<string, number>
view: string[]
tabs: string[]
}) {
if (!input.keep) return []
const keys = new Set<string>([...input.view, ...input.tabs])
if (keys.size <= input.max) return []
const score = (key: string) => {
if (key === input.keep) return Number.MAX_SAFE_INTEGER
return input.used.get(key) ?? 0
}
return Array.from(keys)
.sort((a, b) => score(b) - score(a))
.slice(input.max)
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js" import { createRoot, createSignal } from "solid-js"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
describe("layout session-key helpers", () => { describe("layout session-key helpers", () => {
test("couples touch and scroll seed in order", () => { test("couples touch and scroll seed in order", () => {

View File

@@ -1,10 +1,11 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import { useServer } from "./server" import { ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2" import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -13,6 +14,9 @@ import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path" import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
export type { ProjectAvatarVariant } export type { ProjectAvatarVariant }
@@ -69,42 +73,10 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type ReviewDiffStyle = "unified" | "split" export type ReviewDiffStyle = "unified" | "split"
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { export type LayoutRoute =
touch(key) | { type: "home" }
seed(key) | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
return key | { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
}
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
return () => {
const value = key()
ensure(value)
return value
}
}
export function pruneSessionKeys(input: {
keep?: string
max: number
used: Map<string, number>
view: string[]
tabs: string[]
}) {
if (!input.keep) return []
const keys = new Set<string>([...input.view, ...input.tabs])
if (keys.size <= input.max) return []
const score = (key: string) => {
if (key === input.keep) return Number.MAX_SAFE_INTEGER
return input.used.get(key) ?? 0
}
return Array.from(keys)
.sort((a, b) => score(b) - score(a))
.slice(input.max)
}
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? [] const all = current?.all ?? []
@@ -146,6 +118,21 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
} }
} }
const currentRoute = (pathname: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
const dirBase64 = parts[0]
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
if (parts[1] !== "session") return { type: "home" }
const id = parts[2]
if (id) return { type: "session", dir, dirBase64, sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 }
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout", name: "Layout",
init: () => { init: () => {
@@ -153,6 +140,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const serverSync = useServerSync() const serverSync = useServerSync()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const location = useLocation()
const route = createMemo(() => currentRoute(location.pathname))
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) typeof value === "object" && value !== null && !Array.isArray(value)
@@ -557,6 +546,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}) })
return { return {
route,
ready, ready,
handoff: { handoff: {
tabs: createMemo(() => store.handoff?.tabs), tabs: createMemo(() => store.handoff?.tabs),

View File

@@ -1,17 +1,7 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client" import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
batch,
createContext,
createEffect,
getOwner,
onCleanup,
onMount,
type ParentProps,
untrack,
useContext,
} from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error" import type { InitError } from "../pages/error"
@@ -86,7 +76,7 @@ function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: Path
} }
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi> export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContext(_serverSDK?: ServerSDK) { export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK() const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
const language = useLanguage() const language = useLanguage()
const owner = getOwner() const owner = getOwner()
@@ -476,6 +466,17 @@ export function createServerSyncContext(_serverSDK?: ServerSDK) {
} }
} }
export function createServerSyncContext(_serverSDK?: ServerSDK) {
const inner = createServerSyncContextInner(_serverSDK)
return Object.assign(inner, {
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, inner, _serverSDK),
(dir) => inner.disableMcp(dir),
directoryKey,
),
})
}
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({ export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync", name: "ServerSync",
init: (props: { server?: ServerConnection.Any }) => { init: (props: { server?: ServerConnection.Any }) => {
@@ -487,13 +488,7 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn) const ctx = global.createServerCtx(conn)
return Object.assign(ctx.sync, { return ctx.sync
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, ctx.sync),
(dir) => ctx.sync.disableMcp(dir),
directoryKey,
),
})
}, },
}) })

View File

@@ -159,10 +159,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
init: () => { init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings)) const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
createEffect(() => {
console.log("settings", { ready: ready() })
})
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const root = document.documentElement const root = document.documentElement

View File

@@ -26,7 +26,7 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
createResource( createResource(
() => params.id, () => params.id,
(id) => sync.session.sync(id), (id) => sync.session.sync(id).catch(() => {}),
) )
return ( return (

View File

@@ -67,7 +67,7 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>( export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
session: Session, session: Session,
projects: T[], projects: T[],
byID: Map<string, T>, byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
) { ) {
const direct = byID.get(session.projectID) const direct = byID.get(session.projectID)
if (direct) return direct if (direct) return direct

View File

@@ -418,23 +418,26 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}), }),
] ]
const fileCmds = () => [ const fileCmds = () => {
fileCommand({ const tab = closableTab()
id: "file.open", return [
title: language.t("command.file.open"), fileCommand({
description: language.t("palette.search.placeholder"), id: "file.open",
keybind: "mod+k,mod+p", title: language.t("command.file.open"),
slash: "open", description: language.t("palette.search.placeholder"),
onSelect: openFile, keybind: "mod+k,mod+p",
}), slash: "open",
fileCommand({ onSelect: openFile,
id: "tab.close", }),
title: language.t("command.tab.close"), tab &&
keybind: "mod+w", fileCommand({
disabled: !closableTab(), id: "tab.close",
onSelect: closeTab, title: language.t("command.tab.close"),
}), keybind: "mod+w",
] onSelect: closeTab,
}),
].filter((v) => !!v)
}
const contextCmds = () => [ const contextCmds = () => [
contextCommand({ contextCommand({

View File

@@ -390,7 +390,6 @@ export const SessionReview = (props: SessionReviewProps) => {
<Accordion multiple value={open()} onChange={handleChange}> <Accordion multiple value={open()} onChange={handleChange}>
<For each={files()}> <For each={files()}>
{(file) => { {(file) => {
console.log({ file })
const diff = () => itemsMap()[file] const diff = () => itemsMap()[file]
// binary files have empty diffs that we can't render // binary files have empty diffs that we can't render