feat(app): improve desktop multi-server support (#30678)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createEffect, createMemo, createResource, For, Match, Show, startTransition, Switch, untrack } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -17,20 +17,16 @@ import { useSettings } from "@/context/settings"
|
||||
import { WindowsAppMenu } from "./windows-app-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { iife } from "@opencode-ai/core/util/iife"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import {
|
||||
readSessionTabsRemovedDetail,
|
||||
SESSION_TABS_REMOVED_EVENT,
|
||||
type SessionTabsRemovedDetail,
|
||||
} from "@/components/titlebar-session-events"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabHref, useTabs, type Tab } from "@/context/tabs"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -58,8 +54,6 @@ const v2TitlebarHeight = 36
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
|
||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
||||
|
||||
export type TitlebarUpdate = {
|
||||
version: () => string | undefined
|
||||
installing: () => boolean
|
||||
@@ -73,6 +67,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const server = useServer()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
@@ -256,93 +251,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
return `/${base64Encode(project.worktree)}/session`
|
||||
}
|
||||
|
||||
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)
|
||||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
const tabsStoreActions = tabs
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
if (tab.server === server.key) {
|
||||
navigate(href)
|
||||
return
|
||||
}
|
||||
return "/"
|
||||
void startTransition(() => {
|
||||
server.setActive(tab.server)
|
||||
navigate(href)
|
||||
})
|
||||
}
|
||||
|
||||
const [tabsStore, tabsStoreActions] = iife(() => {
|
||||
const [store, setStore] = persisted(Persist.global("tabs"), createStore<Tab[]>([]))
|
||||
|
||||
const actions = {
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((t) => t.type === "session" && tabHref(t) === tabHref({ type: "session", ...tab })))
|
||||
return
|
||||
|
||||
tabs.push({ type: "session", ...tab })
|
||||
}),
|
||||
)
|
||||
},
|
||||
removeTab: (index: number) => {
|
||||
if (index < 0) return
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const nextTab = tabs[index + 1] ?? tabs[index - 1]
|
||||
tabs.splice(index, 1)
|
||||
if (nextTab) navigate(tabHref(nextTab))
|
||||
else navigate("/")
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const sessionIDs = new Set(input.sessionIDs)
|
||||
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
|
||||
const currentIndex = currentHref
|
||||
? tabs.findIndex((tab) => tab.type === "session" && tabHref(tab) === currentHref)
|
||||
: -1
|
||||
const currentTab = tabs[currentIndex]
|
||||
const removedCurrent =
|
||||
currentTab?.type === "session" &&
|
||||
atob(currentTab.dirBase64) === input.directory &&
|
||||
sessionIDs.has(currentTab.sessionId)
|
||||
|
||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||
const tab = tabs[i]
|
||||
if (!tab || tab.type !== "session") continue
|
||||
if (atob(tab.dirBase64) !== input.directory) continue
|
||||
if (!sessionIDs.has(tab.sessionId)) continue
|
||||
tabs.splice(i, 1)
|
||||
}
|
||||
|
||||
if (!removedCurrent) return
|
||||
const nextTab =
|
||||
tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
|
||||
tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
|
||||
if (nextTab) navigate(tabHref(nextTab))
|
||||
else navigate("/")
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
const main = tabsStore.find(
|
||||
(item) =>
|
||||
item.type === "session" && item.server === route.server && item.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)
|
||||
const parent = tabsStore.find(
|
||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
|
||||
)
|
||||
if (parent) return parent
|
||||
}
|
||||
}
|
||||
@@ -352,6 +292,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
createEffect(() => {
|
||||
const route = layout.route()
|
||||
if (!tabs.ready()) return
|
||||
const tab = currentTab()
|
||||
if (tab) return
|
||||
|
||||
@@ -360,10 +301,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (!session) return
|
||||
const sessionId = session.parentID ?? session.id
|
||||
tabsStoreActions.addSessionTab({
|
||||
const next = {
|
||||
server: route.server ?? server.key,
|
||||
dirBase64: route.dirBase64,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
tabsStoreActions.addSessionTab(next)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -411,7 +354,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
if (index === -1) index = tabsStore.length - 1
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) navigate(tabHref(next))
|
||||
if (next) navigateTab(next)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -428,7 +371,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
if (index === tabsStore.length) index = 0
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) navigate(tabHref(next))
|
||||
if (next) navigateTab(next)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
@@ -443,7 +386,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
const tab = tabsStore[index]
|
||||
if (tab) navigate(tabHref(tab))
|
||||
if (tab) navigateTab(tab)
|
||||
},
|
||||
}
|
||||
}),
|
||||
@@ -482,10 +425,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
)}
|
||||
<TabNavItem
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
directory={decode64(tab.dirBase64)!}
|
||||
sessionId={tab.sessionId}
|
||||
onNavigate={() => navigateTab(tab)}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
activeServer={tab.server === server.key}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -509,7 +455,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<NewSessionTabItem
|
||||
href={`/${params.dir}/session`}
|
||||
title={language.t("command.session.new")}
|
||||
onClose={() => navigate(tabsStore.at(-1) ? tabHref(tabsStore.at(-1)!) : "/")}
|
||||
onClose={() => {
|
||||
const tab = tabsStore.at(-1)
|
||||
if (tab) navigateTab(tab)
|
||||
else navigate("/")
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<div class="min-w-0 flex-1" />
|
||||
@@ -736,11 +686,14 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
|
||||
function TabNavItem(props: {
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
directory: string
|
||||
sessionId?: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
activeServer: boolean
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -748,17 +701,23 @@ function TabNavItem(props: {
|
||||
props.onClose()
|
||||
}
|
||||
const global = useGlobal()
|
||||
const serverCtx = global.createServerCtx(global.servers.default())
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
if (conn) return global.createServerCtx(conn)
|
||||
})
|
||||
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
|
||||
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const dirSyncCtx = serverCtx.sync.createDirSyncContext(props.directory)
|
||||
return props.sessionId ? ([props.sessionId, dirSyncCtx] as const) : undefined
|
||||
const ctx = dirSyncCtx()
|
||||
if (!ctx || !props.sessionId) return
|
||||
return [props.sessionId, ctx] as const
|
||||
},
|
||||
async ([sessionId, dirSyncCtx]) => {
|
||||
await dirSyncCtx.session.sync(sessionId).catch(() => {})
|
||||
return dirSyncCtx.session.get(sessionId)
|
||||
},
|
||||
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -770,18 +729,26 @@ function TabNavItem(props: {
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={session()}>
|
||||
<Show when={session.latest}>
|
||||
{(session) => {
|
||||
const layout = useLayout()
|
||||
const project = createMemo(() => projectForSession(session(), layout.projects.list()))
|
||||
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
||||
|
||||
return (
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.onNavigate()
|
||||
}}
|
||||
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} />
|
||||
<ProjectTabAvatar
|
||||
project={project()}
|
||||
directory={props.directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">{session().title}</span>
|
||||
</a>
|
||||
@@ -809,10 +776,15 @@ function TabNavItem(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) {
|
||||
function ProjectTabAvatar(props: {
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
sessionId: string
|
||||
activeServer: boolean
|
||||
}) {
|
||||
const directory = () => props.directory
|
||||
const sessionId = () => props.sessionId
|
||||
const state = useSessionTabAvatarState(directory, sessionId)
|
||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
|
||||
Reference in New Issue
Block a user