fix(app): improve tab handling (#30669)
This commit is contained in:
parent
3003867c25
commit
5426478e46
12 changed files with 238 additions and 203 deletions
|
|
@ -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 { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
|
||||
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 { 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 { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
|
@ -17,7 +17,6 @@ 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 { decodeDirectory } from "@/pages/directory-layout"
|
||||
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"
|
||||
|
|
@ -29,6 +28,9 @@ import {
|
|||
SESSION_TABS_REMOVED_EVENT,
|
||||
type SessionTabsRemovedDetail,
|
||||
} from "@/components/titlebar-session-events"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
|
|
@ -243,6 +245,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
const serverSync = useServerSync()
|
||||
const navigate = useNavigate()
|
||||
const homeMatch = useMatch(() => "/")
|
||||
const layout = useLayout()
|
||||
|
||||
const newSessionHref = () => {
|
||||
if (params.dir) return `/${params.dir}/session`
|
||||
|
|
@ -253,41 +256,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
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 [store, setStore] = createStore<Tab[]>(
|
||||
iife(() => {
|
||||
if (!params.dir || !params.id) return []
|
||||
return [
|
||||
{
|
||||
dir: decodeDirectory(params.dir) ?? "",
|
||||
sessionId: params.id,
|
||||
href: makeSessionHref(params.dir, params.id),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
const [store, setStore] = persisted(Persist.global("tabs"), createStore<Tab[]>([]))
|
||||
|
||||
const actions = {
|
||||
addTab: (tab: Tab) => {
|
||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||
setStore(
|
||||
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(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const index = tabs.findIndex((t) => t.href === href)
|
||||
if (index === -1) return
|
||||
const nextTab = tabs[index + 1] ?? tabs[index - 1]
|
||||
tabs.splice(index, 1)
|
||||
const nextTab = tabs[index] ?? tabs[tabs.length - 1]
|
||||
if (nextTab) navigate(nextTab.href)
|
||||
if (nextTab) navigate(tabHref(nextTab))
|
||||
else navigate("/")
|
||||
}),
|
||||
)
|
||||
|
|
@ -299,23 +299,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
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.href === currentHref) : -1
|
||||
const currentIndex = currentHref
|
||||
? tabs.findIndex((tab) => tab.type === "session" && tabHref(tab) === currentHref)
|
||||
: -1
|
||||
const currentTab = tabs[currentIndex]
|
||||
const removedCurrent =
|
||||
currentIndex !== -1 &&
|
||||
tabs[currentIndex]?.dir === input.directory &&
|
||||
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
|
||||
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) continue
|
||||
if (tab.dir !== input.directory) continue
|
||||
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[currentIndex] ?? tabs[tabs.length - 1]
|
||||
if (nextTab) navigate(nextTab.href)
|
||||
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("/")
|
||||
}),
|
||||
)
|
||||
|
|
@ -326,55 +331,54 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
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) => {
|
||||
const detail = readSessionTabsRemovedDetail(event)
|
||||
if (!detail) return
|
||||
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 closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab()
|
||||
command.register("tabs", () => {
|
||||
const current = currentTab()
|
||||
|
||||
command.register(() => {
|
||||
const commands = [
|
||||
return [
|
||||
{
|
||||
id: "tab.new",
|
||||
category: "tab",
|
||||
|
|
@ -383,13 +387,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
hidden: true,
|
||||
onSelect: openNewTab,
|
||||
},
|
||||
{
|
||||
current && {
|
||||
id: "tab.close",
|
||||
category: "tab",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
hidden: true,
|
||||
onSelect: closeActiveTab,
|
||||
onSelect: () => {
|
||||
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `tab.prev`,
|
||||
|
|
@ -398,14 +404,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
keybind: `mod+option+ArrowLeft`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
if (index === -1) return
|
||||
|
||||
index -= 1
|
||||
if (index === -1) index = tabsStore.length - 1
|
||||
|
||||
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`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
||||
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||
if (index === -1) return
|
||||
|
||||
index += 1
|
||||
if (index === tabsStore.length) index = 0
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) navigate(next.href)
|
||||
if (next) navigate(tabHref(next))
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
|
|
@ -437,26 +443,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
hidden: true,
|
||||
onSelect: () => {
|
||||
const tab = tabsStore[index]
|
||||
if (tab) navigate(tab.href)
|
||||
if (tab) navigate(tabHref(tab))
|
||||
},
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
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] : []))
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
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-row items-center gap-1.5 overflow-hidden">
|
||||
<For each={tabsEnriched()}>
|
||||
<For each={tabsStore}>
|
||||
{(tab, i) => (
|
||||
<>
|
||||
{i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)}
|
||||
<TabNavItem
|
||||
href={tab.href}
|
||||
title={tab.info.title}
|
||||
project={projectForSession(tab.info, projects(), projectByID())}
|
||||
directory={tab.dir}
|
||||
sessionId={tab.info.id}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
href={tabHref(tab)}
|
||||
directory={decode64(tab.dirBase64)!}
|
||||
sessionId={tab.sessionId}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -519,7 +509,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
<NewSessionTabItem
|
||||
href={`/${params.dir}/session`}
|
||||
title={language.t("command.session.new")}
|
||||
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
|
||||
onClose={() => navigate(tabsStore.at(-1) ? tabHref(tabsStore.at(-1)!) : "/")}
|
||||
/>
|
||||
</Show>
|
||||
<div class="min-w-0 flex-1" />
|
||||
|
|
@ -746,38 +736,58 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
|||
|
||||
function TabNavItem(props: {
|
||||
href: string
|
||||
title: string
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
sessionId: string
|
||||
sessionId?: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
active?: boolean
|
||||
}) {
|
||||
const match = useMatch(() => props.href)
|
||||
const isActive = () => !!match()
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
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 (
|
||||
<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)]"
|
||||
data-active={isActive()}
|
||||
data-active={props.active}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
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={props.project} directory={props.directory} sessionId={props.sessionId} />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">{props.title}</span>
|
||||
</a>
|
||||
<Show when={session()}>
|
||||
{(session) => {
|
||||
const layout = useLayout()
|
||||
const project = createMemo(() => projectForSession(session(), layout.projects.list()))
|
||||
|
||||
return (
|
||||
<a
|
||||
href={props.href}
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue