fix(app): improve tab handling (#30669)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"]>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
38
packages/app/src/context/layout-helpers.ts
Normal file
38
packages/app/src/context/layout-helpers.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user