mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 22:59:53 +00:00
feat(tui): reopen closed session tabs (#39731)
This commit is contained in:
parent
8ebc4c6f85
commit
cc883058df
5 changed files with 105 additions and 0 deletions
|
|
@ -105,6 +105,7 @@ const sessionTabBindingCommands = [
|
|||
"session.tab.next_unread",
|
||||
"session.tab.previous_unread",
|
||||
"session.tab.close",
|
||||
"session.tab.reopen",
|
||||
"session.tab.select.1",
|
||||
"session.tab.select.2",
|
||||
"session.tab.select.3",
|
||||
|
|
@ -721,6 +722,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.close(),
|
||||
},
|
||||
{
|
||||
name: "session.tab.reopen",
|
||||
title: "Reopen closed tab",
|
||||
category: "Session",
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.reopen(),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.tab.select.${i + 1}`,
|
||||
title: `Switch to tab ${i + 1}`,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export const Definitions = {
|
|||
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
|
||||
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
|
||||
session_tab_close: keybind("<leader>w", "Close current tab"),
|
||||
session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
|
||||
session_timeline: keybind("<leader>g", "Show session timeline"),
|
||||
session_fork: keybind("none", "Fork session from message"),
|
||||
session_rename: keybind("ctrl+r", "Rename session"),
|
||||
|
|
@ -297,6 +298,7 @@ export const CommandMap = {
|
|||
session_tab_next_unread: "session.tab.next_unread",
|
||||
session_tab_previous_unread: "session.tab.previous_unread",
|
||||
session_tab_close: "session.tab.close",
|
||||
session_tab_reopen: "session.tab.reopen",
|
||||
session_timeline: "session.timeline",
|
||||
session_fork: "session.fork",
|
||||
session_rename: "session.rename",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,39 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string)
|
|||
}
|
||||
}
|
||||
|
||||
export type ClosedSessionTab = {
|
||||
tab: SessionTab
|
||||
index: number
|
||||
}
|
||||
|
||||
const CLOSED_SESSION_TAB_LIMIT = 10
|
||||
|
||||
export function recordClosedSessionTab(
|
||||
stack: readonly ClosedSessionTab[],
|
||||
tab: SessionTab,
|
||||
index: number,
|
||||
): ClosedSessionTab[] {
|
||||
return [...stack.filter((entry) => entry.tab.sessionID !== tab.sessionID), { tab, index }].slice(
|
||||
-CLOSED_SESSION_TAB_LIMIT,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the most recently closed tab that is not already open and restore it at its original
|
||||
* position. Entries for already-open sessions are consumed so repeated reopens walk the stack.
|
||||
*/
|
||||
export function reopenSessionTab(stack: readonly ClosedSessionTab[], tabs: readonly SessionTab[]) {
|
||||
const remaining = [...stack]
|
||||
while (remaining.length > 0) {
|
||||
const entry = remaining.pop()!
|
||||
if (tabs.some((tab) => tab.sessionID === entry.tab.sessionID)) continue
|
||||
const next = [...tabs]
|
||||
next.splice(Math.min(entry.index, tabs.length), 0, entry.tab)
|
||||
return { stack: remaining, tabs: next, sessionID: entry.tab.sessionID }
|
||||
}
|
||||
return { stack: remaining, tabs: undefined, sessionID: undefined }
|
||||
}
|
||||
|
||||
export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] {
|
||||
const from = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
||||
const to = Math.max(0, Math.min(tabs.length - 1, index))
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ import {
|
|||
moveSessionTab,
|
||||
moveSessionTabHistory,
|
||||
openSessionTab,
|
||||
recordClosedSessionTab,
|
||||
recordSessionTabHistory,
|
||||
reopenSessionTab,
|
||||
type ClosedSessionTab,
|
||||
type SessionTab,
|
||||
type SessionTabHistory,
|
||||
type SessionTabUnread,
|
||||
|
|
@ -57,6 +60,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
const fallback = empty()
|
||||
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
|
||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
||||
let closedTabs: ClosedSessionTab[] = []
|
||||
|
||||
function state() {
|
||||
if (config.tabs?.scope === "global") return store.global
|
||||
|
|
@ -193,6 +198,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
)
|
||||
onCleanup(
|
||||
event.on("session.deleted", (evt) => {
|
||||
const target = root(evt.data.sessionID)
|
||||
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
|
||||
remove(evt.data.sessionID, enabled())
|
||||
}),
|
||||
)
|
||||
|
|
@ -244,8 +251,22 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
|
||||
return
|
||||
}
|
||||
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
|
||||
const tab = state().tabs[index]
|
||||
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
|
||||
remove(target, true)
|
||||
},
|
||||
reopen() {
|
||||
if (!enabled()) return
|
||||
const result = reopenSessionTab(closedTabs, state().tabs)
|
||||
closedTabs = result.stack
|
||||
const tabs = result.tabs
|
||||
if (!tabs || !result.sessionID) return
|
||||
update((draft) => {
|
||||
draft.tabs = tabs
|
||||
})
|
||||
route.navigate({ type: "session", sessionID: result.sessionID })
|
||||
},
|
||||
move(sessionID: string, index: number) {
|
||||
if (!enabled()) return
|
||||
const session = root(sessionID)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import {
|
|||
moveSessionTab,
|
||||
moveSessionTabHistory,
|
||||
openSessionTab,
|
||||
recordClosedSessionTab,
|
||||
recordSessionTabHistory,
|
||||
reopenSessionTab,
|
||||
seedSessionTabMotion,
|
||||
sessionTabComplete,
|
||||
sessionTabOverflowWidth,
|
||||
|
|
@ -136,6 +138,45 @@ describe("session tabs", () => {
|
|||
expect(moveSessionTabHistory(current, closed.tabs, "b", -1).sessionID).toBe("c")
|
||||
})
|
||||
|
||||
test("reopens the most recently closed tab at its original position", () => {
|
||||
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
|
||||
const stack = recordClosedSessionTab([], { sessionID: "b", title: "Middle" }, 1)
|
||||
const reopened = reopenSessionTab(stack, [{ sessionID: "a" }, { sessionID: "c" }])
|
||||
|
||||
expect(reopened.sessionID).toBe("b")
|
||||
expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b", title: "Middle" }, { sessionID: "c" }])
|
||||
expect(reopened.stack).toEqual([])
|
||||
expect(reopenSessionTab([], tabs)).toEqual({ stack: [], tabs: undefined, sessionID: undefined })
|
||||
})
|
||||
|
||||
test("skips and consumes closed entries that are already open", () => {
|
||||
const stack = [
|
||||
{ tab: { sessionID: "a" }, index: 0 },
|
||||
{ tab: { sessionID: "b" }, index: 1 },
|
||||
]
|
||||
const reopened = reopenSessionTab(stack, [{ sessionID: "b" }])
|
||||
|
||||
expect(reopened.sessionID).toBe("a")
|
||||
expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b" }])
|
||||
expect(reopened.stack).toEqual([])
|
||||
})
|
||||
|
||||
test("clamps restored positions and keeps one entry per session", () => {
|
||||
const twice = recordClosedSessionTab(recordClosedSessionTab([], { sessionID: "a" }, 5), { sessionID: "a" }, 2)
|
||||
expect(twice).toEqual([{ tab: { sessionID: "a" }, index: 2 }])
|
||||
|
||||
const reopened = reopenSessionTab(twice, [{ sessionID: "b" }])
|
||||
expect(reopened.tabs).toEqual([{ sessionID: "b" }, { sessionID: "a" }])
|
||||
|
||||
const overflow = Array.from({ length: 12 }, (_, index) => ({ sessionID: String(index) })).reduce(
|
||||
(stack, tab, index) => recordClosedSessionTab(stack, tab, index),
|
||||
twice,
|
||||
)
|
||||
expect(overflow).toHaveLength(10)
|
||||
expect(overflow.at(-1)?.tab.sessionID).toBe("11")
|
||||
expect(overflow[0]?.tab.sessionID).toBe("2")
|
||||
})
|
||||
|
||||
test("reveals completion activity only after session work becomes idle", () => {
|
||||
expect(sessionTabComplete("activity", true)).toBe(false)
|
||||
expect(sessionTabComplete("activity", false)).toBe(true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue