feat(desktop): reopen closed tabs and background tab open (#35010)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
476e3fc382
commit
82db86e2a1
8 changed files with 247 additions and 15 deletions
40
packages/app/src/context/closed-tabs.ts
Normal file
40
packages/app/src/context/closed-tabs.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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<ClosedTab[]>(
|
||||
(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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<Tab[]>([]),
|
||||
)
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
|
||||
|
||||
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<SessionTab, "type">) {
|
||||
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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue