fix: wire plain Ctrl/Cmd+V to paste and split the check by line count
Plain Ctrl+V was never the terminal's: it went to the pty as a literal ^V (0x16) — the command history even recorded 'cd \u0016' — so nothing pasted and the confirm dialog never saw it. It is now handled on the terminal host in the capture phase, with preventDefault, feeding the same path as Ctrl+Shift+V. The paste check is now shape-based rather than length-based: two or more lines confirm (a stray newline executes an unreviewed command), a single line pastes straight through unless it is unusually long.
This commit is contained in:
1 parent
77fd241b43
commit
d3d029fe4f
2 files changed
+45
-12
No files matched your search
@@ -270,7 +270,7 @@ export function RenderSettingsTab(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="粘贴风险确认"
|
label="粘贴风险确认"
|
||||||
desc="粘贴前弹出风险确认提示"
|
desc="多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴"
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={settings.terminal.pasteRiskConfirm}
|
checked={settings.terminal.pasteRiskConfirm}
|
||||||
|
|||||||
@@ -32,10 +32,18 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paste-risk heuristic: multi-line or unreasonably long text. */
|
/**
|
||||||
function isRiskyPaste(text: string): boolean {
|
* Paste-risk heuristic, split by shape rather than by raw length:
|
||||||
if (text.includes('\n') || text.includes('\r')) return true
|
* - two or more lines always confirm — a stray newline executes a command
|
||||||
return text.length > 100
|
* the user never reviewed;
|
||||||
|
* - a single line pastes straight through, unless it is unusually long
|
||||||
|
* (those are pasted scripts rather than something typed by hand).
|
||||||
|
*/
|
||||||
|
const SINGLE_LINE_CONFIRM_LENGTH = 1000
|
||||||
|
function needsPasteConfirm(text: string): boolean {
|
||||||
|
const normalized = text.replace(/\r\n?/g, '\n').replace(/\n$/, '')
|
||||||
|
if (normalized.includes('\n')) return true
|
||||||
|
return normalized.length > SINGLE_LINE_CONFIRM_LENGTH
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -511,19 +519,17 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
|
|
||||||
const beginPaste = useCallback(
|
const beginPaste = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
if (
|
// Read the setting at call time: this runs from a long-lived keydown
|
||||||
!deadRef.current &&
|
// listener, so a captured value would go stale after a settings change.
|
||||||
settings.terminal.pasteRiskConfirm &&
|
const confirmOn = useSettingsStore.getState().settings.terminal.pasteRiskConfirm
|
||||||
!pasteConfirmedForSession &&
|
if (!deadRef.current && confirmOn && !pasteConfirmedForSession && needsPasteConfirm(text)) {
|
||||||
isRiskyPaste(text)
|
|
||||||
) {
|
|
||||||
nativePasteRef.current = text
|
nativePasteRef.current = text
|
||||||
setPaste({ noPrompt: false, disableDetection: false })
|
setPaste({ noPrompt: false, disableDetection: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
confirmPaste(text)
|
confirmPaste(text)
|
||||||
},
|
},
|
||||||
[settings.terminal.pasteRiskConfirm, confirmPaste]
|
[confirmPaste]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Confirm the risky-paste dialog: apply its choices, then paste. */
|
/** Confirm the risky-paste dialog: apply its choices, then paste. */
|
||||||
@@ -873,9 +879,36 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
observerRef.current = observer
|
observerRef.current = observer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plain Ctrl/Cmd+V: not a chord xterm handles, so it used to reach the pty
|
||||||
|
// as a literal ^V (0x16) — nothing pasted. Handled here, on the terminal
|
||||||
|
// host in the capture phase, with preventDefault so the pty never sees it
|
||||||
|
// and Chromium's own paste cannot double up. Text follows the same
|
||||||
|
// single-line / multi-line rule as the Ctrl+Shift+V path.
|
||||||
|
const host = hostRef.current
|
||||||
|
const onPasteKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.type !== 'keydown' || e.altKey) return
|
||||||
|
const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
|
||||||
|
if (!mod || e.shiftKey || e.key.toLowerCase() !== 'v') return
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
const isXtermHelper =
|
||||||
|
target instanceof HTMLTextAreaElement && target.classList.contains('xterm-helper-textarea')
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
!isXtermHelper &&
|
||||||
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||||
|
) {
|
||||||
|
return // ordinary inputs keep the browser's native paste
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
void pasteFromClipboard()
|
||||||
|
}
|
||||||
|
host?.addEventListener('keydown', onPasteKey, true)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (carryTimer !== undefined) window.clearTimeout(carryTimer)
|
if (carryTimer !== undefined) window.clearTimeout(carryTimer)
|
||||||
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
|
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
|
||||||
|
host?.removeEventListener('keydown', onPasteKey, true)
|
||||||
for (const unsubscribe of unsubscribes) unsubscribe()
|
for (const unsubscribe of unsubscribes) unsubscribe()
|
||||||
for (const disposable of disposables) disposable.dispose()
|
for (const disposable of disposables) disposable.dispose()
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
|
|||||||
Reference in new issue
Block a user