diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 703082d55370..3592bbe6227f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -374,9 +374,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: "mod+w", hidden: true, onSelect: () => { - tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab)) + tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab)) }, }, + { + id: "tab.reopenClosed", + category: language.t("command.category.file"), + title: language.t("command.tab.reopenClosed"), + keybind: "mod+shift+t", + onSelect: () => tabsStoreActions.reopenClosedTab(), + }, { id: `tab.prev`, category: "tab", @@ -464,7 +471,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }} onClose={(tab) => { const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab)) - if (index !== -1) tabsStoreActions.removeTab(index) + if (index !== -1) tabsStoreActions.closeTab(index) }} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> diff --git a/packages/app/src/context/closed-tabs.ts b/packages/app/src/context/closed-tabs.ts new file mode 100644 index 000000000000..5c49b94b6fb2 --- /dev/null +++ b/packages/app/src/context/closed-tabs.ts @@ -0,0 +1,40 @@ +import type { SessionTab, Tab } from "./tabs" + +export type ClosedTab = { + tab: SessionTab + index: number +} + +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[] { + if (tab.type !== "session") return stack + return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT) +} + +// 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[] } { + const remaining = [...stack] + while (remaining.length) { + const entry = remaining.pop() + if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining } + } + return { stack: remaining } +} + +export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) { + const removed = new Set(sessionIDs) + return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId)) +} + +export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) { + if (!active) return undefined + return tabs[index + 1] ?? tabs[index - 1] ?? null +} + +function isOpen(tabs: Tab[], tab: SessionTab) { + return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId) +} diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index 27525af63d9d..fbc40ea09e6a 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -1,6 +1,21 @@ import { describe, expect, test } from "bun:test" import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./tab-memory" +import { + nextTabAfterClose, + pushClosedTab, + removeClosedTabs, + takeClosedTab, + type ClosedTab, +} from "./closed-tabs" +import type { SessionTab, Tab } from "./tabs" +import type { ServerConnection } from "./server" + +const server = "local\nhttp://localhost:4096" as ServerConnection.Key + +function sessionTab(sessionId: string): SessionTab { + return { type: "session", server, sessionId } +} describe("tab memory", () => { test("keeps state until its tab is removed", () => { @@ -22,3 +37,75 @@ describe("tab memory", () => { }) }) }) + +describe("closed tab stack", () => { + test("records session tabs with their index", () => { + const stack = pushClosedTab([], sessionTab("a"), 2) + + expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }]) + }) + + test("ignores draft tabs", () => { + const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" } + + expect(pushClosedTab([], draft, 0)).toEqual([]) + }) + + test("caps the stack size", () => { + const stack = Array.from({ length: 30 }, (_, i) => i).reduce( + (acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i), + [], + ) + + expect(stack).toHaveLength(25) + expect(stack[0]?.tab.sessionId).toBe("s5") + expect(stack.at(-1)?.tab.sessionId).toBe("s29") + }) + + test("pops the most recently closed tab", () => { + const stack = [ + { tab: sessionTab("a"), index: 0 }, + { tab: sessionTab("b"), index: 1 }, + ] + const result = takeClosedTab(stack, []) + + expect(result.entry?.tab.sessionId).toBe("b") + expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }]) + }) + + test("skips entries whose tab is already open", () => { + const stack = [ + { tab: sessionTab("a"), index: 0 }, + { tab: sessionTab("b"), index: 1 }, + ] + const result = takeClosedTab(stack, [sessionTab("b")]) + + expect(result.entry?.tab.sessionId).toBe("a") + expect(result.stack).toEqual([]) + }) + + test("returns no entry when everything is open or empty", () => { + expect(takeClosedTab([], []).entry).toBeUndefined() + + const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")]) + expect(result.entry).toBeUndefined() + expect(result.stack).toEqual([]) + }) + + test("purges removed sessions", () => { + const stack = [ + { tab: sessionTab("a"), index: 0 }, + { tab: sessionTab("b"), index: 1 }, + ] + + expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }]) + }) + + test("does not navigate when a background tab closes", () => { + const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")] + + expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined() + expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c")) + expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull() + }) +}) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 6a73d7736dc7..11155d2ca479 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -10,6 +10,7 @@ import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" +import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" export type SessionTab = { type: "session" @@ -63,6 +64,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore({})) + const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) const params = useParams() const navigate = useNavigate() @@ -87,6 +89,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }) } + const updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => { + const apply = () => setClosed((stack) => update(stack)) + if (closedReady()) { + apply() + return + } + void closedReady.promise?.then(apply) + } + const removeDraftPersisted = (draftID: string) => { for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } @@ -106,6 +117,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined) }) + createEffect(() => { + if (!closedReady()) return + const servers = new Set(server.list.map(ServerConnection.key)) + const next = closed.filter((entry) => servers.has(entry.tab.server)) + if (next.length !== closed.length) setClosed(() => next) + }) + const navigateTab = (tab: Tab) => { const href = tabHref(tab) setRecentKey(tabKey(tab)) @@ -117,7 +135,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (!tab) return const key = tabKey(tab) const draftID = tab.type === "draft" ? tab.draftID : undefined - const nextTab = store[index + 1] ?? store[index - 1] + const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/") closing.add(key) void startTransition(() => { setStore( @@ -125,9 +143,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ tabs.splice(index, 1) }), ) - if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab)) + if (nextTab === null) { + setRecentKey(undefined) + navigate("/") + } if (nextTab) navigateTab(nextTab) - else navigate("/") }).finally(() => closing.delete(key)) memory.remove(key) if (draftID) removeDraftPersisted(draftID) @@ -201,13 +221,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ removeDraftPersisted(draftID) }, removeTab, + // User-initiated close: records the tab so it can be reopened. + // Cleanup paths (missing sessions, archive, server removal) go through + // removeTab and friends directly and are not recorded. + closeTab(index: number) { + const tab = store[index] + if (!tab) return + if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index)) + removeTab(index) + }, + reopenClosedTab() { + if (!closedReady()) { + void closedReady.promise?.then(() => actions.reopenClosedTab()) + return + } + const result = takeClosedTab(closed, store) + 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) + void startTransition(() => { + setStore( + produce((tabs) => { + if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return + tabs.splice(index, 0, entry.tab) + }), + ) + navigateTab(entry.tab) + }) + }, removeSessionTab(input: Omit) { + updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId])) const index = store.findIndex( (tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId, ) if (index !== -1) removeTab(index) }, removeServer(key: ServerConnection.Key) { + updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key)) const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) const removed = store.filter((tab) => tab.server === key).map(tabKey) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) @@ -218,6 +270,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, removeSessions: (input: SessionTabsRemovedDetail) => { const targetServer = input.server ?? server.key + updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs)) const removed = store .filter( (tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 0124fcf6ed76..0d5127cfc3ad 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -48,6 +48,7 @@ export const dict = { "command.session.new": "New session", "command.file.open": "Open file", "command.tab.close": "Close tab", + "command.tab.reopenClosed": "Reopen closed tab", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", "command.input.focus": "Focus input", diff --git a/packages/app/src/pages/home-session-open.test.ts b/packages/app/src/pages/home-session-open.test.ts new file mode 100644 index 000000000000..a71d58ff0723 --- /dev/null +++ b/packages/app/src/pages/home-session-open.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test" +import { shouldOpenSessionInBackground } from "./home-session-open" + +describe("shouldOpenSessionInBackground", () => { + test("requires only the platform primary modifier", () => { + expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true) + expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true) + expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false) + expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false) + expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false) + }) +}) diff --git a/packages/app/src/pages/home-session-open.ts b/packages/app/src/pages/home-session-open.ts new file mode 100644 index 000000000000..8e32efb559d2 --- /dev/null +++ b/packages/app/src/pages/home-session-open.ts @@ -0,0 +1,11 @@ +export function shouldOpenSessionInBackground(input: { + mac: boolean + meta: boolean + ctrl: boolean + shift: boolean + alt: boolean +}) { + if (input.shift || input.alt) return false + if (input.mac) return input.meta && !input.ctrl + return input.ctrl && !input.meta +} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 799049526059..f13a832a2d54 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -66,6 +66,7 @@ import { Persist, persisted } from "@/utils/persist" import { useMarked } from "@opencode-ai/ui/context/marked" import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" import { archiveHomeSession } from "./home-session-archive" +import { shouldOpenSessionInBackground } from "./home-session-open" import { showToast } from "@/utils/toast" const HOME_SESSION_LIMIT = 64 @@ -238,6 +239,20 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { return { setViewport, setContentRef, setHeaderRef, update, titleOpacity } } +// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the +// background without navigating, matching browser conventions. +function isBackgroundOpen(event: MouseEvent) { + return shouldOpenSessionInBackground({ + mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), + meta: event.metaKey, + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + }) +} + +type OpenSessionOptions = { background?: boolean } + export function NewHome() { const sync = useServerSync() const layout = useLayout() @@ -373,9 +388,11 @@ export function NewHome() { setState("searchFocused", false) } - function selectSearchSession(session: Session) { - openSession(session) - closeSearch() + function selectSearchSession(session: Session, options?: OpenSessionOptions) { + openSession(session, options) + // Background opens keep the search visible so several results can be + // opened in a row. + if (!options?.background) closeSearch() } command.register("home", () => [ @@ -460,13 +477,17 @@ export function NewHome() { .forEach((directory) => state.project.markViewed(directory)) } - function openSession(session: Session) { + function openSession(session: Session, options?: OpenSessionOptions) { const project = projectForSession(session, projects(), projectByID()) const conn = focusedServer() if (!conn) return const directory = project?.worktree ?? session.directory const ctx = global.ensureServerCtx(conn) ctx.projects.open(directory) + if (options?.background) { + tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + return + } ctx.projects.touch(directory) startTransition(() => { const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) @@ -1031,7 +1052,7 @@ function HomeSessionSearch(props: { onInput: (value: string) => void onFocus: () => void onClose: () => void - onSelect: (session: Session) => void + onSelect: (session: Session, options?: OpenSessionOptions) => void }) { const language = useLanguage() const [store, setStore] = createStore({ active: "" }) @@ -1145,7 +1166,7 @@ function HomeSessionSearch(props: { server={props.server} selected={store.active === homeSessionSearchKey(record)} onHighlight={() => setStore("active", homeSessionSearchKey(record))} - onSelect={(session) => props.onSelect(session)} + onSelect={(session, options) => props.onSelect(session, options)} /> )} @@ -1225,7 +1246,7 @@ function HomeSessionSearchResultRow(props: { server: ServerConnection.Key selected: boolean onHighlight: () => void - onSelect: (session: Session) => void + onSelect: (session: Session, options?: OpenSessionOptions) => void }) { const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const showProjectName = () => props.showProjectName && props.record.projectName @@ -1246,7 +1267,7 @@ function HomeSessionSearchResultRow(props: { group: !!showProjectName(), }} onMouseEnter={() => props.onHighlight()} - onClick={() => props.onSelect(props.record.session)} + onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })} > void + openSession: (session: Session, options?: OpenSessionOptions) => void archiveSession: (session: Session) => Promise }) { const language = useLanguage() @@ -1306,7 +1327,7 @@ function HomeSessionRow(props: { type="button" data-component="home-session-row" class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`} - onClick={() => props.openSession(props.record.session)} + onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })} >