fix: paste through xterm so large multi-line blocks insert instead of executing
confirmPaste wrote the raw text to the pty, so a large paste bypassed bracketed paste and PowerShell ran it line by line; it also skipped onData, which is why history/cwd tracking needed a separate mirror. Now the text goes through term.paste(): xterm adds the \x1b[200~ markers when the shell enabled bracketed paste (one edit, no execution) and the normal onData path restores tracking for free. The risky-paste bar becomes a proper dialog (确认粘贴 / 本次会话不再提示 / 关闭后续粘贴检测), matching the behaviour users expect from other terminals.
This commit is contained in:
1 parent
cbf4c224fb
commit
d7abbaad9c
2 files changed
+70
-83
No files matched your search
@@ -7,6 +7,7 @@ import { WebglAddon } from '@xterm/addon-webgl'
|
|||||||
import { SearchAddon } from '@xterm/addon-search'
|
import { SearchAddon } from '@xterm/addon-search'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
|
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
|
||||||
|
import { Checkbox, Modal } from 'antd'
|
||||||
import { getThemeById } from '@shared/theme'
|
import { getThemeById } from '@shared/theme'
|
||||||
import type { CommandItem } from '@shared/commands'
|
import type { CommandItem } from '@shared/commands'
|
||||||
import type { TerminalSettings } from '@shared/settings'
|
import type { TerminalSettings } from '@shared/settings'
|
||||||
@@ -37,6 +38,13 @@ function isRiskyPaste(text: string): boolean {
|
|||||||
return text.length > 100
|
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<NonNullable<Parameters<SearchAddon['findNext']>[1]>['decorations']> = {
|
const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNext']>[1]>['decorations']> = {
|
||||||
matchBackground: '#3b8eea',
|
matchBackground: '#3b8eea',
|
||||||
matchBorder: '#2d2d2d',
|
matchBorder: '#2d2d2d',
|
||||||
@@ -306,7 +314,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
// time (decides if 复制 is enabled).
|
// time (decides if 复制 is enabled).
|
||||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
|
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
const [menuHasSel, setMenuHasSel] = useState(false)
|
const [menuHasSel, setMenuHasSel] = useState(false)
|
||||||
const [paste, setPaste] = useState<string | null>(null)
|
const [paste, setPaste] = useState<{ noPrompt: boolean; disableDetection: boolean } | null>(null)
|
||||||
const [dead, setDead] = useState(false)
|
const [dead, setDead] = useState(false)
|
||||||
const [exitCode, setExitCode] = useState(0)
|
const [exitCode, setExitCode] = useState(0)
|
||||||
const [recording, setRecording] = useState(false)
|
const [recording, setRecording] = useState(false)
|
||||||
@@ -457,40 +465,20 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
[sessionId]
|
[sessionId]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Pasted text is written straight to the pty (confirmPaste → writeBroadcast),
|
|
||||||
* bypassing xterm's onData, so the line buffer / history / cwd tracker would
|
|
||||||
* never see it — a pasted `cd X` used to be recorded as the bare prefix typed
|
|
||||||
* before it. Mirror the shell: each completed line is a submitted command,
|
|
||||||
* the trailing partial line stays pending in the buffer.
|
|
||||||
*/
|
|
||||||
const trackPastedText = useCallback(
|
|
||||||
(data: string): void => {
|
|
||||||
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(
|
const confirmPaste = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
const data = nativePasteRef.current ?? text
|
const data = nativePasteRef.current ?? text
|
||||||
nativePasteRef.current = null
|
nativePasteRef.current = null
|
||||||
setPaste(null)
|
setPaste(null)
|
||||||
if (!deadRef.current) {
|
// Route through xterm's own paste: when the shell asked for bracketed
|
||||||
writeBroadcast(sessionId, data)
|
// paste (PSReadLine does), the text is wrapped in \x1b[200~ / \x1b[201~
|
||||||
trackPastedText(data)
|
// 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()
|
termRef.current?.focus()
|
||||||
},
|
},
|
||||||
[sessionId, trackPastedText]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const cancelPaste = useCallback(() => {
|
const cancelPaste = useCallback(() => {
|
||||||
@@ -523,9 +511,14 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
|
|
||||||
const beginPaste = useCallback(
|
const beginPaste = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) {
|
if (
|
||||||
|
!deadRef.current &&
|
||||||
|
settings.terminal.pasteRiskConfirm &&
|
||||||
|
!pasteConfirmedForSession &&
|
||||||
|
isRiskyPaste(text)
|
||||||
|
) {
|
||||||
nativePasteRef.current = text
|
nativePasteRef.current = text
|
||||||
setPaste(text)
|
setPaste({ noPrompt: false, disableDetection: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
confirmPaste(text)
|
confirmPaste(text)
|
||||||
@@ -533,6 +526,15 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
[settings.terminal.pasteRiskConfirm, confirmPaste]
|
[settings.terminal.pasteRiskConfirm, confirmPaste]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Confirm the risky-paste dialog: apply its choices, then paste. */
|
||||||
|
const acceptPaste = useCallback((): void => {
|
||||||
|
if (paste?.noPrompt) pasteConfirmedForSession = true
|
||||||
|
if (paste?.disableDetection) {
|
||||||
|
void useSettingsStore.getState().updateTerminal({ pasteRiskConfirm: false })
|
||||||
|
}
|
||||||
|
confirmPaste(nativePasteRef.current ?? '')
|
||||||
|
}, [paste, confirmPaste])
|
||||||
|
|
||||||
const copySelection = useCallback(async () => {
|
const copySelection = useCallback(async () => {
|
||||||
const term = termRef.current
|
const term = termRef.current
|
||||||
if (!term) return
|
if (!term) return
|
||||||
@@ -1103,17 +1105,33 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
<FolderOpenOutlined />
|
<FolderOpenOutlined />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{paste !== null && !dead && (
|
<Modal
|
||||||
<div className="term-pastebar">
|
open={paste !== null}
|
||||||
<span className="term-pastebar-text">检测到多行/长文本粘贴</span>
|
title="确认粘贴"
|
||||||
<button type="button" className="term-pastebar-btn term-pastebar-btn-ok" onClick={() => confirmPaste(paste)}>
|
okText="粘贴"
|
||||||
粘贴
|
cancelText="取消"
|
||||||
</button>
|
width={420}
|
||||||
<button type="button" className="term-pastebar-btn term-pastebar-btn-cancel" onClick={cancelPaste}>
|
maskClosable={false}
|
||||||
取消
|
onOk={acceptPaste}
|
||||||
</button>
|
onCancel={cancelPaste}
|
||||||
|
>
|
||||||
|
<div className="term-paste-confirm">
|
||||||
|
<p>检测到多行或潜在危险命令,确定要粘贴到终端吗?</p>
|
||||||
|
<Checkbox
|
||||||
|
checked={paste?.noPrompt ?? false}
|
||||||
|
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
|
||||||
|
>
|
||||||
|
本次会话不再提示
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
checked={paste?.disableDetection ?? false}
|
||||||
|
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
|
||||||
|
>
|
||||||
|
关闭后续粘贴检测
|
||||||
|
</Checkbox>
|
||||||
|
<p className="term-paste-confirm-hint">可在通用终端设置中重新开启或切换检测策略。</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
{/* Relative-positioned wrapper around the xterm surface: the suggestion
|
{/* Relative-positioned wrapper around the xterm surface: the suggestion
|
||||||
popup anchors to this box, so its coordinates start at the terminal
|
popup anchors to this box, so its coordinates start at the terminal
|
||||||
content rows (the search bar above never offsets the top). */}
|
content rows (the search bar above never offsets the top). */}
|
||||||
|
|||||||
@@ -328,51 +328,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- paste risk confirm bar ---- */
|
/* ---- 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;
|
display: flex;
|
||||||
align-items: center;
|
margin-bottom: 8px;
|
||||||
gap: 8px;
|
}
|
||||||
flex: none;
|
|
||||||
padding: 4px 8px;
|
.term-paste-confirm-hint {
|
||||||
background: #3a2e1a;
|
margin: 12px 0 0 !important;
|
||||||
border-bottom: 1px solid #5c4a26;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #e8d9a8;
|
opacity: 0.65;
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- terminal docked area (flex:1) ---- */
|
/* ---- terminal docked area (flex:1) ---- */
|
||||||
|
|||||||
Reference in new issue
Block a user