diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index d1df67a..655a7d7 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -7,6 +7,7 @@ import { WebglAddon } from '@xterm/addon-webgl' import { SearchAddon } from '@xterm/addon-search' import '@xterm/xterm/css/xterm.css' import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' +import { Checkbox, Modal } from 'antd' import { getThemeById } from '@shared/theme' import type { CommandItem } from '@shared/commands' import type { TerminalSettings } from '@shared/settings' @@ -37,6 +38,13 @@ function isRiskyPaste(text: string): boolean { return text.length > 100 } +/** + * "本次会话不再提示" from the paste confirmation dialog. Deliberately in memory + * only: it lasts until the app exits and never becomes a stored preference + * (that is what 关闭后续粘贴检测 in the dialog is for). + */ +let pasteConfirmedForSession = false + const SEARCH_DECORATIONS: NonNullable[1]>['decorations']> = { matchBackground: '#3b8eea', matchBorder: '#2d2d2d', @@ -306,7 +314,7 @@ export const TerminalView: ForwardRefExoticComponent(null) const [menuHasSel, setMenuHasSel] = useState(false) - const [paste, setPaste] = useState(null) + const [paste, setPaste] = useState<{ noPrompt: boolean; disableDetection: boolean } | null>(null) const [dead, setDead] = useState(false) const [exitCode, setExitCode] = useState(0) const [recording, setRecording] = useState(false) @@ -457,40 +465,20 @@ export const TerminalView: ForwardRefExoticComponent { - if (termRef.current?.buffer.active.type === 'alternate') return - const parts = data.replace(/\r\n/g, '\n').split('\n') - const tail = parts.pop() ?? '' - for (const line of parts) { - const t = line.trim() - lineBufRef.current = '' - if (t) trackSubmittedLine(t) - } - if (tail) lineBufRef.current += tail - }, - [trackSubmittedLine] - ) - const confirmPaste = useCallback( (text: string) => { const data = nativePasteRef.current ?? text nativePasteRef.current = null setPaste(null) - if (!deadRef.current) { - writeBroadcast(sessionId, data) - trackPastedText(data) - } + // Route through xterm's own paste: when the shell asked for bracketed + // paste (PSReadLine does), the text is wrapped in \x1b[200~ / \x1b[201~ + // and inserted as ONE edit instead of executing line by line — which is + // what a large multi-line paste needs. It also feeds onData, so the line + // buffer, command history and cwd memory see the pasted text. + if (!deadRef.current) termRef.current?.paste(data) termRef.current?.focus() }, - [sessionId, trackPastedText] + [] ) const cancelPaste = useCallback(() => { @@ -523,9 +511,14 @@ export const TerminalView: ForwardRefExoticComponent { - if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) { + if ( + !deadRef.current && + settings.terminal.pasteRiskConfirm && + !pasteConfirmedForSession && + isRiskyPaste(text) + ) { nativePasteRef.current = text - setPaste(text) + setPaste({ noPrompt: false, disableDetection: false }) return } confirmPaste(text) @@ -533,6 +526,15 @@ export const TerminalView: ForwardRefExoticComponent { + if (paste?.noPrompt) pasteConfirmedForSession = true + if (paste?.disableDetection) { + void useSettingsStore.getState().updateTerminal({ pasteRiskConfirm: false }) + } + confirmPaste(nativePasteRef.current ?? '') + }, [paste, confirmPaste]) + const copySelection = useCallback(async () => { const term = termRef.current if (!term) return @@ -1103,17 +1105,33 @@ export const TerminalView: ForwardRefExoticComponent - {paste !== null && !dead && ( -
- 检测到多行/长文本粘贴 - - + +
+

检测到多行或潜在危险命令,确定要粘贴到终端吗?

+ setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))} + > + 本次会话不再提示 + + setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))} + > + 关闭后续粘贴检测 + +

可在通用终端设置中重新开启或切换检测策略。

- )} +
{/* Relative-positioned wrapper around the xterm surface: the suggestion popup anchors to this box, so its coordinates start at the terminal content rows (the search bar above never offsets the top). */} diff --git a/src/renderer/src/terminal/terminal.css b/src/renderer/src/terminal/terminal.css index b3b6fa4..9202cbe 100644 --- a/src/renderer/src/terminal/terminal.css +++ b/src/renderer/src/terminal/terminal.css @@ -328,51 +328,20 @@ } /* ---- paste risk confirm bar ---- */ -.term-pastebar { +/* ---- risky-paste confirmation dialog body ---- */ +.term-paste-confirm p { + margin: 0 0 12px; +} + +.term-paste-confirm .ant-checkbox-wrapper { display: flex; - align-items: center; - gap: 8px; - flex: none; - padding: 4px 8px; - background: #3a2e1a; - border-bottom: 1px solid #5c4a26; + margin-bottom: 8px; +} + +.term-paste-confirm-hint { + margin: 12px 0 0 !important; font-size: 12px; - color: #e8d9a8; -} - -.term-pastebar-text { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.term-pastebar-btn { - flex: none; - padding: 3px 12px; - border: none; - border-radius: 4px; - cursor: pointer; - font: inherit; -} - -.term-pastebar-btn-ok { - background: var(--chrome-accent); - color: #fff; -} - -.term-pastebar-btn-ok:hover { - filter: brightness(1.1); -} - -.term-pastebar-btn-cancel { - background: transparent; - color: var(--chrome-fg); -} - -.term-pastebar-btn-cancel:hover { - background: rgba(255, 255, 255, 0.1); + opacity: 0.65; } /* ---- terminal docked area (flex:1) ---- */