Skip to content

Commit 1201a14

Browse files
committed
feat(terminals): confirm before terminating a running process
Killing a process (terminate) or removing a still-running session now opens a confirmation modal, since a live process is being terminated. This applies to both interactive and readonly sessions. Already-stopped sessions are removed immediately without a prompt. The modal is dismissible via backdrop click or Escape and confirmable via Enter.
1 parent d251110 commit 1201a14

2 files changed

Lines changed: 107 additions & 4 deletions

File tree

plugins/terminals/src/client/App.svelte

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/client'
33
import type { TerminalPreset, TerminalSessionInfo } from '../types'
44
import type { DotState } from './design'
5-
import { button, dot, iconButton, nav, navBrand, navTab, tag, toolbar } from './design'
5+
import { button, dot, iconButton, modalBackdrop, modalCard, nav, navBrand, navTab, tag, toolbar } from './design'
66
import { onMount } from 'svelte'
77
import { DOCKS_ACTIVE_STATE_KEY, PLUGIN_ID, PRESETS_STATE_KEY, SESSIONS_STATE_KEY } from '../constants'
88
import TerminalView from './TerminalView.svelte'
@@ -31,6 +31,16 @@
3131
let renamingId = $state<string | null>(null)
3232
let presetsOpen = $state(false)
3333
34+
// Terminating a running process is destructive (its output stops, and for a
35+
// full remove, its scrollback is discarded), so it goes through this modal.
36+
interface ConfirmDialog {
37+
title: string
38+
body: string
39+
confirmLabel: string
40+
onConfirm: () => void
41+
}
42+
let confirm = $state<ConfirmDialog | null>(null)
43+
3444
const activeSession = $derived(sessions.find(s => s.id === activeId) ?? null)
3545
3646
// Only own sessions can be killed/removed; hub-aggregated ones are read-only.
@@ -218,16 +228,50 @@
218228
spawn({ presetId: id })
219229
}
220230
221-
/** Stop the running process but keep the (now stopped) session and its scrollback. */
231+
/**
232+
* Kill a session's running process (interactive or readonly), keeping the
233+
* stopped session and its scrollback. Always confirmed — a live process is
234+
* being terminated.
235+
*/
222236
function killSession(id: string): void {
223-
rpc.call('devframes:plugin:terminals:terminate', { id }).catch(() => {})
237+
const s = sessions.find(x => x.id === id)
238+
if (!s)
239+
return
240+
confirm = {
241+
title: 'Kill process',
242+
body: `Terminate “${displayName(s)}”? The process stops, but the session stays in the list so you can read its output or restart it.`,
243+
confirmLabel: 'Kill process',
244+
onConfirm: () => rpc.call('devframes:plugin:terminals:terminate', { id }).catch(() => {}),
245+
}
224246
}
225247
226-
/** Discard a session entirely — process, stream, and scrollback. */
248+
/**
249+
* Discard a session entirely — process, stream, and scrollback. Removing a
250+
* session whose process is still running terminates it, so that case is
251+
* confirmed; an already-stopped session is dropped immediately.
252+
*/
227253
function removeSession(id: string): void {
254+
const s = sessions.find(x => x.id === id)
255+
if (!s)
256+
return
257+
if (s.status === 'running') {
258+
confirm = {
259+
title: 'Remove terminal',
260+
body: `“${displayName(s)}” is still running. Removing it terminates the process and discards its output.`,
261+
confirmLabel: 'Kill & remove',
262+
onConfirm: () => rpc.call('devframes:plugin:terminals:remove', { id }).catch(() => {}),
263+
}
264+
return
265+
}
228266
rpc.call('devframes:plugin:terminals:remove', { id }).catch(() => {})
229267
}
230268
269+
function resolveConfirm(): void {
270+
const action = confirm?.onConfirm
271+
confirm = null
272+
action?.()
273+
}
274+
231275
/** Sweep every stopped session away in one go. */
232276
function clearExited(): void {
233277
rpc.call('devframes:plugin:terminals:clear-exited').catch(() => {})
@@ -243,13 +287,33 @@
243287
node.select()
244288
}
245289
290+
function autofocus(node: HTMLElement) {
291+
node.focus()
292+
}
293+
294+
// While the confirmation modal is open, Escape cancels and Enter confirms.
295+
function onGlobalKey(e: KeyboardEvent): void {
296+
if (!confirm)
297+
return
298+
if (e.key === 'Escape') {
299+
e.preventDefault()
300+
confirm = null
301+
}
302+
else if (e.key === 'Enter') {
303+
e.preventDefault()
304+
resolveConfirm()
305+
}
306+
}
307+
246308
function statusDot(status: string): DotState {
247309
if (status === 'running')
248310
return 'running'
249311
return status === 'exited' ? 'idle' : 'error'
250312
}
251313
</script>
252314

315+
<svelte:window onkeydown={onGlobalKey} />
316+
253317
{#if connectionStatus !== 'connected'}
254318
{@const copy = CONNECTION_COPY[connectionStatus]}
255319
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-base color-base font-sans p-8 text-center">
@@ -440,5 +504,36 @@
440504
<TerminalView {rpc} info={s} active={activeId === s.id} {isDark} />
441505
{/each}
442506
</div>
507+
508+
{#if confirm}
509+
<!-- svelte-ignore a11y_no_static_element_interactions -->
510+
<div class={modalBackdrop()} role="presentation" onclick={() => (confirm = null)}>
511+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
512+
<div
513+
class={modalCard()}
514+
role="alertdialog"
515+
aria-modal="true"
516+
aria-label={confirm.title}
517+
tabindex="-1"
518+
onclick={e => e.stopPropagation()}
519+
>
520+
<div class="flex items-start gap-3">
521+
<div class="i-ph-warning-duotone text-xl text-error shrink-0 mt-0.5"></div>
522+
<div class="flex flex-col gap-1 min-w-0">
523+
<h2 class="text-sm font-semibold color-base">{confirm.title}</h2>
524+
<p class="text-sm op-mute">{confirm.body}</p>
525+
</div>
526+
</div>
527+
<div class="flex justify-end gap-2">
528+
<button type="button" class={button({ variant: 'outline', size: 'sm' })} onclick={() => (confirm = null)}>
529+
Cancel
530+
</button>
531+
<button type="button" class={button({ variant: 'destructive', size: 'sm' })} use:autofocus onclick={() => resolveConfirm()}>
532+
{confirm.confirmLabel}
533+
</button>
534+
</div>
535+
</div>
536+
</div>
537+
{/if}
443538
</div>
444539
{/if}

plugins/terminals/src/client/design.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ export function card(extra?: string): string {
9595
return cx('flex flex-col rounded-xl border border-base bg-base shadow-sm', extra)
9696
}
9797

98+
export function modalBackdrop(extra?: string): string {
99+
return cx('fixed inset-0 z-modal-backdrop grid place-items-center p-4 bg-black/40 backdrop-blur-sm', extra)
100+
}
101+
102+
export function modalCard(extra?: string): string {
103+
return cx('z-modal-content w-full max-w-sm flex flex-col gap-3 p-4 rounded-xl border border-base bg-base shadow-lg', extra)
104+
}
105+
98106
export function panel(extra?: string): string {
99107
return cx('rounded-lg border border-base bg-base', extra)
100108
}

0 commit comments

Comments
 (0)