From 7f2e4c11106aff4cae4ec4be6076d4b07a7b40a0 Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 8 Sep 2026 20:50:59 +0800 Subject: [PATCH] feat: terminal right-click context menu (copy/paste/find/select-all/clear) --- src/renderer/src/terminal/TerminalView.tsx | 112 ++++++++++++++++++++- src/renderer/src/terminal/terminal.css | 67 ++++++++++++ 2 files changed, 174 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 7801f92..cc16863 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -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(null) + const [menuHasSel, setMenuHasSel] = useState(false) const [paste, setPaste] = useState(null) const [dead, setDead] = useState(false) const [exitCode, setExitCode] = useState(0) @@ -339,6 +343,40 @@ export const TerminalView: ForwardRefExoticComponent { + 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 void) => { + setMenuPos(null) + action() + termRef.current?.focus() + }, + [] + ) + return ( -
termRef.current?.focus()}> +
termRef.current?.focus()} onContextMenu={openContextMenu}> + {menuPos && ( + <> +
{ + e.preventDefault() + e.stopPropagation() + setMenuPos(null) + termRef.current?.focus() + }} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + setMenuPos(null) + }} + /> +
+ + +
+ + + +
+ + )} {searchOpen && ( 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;