feat: terminal right-click context menu (copy/paste/find/select-all/clear)

This commit is contained in:
Bill committed 2026-09-08 20:50:59 +08:00
1 parent 7d3057ee5d
commit 7f2e4c1110
2 files changed
+174 -5

No files matched your search

+107 -5
View File
@@ -6,7 +6,7 @@ import { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { FolderOpenOutlined, PauseOutlined, SoundOutlined } from '@ant-design/icons'
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
@@ -280,6 +280,10 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const [searchOpen, setSearchOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [resultInfo, setResultInfo] = useState('')
// Right-click context menu: position + whether a selection existed at open
// time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
const [menuHasSel, setMenuHasSel] = useState(false)
const [paste, setPaste] = useState<string | null>(null)
const [dead, setDead] = useState(false)
const [exitCode, setExitCode] = useState(0)
@@ -339,6 +343,40 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
})
}, [sessionId])
const openSearch = useCallback(() => {
searchOpenRef.current = true
setSearchOpen(true)
}, [])
const openContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setMenuHasSel(Boolean(termRef.current?.getSelection()))
// Clamp so the menu never overflows the window edge.
const x = Math.min(e.clientX, window.innerWidth - 216)
const y = Math.min(e.clientY, window.innerHeight - 200)
setMenuPos({ x: Math.max(x, 4), y: Math.max(y, 4) })
}, [])
// Global dismiss: Escape / scroll / window blur close the context menu.
useEffect(() => {
if (!menuPos) return
const close = (): void => setMenuPos(null)
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.stopPropagation()
close()
}
}
window.addEventListener('keydown', onKey, true)
window.addEventListener('wheel', close, true)
window.addEventListener('blur', close)
return () => {
window.removeEventListener('keydown', onKey, true)
window.removeEventListener('wheel', close, true)
window.removeEventListener('blur', close)
}
}, [menuPos])
const closeSearch = useCallback(() => {
searchOpenRef.current = false
searchRef.current?.clearDecorations()
@@ -470,8 +508,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}
const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
if ((e.key === 'f' || e.key === 'F') && mod && !e.altKey) {
searchOpenRef.current = true
setSearchOpen(true)
openSearch()
return false
}
if (isModShiftChord(e, 'c')) {
@@ -521,7 +558,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}
return true
},
[closeSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
[closeSearch, openSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
)
// ---- terminal instance: built once per session ----
@@ -834,8 +871,73 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
searchTermRef.current = value
}, [])
const runMenuAction = useCallback(
(action: () => void) => {
setMenuPos(null)
action()
termRef.current?.focus()
},
[]
)
return (
<div className={terminalClassName} onMouseDown={() => termRef.current?.focus()}>
<div className={terminalClassName} onMouseDown={() => termRef.current?.focus()} onContextMenu={openContextMenu}>
{menuPos && (
<>
<div
className="term-menu-overlay"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuPos(null)
termRef.current?.focus()
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuPos(null)
}}
/>
<div className="term-menu" style={{ left: menuPos.x, top: menuPos.y }}>
<button
type="button"
className="term-menu-item"
disabled={!menuHasSel}
onClick={() => runMenuAction(() => void copySelection())}
>
<CopyOutlined />
<span className="term-menu-label">复制</span>
<kbd>Ctrl+Shift+C</kbd>
</button>
<button
type="button"
className="term-menu-item"
disabled={dead}
onClick={() => runMenuAction(() => void pasteFromClipboard())}
>
<SnippetsOutlined />
<span className="term-menu-label">粘贴</span>
<kbd>Ctrl+Shift+V</kbd>
</button>
<div className="term-menu-sep" />
<button type="button" className="term-menu-item" onClick={() => runMenuAction(openSearch)}>
<SearchOutlined />
<span className="term-menu-label">查找</span>
<kbd>Ctrl+F</kbd>
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.selectAll())}>
<SelectOutlined />
<span className="term-menu-label">全选</span>
<kbd />
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}>
<ClearOutlined />
<span className="term-menu-label">清屏</span>
<kbd />
</button>
</div>
</>
)}
{searchOpen && (
<SearchBar
value={searchTerm}
+67
View File
@@ -118,6 +118,73 @@
white-space: nowrap;
}
/* ---- right-click context menu ---- */
.term-menu-overlay {
position: fixed;
inset: 0;
z-index: 40;
}
.term-menu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 4px;
border: 1px solid var(--chrome-border);
border-radius: 8px;
background: var(--chrome-bg-deep);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
font-size: 12px;
color: var(--chrome-fg);
user-select: none;
}
.term-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--chrome-fg);
font: inherit;
cursor: pointer;
text-align: left;
}
.term-menu-item:hover:not(:disabled),
.term-menu-item:focus-visible {
background: var(--chrome-hover);
color: var(--chrome-accent);
}
.term-menu-item:disabled {
opacity: 0.4;
cursor: default;
}
.term-menu-label {
flex: 1;
text-align: left;
}
.term-menu-item > kbd {
border: none;
background: transparent;
color: var(--chrome-fg);
opacity: 0.55;
font: inherit;
font-size: 11px;
}
.term-menu-sep {
height: 1px;
margin: 4px 8px;
background: var(--chrome-border);
}
/* ---- session log record / open-dir buttons (floating, top-left) ---- */
.term-recbar {
position: absolute;