mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-06 23:46:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d49c5b6279 |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import type { SessionTab, Tab } from "./tabs"
|
||||
import type { SessionTab, Tab, TabInfo } from "./tabs"
|
||||
import type { TabStorage } from "./schema"
|
||||
|
||||
export type ClosedTab = typeof TabStorage.ClosedTab.Type
|
||||
@@ -7,14 +7,38 @@ const CLOSED_TAB_LIMIT = 25
|
||||
|
||||
// Only session tabs are recorded; closing a draft tab deletes its persisted
|
||||
// state, so a reopened draft would come back empty anyway.
|
||||
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] {
|
||||
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number, info?: TabInfo): ClosedTab[] {
|
||||
if (tab.type !== "session") return stack
|
||||
return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT)
|
||||
return [
|
||||
...stack.filter((entry) => !sameTab(entry.tab, tab)),
|
||||
{ tab: { ...tab }, index, ...(info ? { info: { ...info } } : {}) },
|
||||
].slice(-CLOSED_TAB_LIMIT)
|
||||
}
|
||||
|
||||
export function listClosedTabs(stack: ClosedTab[], tabs: Tab[]) {
|
||||
const seen = new Set<string>()
|
||||
return stack.toReversed().filter((entry) => {
|
||||
const key = `${entry.tab.server}\n${entry.tab.sessionId}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return !isOpen(tabs, entry.tab)
|
||||
})
|
||||
}
|
||||
|
||||
// Pops the most recently closed tab that is not open again,
|
||||
// discarding stale entries along the way.
|
||||
export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } {
|
||||
export function takeClosedTab(
|
||||
stack: ClosedTab[],
|
||||
tabs: Tab[],
|
||||
target?: SessionTab,
|
||||
): { entry?: ClosedTab; stack: ClosedTab[] } {
|
||||
if (target) {
|
||||
const index = stack.findLastIndex((entry) => sameTab(entry.tab, target))
|
||||
const entry = stack[index]
|
||||
if (!entry || isOpen(tabs, entry.tab)) return { stack }
|
||||
return { entry, stack: [...stack.slice(0, index), ...stack.slice(index + 1)] }
|
||||
}
|
||||
|
||||
const remaining = [...stack]
|
||||
while (remaining.length) {
|
||||
const entry = remaining.pop()
|
||||
@@ -34,5 +58,9 @@ export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) {
|
||||
}
|
||||
|
||||
function isOpen(tabs: Tab[], tab: SessionTab) {
|
||||
return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId)
|
||||
return tabs.some((item) => item.type === "session" && sameTab(item, tab))
|
||||
}
|
||||
|
||||
function sameTab(first: SessionTab, second: SessionTab) {
|
||||
return first.server === second.server && first.sessionId === second.sessionId
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export const Recent = Persistence.struct({
|
||||
export const Info = Persistence.struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
directory: Schema.optional(Schema.String),
|
||||
prompted: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
|
||||
export const Panes = Schema.Record(
|
||||
@@ -59,5 +60,9 @@ export const Panes = Schema.Record(
|
||||
}),
|
||||
),
|
||||
)
|
||||
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
|
||||
export const ClosedTab = Persistence.struct({
|
||||
tab: SessionCodec,
|
||||
index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
info: Persistence.optional(Info),
|
||||
})
|
||||
export const Closed = Persistence.array(ClosedTab)
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
|
||||
import {
|
||||
listClosedTabs,
|
||||
nextTabAfterClose,
|
||||
pushClosedTab,
|
||||
removeClosedTabs,
|
||||
takeClosedTab,
|
||||
type ClosedTab,
|
||||
} from "./closed"
|
||||
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
|
||||
import { Schema } from "effect"
|
||||
import { TabStorage } from "./schema"
|
||||
@@ -140,9 +147,15 @@ describe("tab memory", () => {
|
||||
|
||||
describe("closed tab stack", () => {
|
||||
test("records session tabs with their index", () => {
|
||||
const stack = pushClosedTab([], sessionTab("a"), 2)
|
||||
const stack = pushClosedTab([], sessionTab("a"), 2, {
|
||||
title: "Alpha",
|
||||
directory: "/project",
|
||||
prompted: true,
|
||||
})
|
||||
|
||||
expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }])
|
||||
expect(stack).toEqual([
|
||||
{ tab: sessionTab("a"), index: 2, info: { title: "Alpha", directory: "/project", prompted: true } },
|
||||
])
|
||||
})
|
||||
|
||||
test("ignores draft tabs", () => {
|
||||
@@ -162,6 +175,19 @@ describe("closed tab stack", () => {
|
||||
expect(stack.at(-1)?.tab.sessionId).toBe("s29")
|
||||
})
|
||||
|
||||
test("keeps only the newest close record for a session", () => {
|
||||
const stack = pushClosedTab(
|
||||
pushClosedTab([], sessionTab("a"), 1, { title: "Old", directory: "/old", prompted: true }),
|
||||
sessionTab("a"),
|
||||
3,
|
||||
{ title: "New", directory: "/new", prompted: true },
|
||||
)
|
||||
|
||||
expect(stack).toEqual([
|
||||
{ tab: sessionTab("a"), index: 3, info: { title: "New", directory: "/new", prompted: true } },
|
||||
])
|
||||
})
|
||||
|
||||
test("pops the most recently closed tab", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
@@ -184,6 +210,41 @@ describe("closed tab stack", () => {
|
||||
expect(result.stack).toEqual([])
|
||||
})
|
||||
|
||||
test("lists closed tabs from newest to oldest and excludes open tabs", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
{ tab: sessionTab("c"), index: 2 },
|
||||
]
|
||||
|
||||
expect(listClosedTabs(stack, [sessionTab("b")]).map((entry) => entry.tab.sessionId)).toEqual(["c", "a"])
|
||||
})
|
||||
|
||||
test("deduplicates previously stored close records", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
{ tab: sessionTab("a"), index: 2 },
|
||||
]
|
||||
|
||||
expect(listClosedTabs(stack, []).map((entry) => [entry.tab.sessionId, entry.index])).toEqual([
|
||||
["a", 2],
|
||||
["b", 1],
|
||||
])
|
||||
})
|
||||
|
||||
test("takes a selected closed tab without removing newer entries", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
{ tab: sessionTab("c"), index: 2 },
|
||||
]
|
||||
const result = takeClosedTab(stack, [], sessionTab("a"))
|
||||
|
||||
expect(result.entry?.tab.sessionId).toBe("a")
|
||||
expect(result.stack.map((entry) => entry.tab.sessionId)).toEqual(["b", "c"])
|
||||
})
|
||||
|
||||
test("returns no entry when everything is open or empty", () => {
|
||||
expect(takeClosedTab([], []).entry).toBeUndefined()
|
||||
|
||||
|
||||
@@ -364,20 +364,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
closeTab(index: number) {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
|
||||
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index, info[tabKey(tab)]))
|
||||
removeTab(index)
|
||||
},
|
||||
reopenClosedTab() {
|
||||
reopenClosedTab(target?: SessionTab, options?: { append?: boolean }) {
|
||||
if (!closedReady()) {
|
||||
void closedReady.promise?.then(() => actions.reopenClosedTab())
|
||||
void closedReady.promise?.then(() => actions.reopenClosedTab(target, options))
|
||||
return
|
||||
}
|
||||
const result = takeClosedTab(closed, store)
|
||||
const result = takeClosedTab(closed, store, target)
|
||||
if (result.stack.length === closed.length) return
|
||||
setClosed(() => result.stack)
|
||||
const entry = result.entry
|
||||
if (!entry) return
|
||||
const index = Math.min(entry.index, store.length)
|
||||
const index = options?.append ? store.length : Math.min(entry.index, store.length)
|
||||
if (entry.info) setInfo(tabKey(entry.tab), entry.info)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
@@ -457,11 +458,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
for (const key of removed) memory.remove(key)
|
||||
for (const key of removed) removeInfo(key)
|
||||
},
|
||||
rememberSessionInfo(tab: SessionTab, session: SessionInfo) {
|
||||
rememberSessionInfo(tab: SessionTab, session: SessionInfo, prompted: boolean) {
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.location.directory }
|
||||
const current = info[key]
|
||||
if (current && current.title === next.title && current.directory === next.directory) return
|
||||
const next = {
|
||||
title: session.title,
|
||||
directory: session.location.directory,
|
||||
prompted: current?.prompted === true || prompted,
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.title === next.title &&
|
||||
current.directory === next.directory &&
|
||||
current.prompted === next.prompted
|
||||
)
|
||||
return
|
||||
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
|
||||
setInfo(key, next)
|
||||
},
|
||||
@@ -533,6 +544,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
}
|
||||
|
||||
return { ...actions, store, info, ready, infoReady, recentReady, panesReady }
|
||||
return { ...actions, store, info, closed, ready, infoReady, recentReady, closedReady, panesReady }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createMemo, For, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { listClosedTabs, type ClosedTab } from "@/shell/tabs/closed"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
|
||||
export function RecentlyClosedTabsMenu(props: {
|
||||
onNewTab: () => void
|
||||
tooltip?: JSX.Element
|
||||
vertical?: boolean
|
||||
keybind?: string
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const tabs = useTabs()
|
||||
const recent = createMemo(() => listClosedTabs(tabs.closed, tabs.store).filter((entry) => entry.info?.prompted === true))
|
||||
|
||||
return (
|
||||
<Menu.Context modal={false}>
|
||||
<Show
|
||||
when={props.vertical}
|
||||
fallback={
|
||||
<Tooltip placement="bottom" value={props.tooltip}>
|
||||
<Menu.Context.Trigger
|
||||
as={IconButton}
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<Icon name="plus" />}
|
||||
onClick={props.onNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Menu.Context.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
|
||||
onClick={props.onNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{props.keybind}</bdi>
|
||||
</span>
|
||||
</Menu.Context.Trigger>
|
||||
</Show>
|
||||
<Menu.Context.Portal>
|
||||
<Menu.Context.Content class="max-h-[66.667dvh] w-56 max-w-56 overflow-y-auto">
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("home.recentlyClosed")}</Menu.GroupLabel>
|
||||
<For each={recent()}>
|
||||
{(entry) => (
|
||||
<RecentlyClosedTabItem entry={entry} onSelect={() => tabs.reopenClosedTab(entry.tab, { append: true })} />
|
||||
)}
|
||||
</For>
|
||||
</Menu.Group>
|
||||
</Menu.Context.Content>
|
||||
</Menu.Context.Portal>
|
||||
</Menu.Context>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentlyClosedTabItem(props: { entry: ClosedTab; onSelect: () => void }) {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ truncated: false })
|
||||
const placement = language.direction() === "rtl" ? "left-start" : "right-start"
|
||||
const serverCtx = useServerCtx(() =>
|
||||
global.servers.list().find((item) => ServerConnection.key(item) === props.entry.tab.server),
|
||||
)
|
||||
const session = createMemo(() => serverCtx()?.data.session.get(props.entry.tab.sessionId))
|
||||
const directory = () => props.entry.info?.directory ?? session()?.location.directory ?? ""
|
||||
const project = createMemo(() => {
|
||||
const value = session()
|
||||
if (value) return serverCtx()?.projects.forSession(value)
|
||||
if (!directory()) return
|
||||
return serverCtx()?.projects.resolve({ worktree: directory(), expanded: false })
|
||||
})
|
||||
const title = () =>
|
||||
sessionTabTitle(session()?.title ?? props.entry.info?.title, language.t("session.tab.session"))
|
||||
|
||||
return (
|
||||
<Menu.Item class="max-w-56" onSelect={props.onSelect}>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<SessionTabAvatarView project={project()} directory={directory()} unread={false} loading={false} />
|
||||
</span>
|
||||
<Tooltip
|
||||
placement={placement}
|
||||
value={<bdi dir="auto">{title()}</bdi>}
|
||||
disabled={!state.truncated}
|
||||
class="min-w-0 flex-1"
|
||||
contentClass="max-w-[min(480px,calc(100vw-32px))] break-words"
|
||||
>
|
||||
<span
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth))
|
||||
}
|
||||
dir="auto"
|
||||
class="block min-w-0 truncate text-start"
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
@@ -150,7 +150,11 @@ function SessionTabEntry(props: {
|
||||
createEffect(() => {
|
||||
const value = session()
|
||||
if (!value) return
|
||||
tabs.rememberSessionInfo(props.tab, value)
|
||||
tabs.rememberSessionInfo(
|
||||
props.tab,
|
||||
value,
|
||||
props.serverCtx?.data.session.message.list(value.id).some((message) => message.type === "user") ?? false,
|
||||
)
|
||||
const current = sdk()
|
||||
if (!current) return
|
||||
createTabComposerState(tabs, props.tab, current.scope, {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigg
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import { RecentlyClosedTabsMenu } from "./recently-closed-tabs-menu"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { updaterAction } from "@/shell/updates/action"
|
||||
import type { UpdaterState } from "@/shell/updates/types"
|
||||
@@ -629,25 +630,15 @@ export function Titlebar(props: {
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<RecentlyClosedTabsMenu
|
||||
onNewTab={openNewTab}
|
||||
tooltip={
|
||||
<>
|
||||
{language.t("command.session.new")}
|
||||
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<Icon name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -669,23 +660,11 @@ export function Titlebar(props: {
|
||||
<ChannelIndicator sidebar debugTools={props.debugTools} />
|
||||
</Show>
|
||||
{homeButton(true)}
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
<RecentlyClosedTabsMenu
|
||||
vertical
|
||||
onNewTab={openNewTab}
|
||||
keybind={command.keybind("tab.new")}
|
||||
/>
|
||||
<div class="h-4 w-full shrink-0" aria-hidden="true" />
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<TitlebarTabStrip
|
||||
|
||||
Reference in new issue
Block a user