|
2 | 2 | import type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/client' |
3 | 3 | import type { TerminalPreset, TerminalSessionInfo } from '../types' |
4 | 4 | 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' |
6 | 6 | import { onMount } from 'svelte' |
7 | 7 | import { DOCKS_ACTIVE_STATE_KEY, PLUGIN_ID, PRESETS_STATE_KEY, SESSIONS_STATE_KEY } from '../constants' |
8 | 8 | import TerminalView from './TerminalView.svelte' |
|
31 | 31 | let renamingId = $state<string | null>(null) |
32 | 32 | let presetsOpen = $state(false) |
33 | 33 |
|
| 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 | +
|
34 | 44 | const activeSession = $derived(sessions.find(s => s.id === activeId) ?? null) |
35 | 45 |
|
36 | 46 | // Only own sessions can be killed/removed; hub-aggregated ones are read-only. |
|
218 | 228 | spawn({ presetId: id }) |
219 | 229 | } |
220 | 230 |
|
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 | + */ |
222 | 236 | 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 | + } |
224 | 246 | } |
225 | 247 |
|
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 | + */ |
227 | 253 | 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 | + } |
228 | 266 | rpc.call('devframes:plugin:terminals:remove', { id }).catch(() => {}) |
229 | 267 | } |
230 | 268 |
|
| 269 | + function resolveConfirm(): void { |
| 270 | + const action = confirm?.onConfirm |
| 271 | + confirm = null |
| 272 | + action?.() |
| 273 | + } |
| 274 | +
|
231 | 275 | /** Sweep every stopped session away in one go. */ |
232 | 276 | function clearExited(): void { |
233 | 277 | rpc.call('devframes:plugin:terminals:clear-exited').catch(() => {}) |
|
243 | 287 | node.select() |
244 | 288 | } |
245 | 289 |
|
| 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 | +
|
246 | 308 | function statusDot(status: string): DotState { |
247 | 309 | if (status === 'running') |
248 | 310 | return 'running' |
249 | 311 | return status === 'exited' ? 'idle' : 'error' |
250 | 312 | } |
251 | 313 | </script> |
252 | 314 |
|
| 315 | +<svelte:window onkeydown={onGlobalKey} /> |
| 316 | + |
253 | 317 | {#if connectionStatus !== 'connected'} |
254 | 318 | {@const copy = CONNECTION_COPY[connectionStatus]} |
255 | 319 | <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 | 504 | <TerminalView {rpc} info={s} active={activeId === s.id} {isDark} /> |
441 | 505 | {/each} |
442 | 506 | </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} |
443 | 538 | </div> |
444 | 539 | {/if} |
0 commit comments