Skip to content

Commit 6c31f72

Browse files
authored
Merge pull request #167 from AutoMaker-Org/add-copy-paste-terminals
feat: implement context menu for terminal actions
2 parents a0efa5d + 46933a2 commit 6c31f72

1 file changed

Lines changed: 301 additions & 0 deletions

File tree

apps/app/src/components/views/terminal-view/terminal-panel.tsx

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import {
99
Terminal,
1010
ZoomIn,
1111
ZoomOut,
12+
Copy,
13+
ClipboardPaste,
14+
CheckSquare,
15+
Trash2,
1216
} from "lucide-react";
1317
import { Button } from "@/components/ui/button";
1418
import { cn } from "@/lib/utils";
@@ -66,6 +70,27 @@ export function TerminalPanel({
6670
const focusHandlerRef = useRef<{ dispose: () => void } | null>(null);
6771
const [isTerminalReady, setIsTerminalReady] = useState(false);
6872
const [shellName, setShellName] = useState("shell");
73+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
74+
const [isMac, setIsMac] = useState(false);
75+
const isMacRef = useRef(false);
76+
const contextMenuRef = useRef<HTMLDivElement>(null);
77+
const [focusedMenuIndex, setFocusedMenuIndex] = useState(0);
78+
const focusedMenuIndexRef = useRef(0);
79+
80+
// Detect platform on mount
81+
useEffect(() => {
82+
// Use modern userAgentData API with fallback to navigator.platform
83+
const nav = navigator as Navigator & { userAgentData?: { platform: string } };
84+
let detected = false;
85+
if (nav.userAgentData?.platform) {
86+
detected = nav.userAgentData.platform.toLowerCase().includes("mac");
87+
} else if (typeof navigator !== "undefined") {
88+
// Fallback for browsers without userAgentData (intentionally using deprecated API)
89+
detected = /mac/i.test(navigator.platform);
90+
}
91+
setIsMac(detected);
92+
isMacRef.current = detected;
93+
}, []);
6994

7095
// Get effective theme from store
7196
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
@@ -84,6 +109,8 @@ export function TerminalPanel({
84109
fontSizeRef.current = fontSize;
85110
const themeRef = useRef(effectiveTheme);
86111
themeRef.current = effectiveTheme;
112+
const copySelectionRef = useRef<() => Promise<boolean>>(() => Promise.resolve(false));
113+
const pasteFromClipboardRef = useRef<() => Promise<void>>(() => Promise.resolve());
87114

88115
// Zoom functions - use the prop callback
89116
const zoomIn = useCallback(() => {
@@ -98,6 +125,75 @@ export function TerminalPanel({
98125
onFontSizeChange(DEFAULT_FONT_SIZE);
99126
}, [onFontSizeChange]);
100127

128+
// Copy selected text to clipboard
129+
const copySelection = useCallback(async (): Promise<boolean> => {
130+
const terminal = xtermRef.current;
131+
if (!terminal) return false;
132+
133+
const selection = terminal.getSelection();
134+
if (!selection) return false;
135+
136+
try {
137+
await navigator.clipboard.writeText(selection);
138+
return true;
139+
} catch (err) {
140+
console.error("[Terminal] Copy failed:", err);
141+
return false;
142+
}
143+
}, []);
144+
copySelectionRef.current = copySelection;
145+
146+
// Paste from clipboard
147+
const pasteFromClipboard = useCallback(async () => {
148+
const terminal = xtermRef.current;
149+
if (!terminal || !wsRef.current) return;
150+
151+
try {
152+
const text = await navigator.clipboard.readText();
153+
if (text && wsRef.current.readyState === WebSocket.OPEN) {
154+
wsRef.current.send(JSON.stringify({ type: "input", data: text }));
155+
}
156+
} catch (err) {
157+
console.error("[Terminal] Paste failed:", err);
158+
}
159+
}, []);
160+
pasteFromClipboardRef.current = pasteFromClipboard;
161+
162+
// Select all terminal content
163+
const selectAll = useCallback(() => {
164+
xtermRef.current?.selectAll();
165+
}, []);
166+
167+
// Clear terminal
168+
const clearTerminal = useCallback(() => {
169+
xtermRef.current?.clear();
170+
}, []);
171+
172+
// Close context menu
173+
const closeContextMenu = useCallback(() => {
174+
setContextMenu(null);
175+
}, []);
176+
177+
// Handle context menu action
178+
const handleContextMenuAction = useCallback(async (action: "copy" | "paste" | "selectAll" | "clear") => {
179+
closeContextMenu();
180+
switch (action) {
181+
case "copy":
182+
await copySelection();
183+
break;
184+
case "paste":
185+
await pasteFromClipboard();
186+
break;
187+
case "selectAll":
188+
selectAll();
189+
break;
190+
case "clear":
191+
clearTerminal();
192+
break;
193+
}
194+
xtermRef.current?.focus();
195+
}, [closeContextMenu, copySelection, pasteFromClipboard, selectAll, clearTerminal]);
196+
101197
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
102198
const wsUrl = serverUrl.replace(/^http/, "ws");
103199

@@ -263,6 +359,43 @@ export function TerminalPanel({
263359
return false;
264360
}
265361

362+
const modKey = isMacRef.current ? event.metaKey : event.ctrlKey;
363+
const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey;
364+
365+
// Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention)
366+
if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyC') {
367+
event.preventDefault();
368+
copySelectionRef.current();
369+
return false;
370+
}
371+
372+
// Ctrl+C / Cmd+C - Copy if text is selected, otherwise send SIGINT
373+
if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyC') {
374+
const hasSelection = terminal.hasSelection();
375+
if (hasSelection) {
376+
event.preventDefault();
377+
copySelectionRef.current();
378+
terminal.clearSelection();
379+
return false;
380+
}
381+
// No selection - let xterm handle it (sends SIGINT)
382+
return true;
383+
}
384+
385+
// Ctrl+V / Cmd+V or Ctrl+Shift+V / Cmd+Shift+V - Paste
386+
if (modKey && !otherModKey && !event.altKey && code === 'KeyV') {
387+
event.preventDefault();
388+
pasteFromClipboardRef.current();
389+
return false;
390+
}
391+
392+
// Ctrl+A / Cmd+A - Select all
393+
if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyA') {
394+
event.preventDefault();
395+
terminal.selectAll();
396+
return false;
397+
}
398+
266399
// Let xterm handle all other keys
267400
return true;
268401
});
@@ -548,6 +681,108 @@ export function TerminalPanel({
548681
return () => container.removeEventListener("wheel", handleWheel);
549682
}, [zoomIn, zoomOut]);
550683

684+
// Context menu actions for keyboard navigation
685+
const menuActions = ["copy", "paste", "selectAll", "clear"] as const;
686+
687+
// Keep ref in sync with state for use in event handlers
688+
useEffect(() => {
689+
focusedMenuIndexRef.current = focusedMenuIndex;
690+
}, [focusedMenuIndex]);
691+
692+
// Close context menu on click outside or scroll, handle keyboard navigation
693+
useEffect(() => {
694+
if (!contextMenu) return;
695+
696+
// Reset focus index and focus menu when opened
697+
setFocusedMenuIndex(0);
698+
focusedMenuIndexRef.current = 0;
699+
requestAnimationFrame(() => {
700+
const firstButton = contextMenuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]');
701+
firstButton?.focus();
702+
});
703+
704+
const handleClick = () => closeContextMenu();
705+
const handleScroll = () => closeContextMenu();
706+
const handleKeyDown = (e: KeyboardEvent) => {
707+
const updateFocusIndex = (newIndex: number) => {
708+
focusedMenuIndexRef.current = newIndex;
709+
setFocusedMenuIndex(newIndex);
710+
};
711+
712+
switch (e.key) {
713+
case "Escape":
714+
e.preventDefault();
715+
closeContextMenu();
716+
break;
717+
case "ArrowDown":
718+
e.preventDefault();
719+
updateFocusIndex((focusedMenuIndexRef.current + 1) % menuActions.length);
720+
break;
721+
case "ArrowUp":
722+
e.preventDefault();
723+
updateFocusIndex((focusedMenuIndexRef.current - 1 + menuActions.length) % menuActions.length);
724+
break;
725+
case "Enter":
726+
case " ":
727+
e.preventDefault();
728+
handleContextMenuAction(menuActions[focusedMenuIndexRef.current]);
729+
break;
730+
case "Tab":
731+
e.preventDefault();
732+
closeContextMenu();
733+
break;
734+
}
735+
};
736+
737+
document.addEventListener("click", handleClick);
738+
document.addEventListener("scroll", handleScroll, true);
739+
document.addEventListener("keydown", handleKeyDown);
740+
741+
return () => {
742+
document.removeEventListener("click", handleClick);
743+
document.removeEventListener("scroll", handleScroll, true);
744+
document.removeEventListener("keydown", handleKeyDown);
745+
};
746+
}, [contextMenu, closeContextMenu, handleContextMenuAction]);
747+
748+
// Focus the correct menu item when navigation changes
749+
useEffect(() => {
750+
if (!contextMenu || !contextMenuRef.current) return;
751+
const buttons = contextMenuRef.current.querySelectorAll<HTMLButtonElement>('[role="menuitem"]');
752+
buttons[focusedMenuIndex]?.focus();
753+
}, [focusedMenuIndex, contextMenu]);
754+
755+
// Handle right-click context menu with boundary checking
756+
const handleContextMenu = useCallback((e: React.MouseEvent) => {
757+
e.preventDefault();
758+
e.stopPropagation();
759+
760+
// Menu dimensions (approximate)
761+
const menuWidth = 160;
762+
const menuHeight = 152; // 4 items + separator + padding
763+
const padding = 8;
764+
765+
// Calculate position with boundary checks
766+
let x = e.clientX;
767+
let y = e.clientY;
768+
769+
// Check right edge
770+
if (x + menuWidth + padding > window.innerWidth) {
771+
x = window.innerWidth - menuWidth - padding;
772+
}
773+
774+
// Check bottom edge
775+
if (y + menuHeight + padding > window.innerHeight) {
776+
y = window.innerHeight - menuHeight - padding;
777+
}
778+
779+
// Ensure not negative
780+
x = Math.max(padding, x);
781+
y = Math.max(padding, y);
782+
783+
setContextMenu({ x, y });
784+
}, []);
785+
551786
// Combine refs for the container
552787
const setRefs = useCallback((node: HTMLDivElement | null) => {
553788
containerRef.current = node;
@@ -695,7 +930,73 @@ export function TerminalPanel({
695930
ref={terminalRef}
696931
className="flex-1 overflow-hidden"
697932
style={{ backgroundColor: currentTerminalTheme.background }}
933+
onContextMenu={handleContextMenu}
698934
/>
935+
936+
{/* Context menu */}
937+
{contextMenu && (
938+
<div
939+
ref={contextMenuRef}
940+
role="menu"
941+
aria-label="Terminal context menu"
942+
className="fixed z-50 min-w-[160px] rounded-md border border-border bg-popover p-1 shadow-md animate-in fade-in-0 zoom-in-95"
943+
style={{ left: contextMenu.x, top: contextMenu.y }}
944+
onClick={(e) => e.stopPropagation()}
945+
>
946+
<button
947+
role="menuitem"
948+
tabIndex={focusedMenuIndex === 0 ? 0 : -1}
949+
className={cn(
950+
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-popover-foreground cursor-default outline-none",
951+
focusedMenuIndex === 0 ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
952+
)}
953+
onClick={() => handleContextMenuAction("copy")}
954+
>
955+
<Copy className="h-4 w-4" />
956+
<span className="flex-1 text-left">Copy</span>
957+
<span className="text-xs text-muted-foreground">{isMac ? "⌘C" : "Ctrl+C"}</span>
958+
</button>
959+
<button
960+
role="menuitem"
961+
tabIndex={focusedMenuIndex === 1 ? 0 : -1}
962+
className={cn(
963+
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-popover-foreground cursor-default outline-none",
964+
focusedMenuIndex === 1 ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
965+
)}
966+
onClick={() => handleContextMenuAction("paste")}
967+
>
968+
<ClipboardPaste className="h-4 w-4" />
969+
<span className="flex-1 text-left">Paste</span>
970+
<span className="text-xs text-muted-foreground">{isMac ? "⌘V" : "Ctrl+V"}</span>
971+
</button>
972+
<div role="separator" className="my-1 h-px bg-border" />
973+
<button
974+
role="menuitem"
975+
tabIndex={focusedMenuIndex === 2 ? 0 : -1}
976+
className={cn(
977+
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-popover-foreground cursor-default outline-none",
978+
focusedMenuIndex === 2 ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
979+
)}
980+
onClick={() => handleContextMenuAction("selectAll")}
981+
>
982+
<CheckSquare className="h-4 w-4" />
983+
<span className="flex-1 text-left">Select All</span>
984+
<span className="text-xs text-muted-foreground">{isMac ? "⌘A" : "Ctrl+A"}</span>
985+
</button>
986+
<button
987+
role="menuitem"
988+
tabIndex={focusedMenuIndex === 3 ? 0 : -1}
989+
className={cn(
990+
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-popover-foreground cursor-default outline-none",
991+
focusedMenuIndex === 3 ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
992+
)}
993+
onClick={() => handleContextMenuAction("clear")}
994+
>
995+
<Trash2 className="h-4 w-4" />
996+
<span className="flex-1 text-left">Clear</span>
997+
</button>
998+
</div>
999+
)}
6991000
</div>
7001001
);
7011002
}

0 commit comments

Comments
 (0)