diff --git a/frontend/src/process-manager.ts b/frontend/src/process-manager.ts index a26bdc0..6f44d5d 100644 --- a/frontend/src/process-manager.ts +++ b/frontend/src/process-manager.ts @@ -219,6 +219,19 @@ export class ProcessManager { return; } }); + // Copyable PID/command cells are keyboard-focusable (tabIndex=0); mirror the click-to-copy + // behavior for Enter/Space so the interaction is operable without a mouse. + this.elements.panel.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ' && event.key !== 'Spacebar') return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + const target = event.target as HTMLElement; + const copyCell = target.closest('[data-process-copy-value]'); + if (!copyCell || !this.elements.tableBody.contains(copyCell)) return; + const value = copyCell.dataset.processCopyValue ?? ''; + if (!value) return; + event.preventDefault(); + void this.copyProcessValue(value, copyCell.dataset.processCopyKind as 'pid' | 'command' | undefined); + }); this.bindKillDialog(); this.render(); } @@ -553,7 +566,8 @@ export class ProcessManager { zh = `结束 PID ${result.pid} 失败`; en = `Failed to terminate PID ${result.pid}`; } - this.bilingualToast(zh, en, 'error'); + const kind: 'info' | 'error' = result.ok ? 'info' : 'error'; + this.bilingualToast(zh, en, kind); } private bilingualToast(zh: string, en: string, kind: 'info' | 'error' = 'info'): void { diff --git a/src/backend/session.ts b/src/backend/session.ts index 32d9d5e..4dbb9e3 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -71,7 +71,8 @@ interface PendingProcessKillChannel { timeout: ReturnType | null; cancelled: boolean; openConfirmed: boolean; - decoder: TextDecoder; + stdoutDecoder: TextDecoder; + stderrDecoder: TextDecoder; stdout: string; stderr: string; stdoutBytes: number; @@ -913,7 +914,8 @@ export class SSHSession { timeout: null, cancelled: false, openConfirmed: false, - decoder: new TextDecoder(), + stdoutDecoder: new TextDecoder(), + stderrDecoder: new TextDecoder(), stdout: '', stderr: '', stdoutBytes: 0, @@ -928,6 +930,7 @@ export class SSHSession { } catch (error) { this.clearPendingProcessKill(channel); this.channels.delete(channelID); + this.pendingProcessKillChannels.delete(channelID); throw error; } } @@ -1029,7 +1032,7 @@ export class SSHSession { private appendKillOutput(kill: PendingProcessKillChannel, output: Uint8Array, isStderr: boolean): void { if (kill.finalized) return; - const text = kill.decoder.decode(output, { stream: true }); + const text = (isStderr ? kill.stderrDecoder : kill.stdoutDecoder).decode(output, { stream: true }); if (!text) return; if (isStderr) { const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stderrBytes; @@ -1050,16 +1053,24 @@ export class SSHSession { if (kill.finalized) return; kill.finalized = true; if (kill.timeout) { clearTimeout(kill.timeout); kill.timeout = null; } - // Flush any decoder tail so callers see a complete (possibly empty) string. - if (!errorMessage) { - const tail = kill.decoder.decode(); - if (tail) { - const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stdoutBytes; - if (remaining > 0) { - const slice = tail.length > remaining ? tail.slice(0, remaining) : tail; - kill.stdout += slice; - kill.stdoutBytes += slice.length; - } + // Flush any decoder tail so callers see a complete (possibly empty) string. Each stream uses + // its own decoder, so an interleaved multibyte sequence can never corrupt the other stream. + const stdoutTail = kill.stdoutDecoder.decode(); + if (stdoutTail) { + const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stdoutBytes; + if (remaining > 0) { + const slice = stdoutTail.length > remaining ? stdoutTail.slice(0, remaining) : stdoutTail; + kill.stdout += slice; + kill.stdoutBytes += slice.length; + } + } + const stderrTail = kill.stderrDecoder.decode(); + if (stderrTail) { + const remaining = PROCESS_KILL_MAX_BUFFER_BYTES - kill.stderrBytes; + if (remaining > 0) { + const slice = stderrTail.length > remaining ? stderrTail.slice(0, remaining) : stderrTail; + kill.stderr += slice; + kill.stderrBytes += slice.length; } } const stdout = kill.stdout;