Skip to content

Commit 82db86e

Browse files
usrnk1Hona
andauthored
feat(desktop): reopen closed tabs and background tab open (#35010)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
1 parent 476e3fc commit 82db86e

8 files changed

Lines changed: 247 additions & 15 deletions

File tree

packages/app/src/components/titlebar.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,9 +374,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
374374
keybind: "mod+w",
375375
hidden: true,
376376
onSelect: () => {
377-
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
377+
tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab))
378378
},
379379
},
380+
{
381+
id: "tab.reopenClosed",
382+
category: language.t("command.category.file"),
383+
title: language.t("command.tab.reopenClosed"),
384+
keybind: "mod+shift+t",
385+
onSelect: () => tabsStoreActions.reopenClosedTab(),
386+
},
380387
{
381388
id: `tab.prev`,
382389
category: "tab",
@@ -464,7 +471,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
464471
}}
465472
onClose={(tab) => {
466473
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
467-
if (index !== -1) tabsStoreActions.removeTab(index)
474+
if (index !== -1) tabsStoreActions.closeTab(index)
468475
}}
469476
onReorder={(keys) => tabsStoreActions.reorder(keys)}
470477
/>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { SessionTab, Tab } from "./tabs"
2+
3+
export type ClosedTab = {
4+
tab: SessionTab
5+
index: number
6+
}
7+
8+
const CLOSED_TAB_LIMIT = 25
9+
10+
// Only session tabs are recorded; closing a draft tab deletes its persisted
11+
// state, so a reopened draft would come back empty anyway.
12+
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] {
13+
if (tab.type !== "session") return stack
14+
return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT)
15+
}
16+
17+
// Pops the most recently closed tab that is not open again,
18+
// discarding stale entries along the way.
19+
export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } {
20+
const remaining = [...stack]
21+
while (remaining.length) {
22+
const entry = remaining.pop()
23+
if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining }
24+
}
25+
return { stack: remaining }
26+
}
27+
28+
export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) {
29+
const removed = new Set(sessionIDs)
30+
return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId))
31+
}
32+
33+
export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) {
34+
if (!active) return undefined
35+
return tabs[index + 1] ?? tabs[index - 1] ?? null
36+
}
37+
38+
function isOpen(tabs: Tab[], tab: SessionTab) {
39+
return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId)
40+
}

packages/app/src/context/tabs.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { describe, expect, test } from "bun:test"
22
import { createRoot, getOwner, onCleanup } from "solid-js"
33
import { createTabMemory } from "./tab-memory"
4+
import {
5+
nextTabAfterClose,
6+
pushClosedTab,
7+
removeClosedTabs,
8+
takeClosedTab,
9+
type ClosedTab,
10+
} from "./closed-tabs"
11+
import type { SessionTab, Tab } from "./tabs"
12+
import type { ServerConnection } from "./server"
13+
14+
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
15+
16+
function sessionTab(sessionId: string): SessionTab {
17+
return { type: "session", server, sessionId }
18+
}
419

520
describe("tab memory", () => {
621
test("keeps state until its tab is removed", () => {
@@ -22,3 +37,75 @@ describe("tab memory", () => {
2237
})
2338
})
2439
})
40+
41+
describe("closed tab stack", () => {
42+
test("records session tabs with their index", () => {
43+
const stack = pushClosedTab([], sessionTab("a"), 2)
44+
45+
expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }])
46+
})
47+
48+
test("ignores draft tabs", () => {
49+
const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" }
50+
51+
expect(pushClosedTab([], draft, 0)).toEqual([])
52+
})
53+
54+
test("caps the stack size", () => {
55+
const stack = Array.from({ length: 30 }, (_, i) => i).reduce<ClosedTab[]>(
56+
(acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i),
57+
[],
58+
)
59+
60+
expect(stack).toHaveLength(25)
61+
expect(stack[0]?.tab.sessionId).toBe("s5")
62+
expect(stack.at(-1)?.tab.sessionId).toBe("s29")
63+
})
64+
65+
test("pops the most recently closed tab", () => {
66+
const stack = [
67+
{ tab: sessionTab("a"), index: 0 },
68+
{ tab: sessionTab("b"), index: 1 },
69+
]
70+
const result = takeClosedTab(stack, [])
71+
72+
expect(result.entry?.tab.sessionId).toBe("b")
73+
expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }])
74+
})
75+
76+
test("skips entries whose tab is already open", () => {
77+
const stack = [
78+
{ tab: sessionTab("a"), index: 0 },
79+
{ tab: sessionTab("b"), index: 1 },
80+
]
81+
const result = takeClosedTab(stack, [sessionTab("b")])
82+
83+
expect(result.entry?.tab.sessionId).toBe("a")
84+
expect(result.stack).toEqual([])
85+
})
86+
87+
test("returns no entry when everything is open or empty", () => {
88+
expect(takeClosedTab([], []).entry).toBeUndefined()
89+
90+
const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")])
91+
expect(result.entry).toBeUndefined()
92+
expect(result.stack).toEqual([])
93+
})
94+
95+
test("purges removed sessions", () => {
96+
const stack = [
97+
{ tab: sessionTab("a"), index: 0 },
98+
{ tab: sessionTab("b"), index: 1 },
99+
]
100+
101+
expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }])
102+
})
103+
104+
test("does not navigate when a background tab closes", () => {
105+
const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")]
106+
107+
expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined()
108+
expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c"))
109+
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
110+
})
111+
})

