Skip to content

Commit d7a4647

Browse files
committed
fix(desktop): replace exited terminal sessions
1 parent 984fba7 commit d7a4647

3 files changed

Lines changed: 79 additions & 6 deletions

File tree

apps/desktop/src/main/terminal/index.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ export class TerminalService {
155155
/** Insertion-ordered, which is also the tab order the user sees. */
156156
private readonly sessions = new Map<string, TerminalSession>()
157157
private activeId: string | null = null
158+
/** True while tearing every shell down, so an exit does not respawn one. */
159+
private disposing = false
158160
private nextId = 1
159161
private sink: TerminalSink | null = null
160162
private lastEmittedTabs: string | null = null
@@ -273,14 +275,26 @@ export class TerminalService {
273275
* every count — the same shape as closing a browser's last tab, which
274276
* leaves you a tab rather than an empty window.
275277
*
276-
* A shell that exits on its own is different and still closes the panel:
277-
* that is the user saying they are done, not asking for a clean one.
278+
* A shell that ends by itself — `exit`, or Ctrl-D — goes the same way. It
279+
* leaves behind a session that can no longer do anything, so it has to be
280+
* reaped either way; treating it as a close means the last one is replaced
281+
* rather than leaving a dead tab that cannot be typed into.
278282
*/
279283
closeTerminal(terminalId: string): TerminalTabsState {
280-
const session = this.sessions.get(terminalId)
281-
if (!session) {
284+
if (!this.sessions.has(terminalId)) {
282285
throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(terminalId))
283286
}
287+
return this.retire(terminalId)
288+
}
289+
290+
/**
291+
* Drops a terminal and decides what replaces it. Closing and exiting share
292+
* this so the two cannot drift into different answers for "what happens to
293+
* the last one".
294+
*/
295+
private retire(terminalId: string): TerminalTabsState {
296+
const session = this.sessions.get(terminalId)
297+
if (!session) return this.getTabs()
284298
const closedCwd = session.currentCwd
285299
const cols = session.cols
286300
const rows = session.rows
@@ -362,11 +376,13 @@ export class TerminalService {
362376
}
363377

364378
dispose(): void {
379+
this.disposing = true
365380
this.stopCwdWatch()
366381
for (const session of this.sessions.values()) session.dispose()
367382
this.sessions.clear()
368383
this.tmuxCache.clear()
369384
this.activeId = null
385+
this.disposing = false
370386
}
371387

372388
async executeTool(
@@ -756,6 +772,12 @@ export class TerminalService {
756772
this.emitTabs()
757773
},
758774
onCommand: (event) => this.sink?.command(event),
775+
onExit: (id) => {
776+
// Not during shutdown: every shell is ending then, and replacing
777+
// the last one would spawn a shell as the app is closing.
778+
if (this.disposing) return
779+
this.retire(id)
780+
},
759781
},
760782
})
761783
this.sessions.set(terminalId, session)

apps/desktop/src/main/terminal/service.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { TerminalService } from '@/main/terminal'
33

44
/** Stub sessions by terminal id, populated by the mock below. */
55
const { stubSessions } = vi.hoisted(() => ({
6-
stubSessions: new Map<string, { setBusy(busy: boolean): void }>(),
6+
stubSessions: new Map<string, { setBusy(busy: boolean): void; exit(): void }>(),
77
}))
88

99
/**
@@ -19,12 +19,26 @@ vi.mock('@/main/terminal/session', async () => {
1919
return {
2020
...actual,
2121
TerminalSession: {
22-
create: ({ terminalId, cwd, cols, rows }: Record<string, never> & { terminalId: string }) => {
22+
create: ({
23+
terminalId,
24+
cwd,
25+
cols,
26+
rows,
27+
callbacks,
28+
}: Record<string, never> & {
29+
terminalId: string
30+
callbacks: { onExit(terminalId: string): void }
31+
}) => {
2332
const state = { cwd, disposed: false, busy: false }
2433
const stub = {
2534
setBusy: (busy: boolean) => {
2635
state.busy = busy
2736
},
37+
/** Stands in for the user running `exit` or pressing Ctrl-D. */
38+
exit: () => {
39+
state.disposed = true
40+
callbacks.onExit(terminalId)
41+
},
2842
terminalId,
2943
cols,
3044
rows,
@@ -237,3 +251,33 @@ describe('closing', () => {
237251
expect(terminal.getTabs().tabs).toHaveLength(1)
238252
})
239253
})
254+
255+
describe('a shell that ends by itself', () => {
256+
it('replaces the only terminal instead of leaving a dead tab', () => {
257+
const terminal = service()
258+
const { activeTerminalId } = terminal.start({ cols: 80, rows: 24 })
259+
const original = activeTerminalId as string
260+
261+
stubSessions.get(original)?.exit()
262+
263+
// The panel's whole content is the terminal, so an exited last shell used
264+
// to sit there unusable — nothing to type into and no way to get it back.
265+
const after = terminal.getTabs()
266+
expect(after.tabs).toHaveLength(1)
267+
expect(after.activeTerminalId).not.toBe(original)
268+
expect(after.tabs[0]?.terminalId).toBe(after.activeTerminalId)
269+
})
270+
271+
it('removes one of several and activates a neighbour', () => {
272+
const terminal = service()
273+
terminal.start({ cols: 80, rows: 24 })
274+
const second = terminal.openTerminal().activeTerminalId as string
275+
276+
stubSessions.get(second)?.exit()
277+
278+
const after = terminal.getTabs()
279+
expect(after.tabs.map((tab) => tab.terminalId)).not.toContain(second)
280+
expect(after.tabs).toHaveLength(1)
281+
expect(after.activeTerminalId).toBe(after.tabs[0]?.terminalId)
282+
})
283+
})

apps/desktop/src/main/terminal/session.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,12 @@ export interface TerminalSessionCallbacks {
268268
onData(terminalId: string, data: string): void
269269
onState(): void
270270
onCommand(event: TerminalCommandEvent): void
271+
/**
272+
* The shell ended by itself — the user ran `exit`, or pressed Ctrl-D at an
273+
* empty prompt. Distinct from the service disposing the session, which is
274+
* already-known and never reaches here.
275+
*/
276+
onExit(terminalId: string): void
271277
}
272278

273279
export interface TerminalSessionOptions {
@@ -978,6 +984,7 @@ export class TerminalSession {
978984
this.finishCommand(null)
979985
this.cleanupIntegrationDir()
980986
this.emitState()
987+
this.callbacks.onExit(this.terminalId)
981988
}
982989

983990
private emitState(): void {

0 commit comments

Comments
 (0)