From fd621efaa2e5edfbc3d82c421a68c48ed1f163f7 Mon Sep 17 00:00:00 2001 From: cmliu Date: Fri, 31 Jul 2026 19:30:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E4=BB=A5=E6=94=AF=E6=8C=81=E9=94=AE?= =?UTF-8?q?=E7=9B=98=E5=A4=8D=E5=88=B6=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=9A=E6=B5=81=E8=A7=A3=E7=A0=81=E5=99=A8?= =?UTF-8?q?=E7=9A=84=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/process-manager.ts | 16 +++++++++++++- src/backend/session.ts | 37 +++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 14 deletions(-) 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;