packages/app/src/context/tabs.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { uuid } from "@/utils/uuid"
1010
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
1111
import { sessionHref } from "@/utils/session-route"
1212
import { createTabMemory } from "./tab-memory"
13+
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
1314

1415
export type SessionTab = {
1516
type: "session"
@@ -63,6 +64,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
6364
createStore<Tab[]>([]),
6465
)
6566
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
67+
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
6668

6769
const params = useParams()
6870
const navigate = useNavigate()
@@ -87,6 +89,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
8789
})
8890
}
8991

92+
const updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => {
93+
const apply = () => setClosed((stack) => update(stack))
94+
if (closedReady()) {
95+
apply()
96+
return
97+
}
98+
void closedReady.promise?.then(apply)
99+
}
100+
90101
const removeDraftPersisted = (draftID: string) => {
91102
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
92103
}
@@ -106,6 +117,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
106117
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
107118
})
108119

120+
createEffect(() => {
121+
if (!closedReady()) return
122+
const servers = new Set(server.list.map(ServerConnection.key))
123+
const next = closed.filter((entry) => servers.has(entry.tab.server))
124+
if (next.length !== closed.length) setClosed(() => next)
125+
})
126+
109127
const navigateTab = (tab: Tab) => {
110128
const href = tabHref(tab)
111129
setRecentKey(tabKey(tab))
@@ -117,17 +135,19 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
117135
if (!tab) return
118136
const key = tabKey(tab)
119137
const draftID = tab.type === "draft" ? tab.draftID : undefined
120-
const nextTab = store[index + 1] ?? store[index - 1]
138+
const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
121139
closing.add(key)
122140
void startTransition(() => {
123141
setStore(
124142
produce((tabs) => {
125143
tabs.splice(index, 1)
126144
}),
127145
)
128-
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
146+
if (nextTab === null) {
147+
setRecentKey(undefined)
148+
navigate("/")
149+
}
129150
if (nextTab) navigateTab(nextTab)
130-
else navigate("/")
131151
}).finally(() => closing.delete(key))
132152
memory.remove(key)
133153
if (draftID) removeDraftPersisted(draftID)
@@ -201,13 +221,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
201221
removeDraftPersisted(draftID)
202222
},
203223
removeTab,
224+
// User-initiated close: records the tab so it can be reopened.
225+
// Cleanup paths (missing sessions, archive, server removal) go through
226+
// removeTab and friends directly and are not recorded.
227+
closeTab(index: number) {
228+
const tab = store[index]
229+
if (!tab) return
230+
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
231+
removeTab(index)
232+
},
233+
reopenClosedTab() {
234+
if (!closedReady()) {
235+
void closedReady.promise?.then(() => actions.reopenClosedTab())
236+
return
237+
}
238+
const result = takeClosedTab(closed, store)
239+
if (result.stack.length === closed.length) return
240+
setClosed(() => result.stack)
241+
const entry = result.entry
242+
if (!entry) return
243+
const index = Math.min(entry.index, store.length)
244+
void startTransition(() => {
245+
setStore(
246+
produce((tabs) => {
247+
if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return
248+
tabs.splice(index, 0, entry.tab)
249+
}),
250+
)
251+
navigateTab(entry.tab)
252+
})
253+
},
204254
removeSessionTab(input: Omit<SessionTab, "type">) {
255+
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
205256
const index = store.findIndex(
206257
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
207258
)
208259
if (index !== -1) removeTab(index)
209260
},
210261
removeServer(key: ServerConnection.Key) {
262+
updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key))
211263
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
212264
const removed = store.filter((tab) => tab.server === key).map(tabKey)
213265
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
@@ -218,6 +270,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
218270
},
219271
removeSessions: (input: SessionTabsRemovedDetail) => {
220272
const targetServer = input.server ?? server.key
273+
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
221274
const removed = store
222275
.filter(
223276
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),

packages/app/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const dict = {
4848
"command.session.new": "New session",
4949
"command.file.open": "Open file",
5050
"command.tab.close": "Close tab",
51+
"command.tab.reopenClosed": "Reopen closed tab",
5152
"command.context.addSelection": "Add selection to context",
5253
"command.context.addSelection.description": "Add selected lines from the current file",
5354
"command.input.focus": "Focus input",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { shouldOpenSessionInBackground } from "./home-session-open"
3+
4+
describe("shouldOpenSessionInBackground", () => {
5+
test("requires only the platform primary modifier", () => {
6+
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
7+
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
8+
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
9+
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
10+
expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
11+
})
12+
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function shouldOpenSessionInBackground(input: {
2+
mac: boolean
3+
meta: boolean
4+
ctrl: boolean
5+
shift: boolean
6+
alt: boolean
7+
}) {
8+
if (input.shift || input.alt) return false
9+
if (input.mac) return input.meta && !input.ctrl
10+
return input.ctrl && !input.meta
11+
}

0 commit comments

Comments
 (0)