Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 5.2k
fix(terminal): right-click paste works in the terminal#5240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -13,6 +13,7 @@ import { | ||
| XIcon, | ||
| } from "lucide-react"; | ||
| import { | ||
| type ContextMenuItem, | ||
| type ResolvedKeybindingsConfig, | ||
| type ScopedThreadRef, | ||
| type ThreadId, | ||
| @@ -32,7 +33,7 @@ import { | ||
| } from "react"; | ||
| import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | ||
| import { Button } from "~/components/ui/button"; | ||
| import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | ||
| import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | ||
| import { cn } from "~/lib/utils"; | ||
| import { type TerminalContextSelection } from "~/lib/terminalContext"; | ||
| import { | ||
| @@ -255,6 +256,49 @@ export function terminalSelectionLineRange(position: { | ||
| }; | ||
| } | ||
| export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; | ||
| /** Post-selection popup: just the two selection actions, always enabled. */ | ||
| export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { | ||
| return [ | ||
| { id: "add-to-chat", label: "Add to chat" }, | ||
| { id: "copy", label: "Copy" }, | ||
| ]; | ||
| } | ||
| /** | ||
| * Right-click menu for the terminal canvas: the selection actions (disabled | ||
| * until a selection exists) plus Paste. Paste is always offered: the browser | ||
| * (and Electron's default editing menu) can only paste into an editable | ||
| * element, so a canvas terminal never gets a usable entry from them. | ||
| */ | ||
| export function terminalContextMenuItems(options: { | ||
| hasSelection: boolean; | ||
| }): ContextMenuItem<TerminalContextMenuAction>[] { | ||
| return [ | ||
| ...terminalSelectionMenuItems().map((item) => ({ | ||
| ...item, | ||
| disabled: !options.hasSelection, | ||
| })), | ||
| { id: "paste", label: "Paste" }, | ||
| ]; | ||
| } | ||
| /** | ||
| * An empty selection change may only cancel a selection-action flow that is | ||
| * still current: a pending popup timer, or an open popup whose request id has | ||
| * not been superseded. A popup already superseded by a right-click keeps its | ||
| * menu promise unsettled for a moment; treating it as active would cancel the | ||
| * newer context-menu flow instead. | ||
| */ | ||
| export function shouldClearTerminalSelectionAction(options: { | ||
| timerPending: boolean; | ||
| openMenuRequestId: number | null; | ||
| currentRequestId: number; | ||
| }): boolean { | ||
| return options.timerPending || options.openMenuRequestId === options.currentRequestId; | ||
| } | ||
| export function shouldHandleTerminalExit( | ||
| current: TerminalSessionState["status"], | ||
| synchronized: TerminalSessionState["status"], | ||
| @@ -328,7 +372,10 @@ export function TerminalViewport({ | ||
| const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); | ||
| const selectionGestureActiveRef = useRef(false); | ||
| const selectionActionRequestIdRef = useRef(0); | ||
| const selectionActionMenuOpenRef = useRef(false); | ||
| // Holds the request id of the selection popup currently on screen, so a | ||
| // popup that was superseded (but whose menu promise has not settled yet) | ||
| // cannot be mistaken for the active flow. | ||
| const openSelectionMenuRequestIdRef = useRef<number | null>(null); | ||
| const selectionActionTimerRef = useRef<number | null>(null); | ||
| const keybindingsRef = useRef(keybindings); | ||
| const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); | ||
| @@ -443,6 +490,12 @@ export function TerminalViewport({ | ||
| onSelectionChange: () => handleSelectionChange(), | ||
| beforeKey: (event) => handleBeforeKey(event), | ||
| onLinkActivate: (text, event) => handleLinkActivate(text, event), | ||
| // The surface listens from construction, so a right-click can land | ||
| // while `create` is still awaiting WASM — before the handler below it | ||
| // exists. The ref is only assigned once that setup has run. | ||
| onContextMenu: (event) => { | ||
| if (terminalRef.current) void showTerminalContextMenu(event); | ||
| }, | ||
| }; | ||
| const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); | ||
| if (cancelled) { | ||
| @@ -518,12 +571,98 @@ export function TerminalViewport({ | ||
| }; | ||
| }; | ||
| const addSelectionToChat = (selection: TerminalContextSelection) => { | ||
| handleAddTerminalContext(selection); | ||
| terminalRef.current?.clearSelection(); | ||
| terminalRef.current?.focus(); | ||
| }; | ||
| // A selection-action flow that was superseded while its async work ran | ||
| // must go silent: no error message, no focus steal. | ||
| const reportIfCurrent = (requestId: number, error: unknown, fallback: string) => { | ||
| if (requestId !== selectionActionRequestIdRef.current) return; | ||
| const activeTerminal = terminalRef.current; | ||
| if (activeTerminal) { | ||
| writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallback); | ||
| } | ||
| }; | ||
| const focusIfCurrent = (requestId: number) => { | ||
| if (requestId === selectionActionRequestIdRef.current) { | ||
| terminalRef.current?.focus(); | ||
| } | ||
| }; | ||
| const copySelection = async (text: string, requestId: number) => { | ||
| try { | ||
| await writeTextToClipboard(text, "terminal selection"); | ||
| } catch (error) { | ||
| reportIfCurrent(requestId, error, "Unable to copy terminal selection"); | ||
| } | ||
| focusIfCurrent(requestId); | ||
| }; | ||
| const pasteFromClipboard = async (requestId: number) => { | ||
| const activeTerminal = terminalRef.current; | ||
| if (!activeTerminal) return; | ||
| try { | ||
| // The surface owns the read so it can claim the paste race before it | ||
| // starts: a paste shortcut fired while the menu read is in flight | ||
| // supersedes this paste instead of landing alongside it. | ||
| await activeTerminal.pasteFromClipboard( | ||
| () => readTextFromClipboard("terminal input"), | ||
| () => requestId === selectionActionRequestIdRef.current, | ||
| ); | ||
| } catch (error) { | ||
| reportIfCurrent(requestId, error, "Unable to read the clipboard"); | ||
| return; | ||
| } | ||
| focusIfCurrent(requestId); | ||
| }; | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const showTerminalContextMenu = async (event: MouseEvent) => { | ||
macroscopeapp[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| if (!localApi || !terminalRef.current) return; | ||
| // Own the gesture before anything async: leaving the default alive lets | ||
| // the browser (or Electron's editing menu) answer with a Paste entry | ||
| // that is permanently disabled over the terminal canvas. | ||
| event.preventDefault(); | ||
| // A right-click supersedes a selection popup that is pending or open. | ||
| clearSelectionAction(); | ||
| const selectionAction = readSelectionAction(); | ||
StiensWout marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const requestId = selectionActionRequestIdRef.current; | ||
| let clicked: TerminalContextMenuAction | null; | ||
| try { | ||
| clicked = await localApi.contextMenu.show( | ||
| terminalContextMenuItems({ hasSelection: selectionAction !== null }), | ||
| { x: event.clientX, y: event.clientY }, | ||
| ); | ||
| } catch (error) { | ||
| reportIfCurrent(requestId, error, "Unable to open the terminal context menu"); | ||
| focusIfCurrent(requestId); | ||
| return; | ||
| } | ||
| if (requestId !== selectionActionRequestIdRef.current || clicked === null) { | ||
| return; | ||
| } | ||
| switch (clicked) { | ||
| case "add-to-chat": | ||
| if (selectionAction) addSelectionToChat(selectionAction.selection); | ||
| return; | ||
| case "copy": | ||
| if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); | ||
| return; | ||
| case "paste": | ||
| await pasteFromClipboard(requestId); | ||
| return; | ||
| } | ||
| }; | ||
| const showSelectionAction = async () => { | ||
| if (!localApi) { | ||
| clearSelectionAction(); | ||
| return; | ||
| } | ||
| if (selectionActionMenuOpenRef.current) { | ||
| if (openSelectionMenuRequestIdRef.current !== null) { | ||
| return; | ||
| } | ||
| const nextAction = readSelectionAction(); | ||
| @@ -532,45 +671,23 @@ export function TerminalViewport({ | ||
| return; | ||
| } | ||
| const requestId = ++selectionActionRequestIdRef.current; | ||
| selectionActionMenuOpenRef.current = true; | ||
| openSelectionMenuRequestIdRef.current = requestId; | ||
| const clicked = await localApi.contextMenu | ||
| .show( | ||
| [ | ||
| { id: "add-to-chat", label: "Add to chat" }, | ||
| { id: "copy", label: "Copy" }, | ||
| ], | ||
| nextAction.position, | ||
| ) | ||
| .show(terminalSelectionMenuItems(), nextAction.position) | ||
| .finally(() => { | ||
| selectionActionMenuOpenRef.current = false; | ||
| if (openSelectionMenuRequestIdRef.current === requestId) { | ||
| openSelectionMenuRequestIdRef.current = null; | ||
| } | ||
| }); | ||
| if (requestId !== selectionActionRequestIdRef.current || clicked === null) { | ||
| return; | ||
| } | ||
| switch (clicked) { | ||
| case "add-to-chat": | ||
| handleAddTerminalContext(nextAction.selection); | ||
| terminalRef.current?.clearSelection(); | ||
| terminalRef.current?.focus(); | ||
| addSelectionToChat(nextAction.selection); | ||
| return; | ||
| case "copy": | ||
| try { | ||
| await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); | ||
| } catch (error) { | ||
| if (requestId !== selectionActionRequestIdRef.current) { | ||
| return; | ||
| } | ||
| const activeTerminal = terminalRef.current; | ||
| if (activeTerminal) { | ||
| writeSystemMessage( | ||
| activeTerminal, | ||
| error instanceof Error ? error.message : "Unable to copy terminal selection", | ||
| ); | ||
| } | ||
| } | ||
| if (requestId === selectionActionRequestIdRef.current) { | ||
| terminalRef.current?.focus(); | ||
| } | ||
| await copySelection(nextAction.clipboardText, requestId); | ||
| return; | ||
| } | ||
| }; | ||
| @@ -684,11 +801,17 @@ export function TerminalViewport({ | ||
| if (terminalRef.current?.hasSelection()) { | ||
| return; | ||
| } | ||
| const shouldClear = shouldClearTerminalSelectionAction({ | ||
| timerPending: selectionActionTimerRef.current !== null, | ||
| openMenuRequestId: openSelectionMenuRequestIdRef.current, | ||
| currentRequestId: selectionActionRequestIdRef.current, | ||
| }); | ||
| if (!shouldClear) return; | ||
| clearSelectionAction(); | ||
| // A copy shortcut that clears the selection (Ctrl+C) must also close | ||
| // the context menu that appears with the selection, but a clear that | ||
| // never opened a menu must not dismiss an unrelated one. | ||
| if (selectionActionMenuOpenRef.current) { | ||
| if (openSelectionMenuRequestIdRef.current !== null) { | ||
| void localApi?.contextMenu.close(); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.