Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import {
ContextMenuTrigger,
} from "@superset/ui/context-menu";
import type { ReactNode } from "react";
import { useState } from "react";
import {
LuArrowDownToLine,
LuClipboard,
LuClipboardCopy,
LuColumns2,
LuEraser,
LuMoveRight,
Expand All @@ -22,13 +25,20 @@ import {
import { useHotkeyText } from "renderer/stores/hotkeys";
import type { Tab } from "renderer/stores/tabs/types";

function getModifierKeyLabel() {
const isMac = navigator.platform.toLowerCase().includes("mac");
return isMac ? "⌘" : "Ctrl+";
}

interface TabContentContextMenuProps {
children: ReactNode;
onSplitHorizontal: () => void;
onSplitVertical: () => void;
onClosePane: () => void;
onClearTerminal: () => void;
onScrollToBottom: () => void;
getSelection?: () => string;
onPaste?: (text: string) => void;
currentTabId: string;
availableTabs: Tab[];
onMoveToTab: (tabId: string) => void;
Expand All @@ -42,6 +52,8 @@ export function TabContentContextMenu({
onClosePane,
onClearTerminal,
onScrollToBottom,
getSelection,
onPaste,
currentTabId,
availableTabs,
onMoveToTab,
Expand All @@ -53,11 +65,57 @@ export function TabContentContextMenu({
const showClearShortcut = clearShortcut !== "Unassigned";
const scrollToBottomShortcut = useHotkeyText("SCROLL_TO_BOTTOM");
const showScrollToBottomShortcut = scrollToBottomShortcut !== "Unassigned";
const modKey = getModifierKeyLabel();

const [hasSelection, setHasSelection] = useState(false);
const [hasClipboard, setHasClipboard] = useState(false);

const handleOpenChange = async (open: boolean) => {
if (!open) return;
setHasSelection(!!getSelection?.()?.length);
try {
const text = await navigator.clipboard.readText();
setHasClipboard(!!text);
} catch {
setHasClipboard(false);
}
};

const handleCopy = async () => {
const text = getSelection?.();
if (!text) return;
await navigator.clipboard.writeText(text);
};

const handlePaste = async () => {
if (!onPaste) return;
try {
const text = await navigator.clipboard.readText();
if (text) onPaste(text);
} catch {
// Clipboard access denied
}
};
Comment on lines +84 to +98
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Async onSelect handlers — unhandled rejection risk is minimal but present.

Both handleCopy and handlePaste are async functions passed to onSelect, which expects (event) => void. Any thrown error becomes an unhandled promise rejection. The try/catch in handlePaste covers clipboard failures, but handleCopy's navigator.clipboard.writeText call at line 87 is not wrapped in a try/catch.

Wrap handleCopy in try/catch for consistency
 const handleCopy = async () => {
 	const text = getSelection?.();
 	if (!text) return;
-	await navigator.clipboard.writeText(text);
+	try {
+		await navigator.clipboard.writeText(text);
+	} catch {
+		// Clipboard write failed
+	}
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleCopy = async () => {
const text = getSelection?.();
if (!text) return;
await navigator.clipboard.writeText(text);
};
const handlePaste = async () => {
if (!onPaste) return;
try {
const text = await navigator.clipboard.readText();
if (text) onPaste(text);
} catch {
// Clipboard access denied
}
};
const handleCopy = async () => {
const text = getSelection?.();
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch {
// Clipboard write failed
}
};
const handlePaste = async () => {
if (!onPaste) return;
try {
const text = await navigator.clipboard.readText();
if (text) onPaste(text);
} catch {
// Clipboard access denied
}
};
🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx`
around lines 84 - 98, handleCopy is an async function passed to onSelect but its
navigator.clipboard.writeText call is not guarded, creating a possible unhandled
rejection; update handleCopy (the function named handleCopy used for onSelect)
to wrap the clipboard read/write in a try/catch (mirroring handlePaste) and
handle or log the error so any thrown exception is caught before the promise
escapes the onSelect handler.


return (
<ContextMenu>
<ContextMenu onOpenChange={handleOpenChange}>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{getSelection && (
<ContextMenuItem disabled={!hasSelection} onSelect={handleCopy}>
<LuClipboardCopy className="size-4" />
Copy
<ContextMenuShortcut>{modKey}C</ContextMenuShortcut>
</ContextMenuItem>
)}
{onPaste && (
<ContextMenuItem disabled={!hasClipboard} onSelect={handlePaste}>
<LuClipboard className="size-4" />
Paste
<ContextMenuShortcut>{modKey}V</ContextMenuShortcut>
</ContextMenuItem>
)}
{(getSelection || onPaste) && <ContextMenuSeparator />}
<ContextMenuItem onSelect={onSplitHorizontal}>
<LuRows2 className="size-4" />
Split Horizontally
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export function TabPane({
const getScrollToBottomCallback = useTerminalCallbacksStore(
(s) => s.getScrollToBottomCallback,
);
const getGetSelectionCallback = useTerminalCallbacksStore(
(s) => s.getGetSelectionCallback,
);
const getPasteCallback = useTerminalCallbacksStore((s) => s.getPasteCallback);

useEffect(() => {
const container = terminalContainerRef.current;
Expand Down Expand Up @@ -117,6 +121,8 @@ export function TabPane({
onClosePane={() => removePane(paneId)}
onClearTerminal={handleClearTerminal}
onScrollToBottom={handleScrollToBottom}
getSelection={() => getGetSelectionCallback(paneId)?.() ?? ""}
onPaste={(text) => getPasteCallback(paneId)?.(text)}
currentTabId={tabId}
availableTabs={availableTabs}
onMoveToTab={onMoveToTab}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => {
unregisterClearCallbackRef,
registerScrollToBottomCallbackRef,
unregisterScrollToBottomCallbackRef,
registerGetSelectionCallbackRef,
unregisterGetSelectionCallbackRef,
registerPasteCallbackRef,
unregisterPasteCallbackRef,
} = useTerminalRefs({
paneId,
tabId,
Expand Down Expand Up @@ -299,6 +303,10 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => {
unregisterClearCallbackRef,
registerScrollToBottomCallbackRef,
unregisterScrollToBottomCallbackRef,
registerGetSelectionCallbackRef,
unregisterGetSelectionCallbackRef,
registerPasteCallbackRef,
unregisterPasteCallbackRef,
});

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ export interface UseTerminalLifecycleOptions {
unregisterClearCallbackRef: MutableRefObject<UnregisterCallback>;
registerScrollToBottomCallbackRef: MutableRefObject<RegisterCallback>;
unregisterScrollToBottomCallbackRef: MutableRefObject<UnregisterCallback>;
registerGetSelectionCallbackRef: MutableRefObject<
(paneId: string, callback: () => string) => void
>;
unregisterGetSelectionCallbackRef: MutableRefObject<UnregisterCallback>;
registerPasteCallbackRef: MutableRefObject<
(paneId: string, callback: (text: string) => void) => void
>;
unregisterPasteCallbackRef: MutableRefObject<UnregisterCallback>;
}

export interface UseTerminalLifecycleReturn {
Expand Down Expand Up @@ -176,6 +184,10 @@ export function useTerminalLifecycle({
unregisterClearCallbackRef,
registerScrollToBottomCallbackRef,
unregisterScrollToBottomCallbackRef,
registerGetSelectionCallbackRef,
unregisterGetSelectionCallbackRef,
registerPasteCallbackRef,
unregisterPasteCallbackRef,
}: UseTerminalLifecycleOptions): UseTerminalLifecycleReturn {
const [xtermInstance, setXtermInstance] = useState<XTerm | null>(null);
const restartTerminalRef = useRef<() => void>(() => {});
Expand Down Expand Up @@ -490,6 +502,23 @@ export function useTerminalLifecycle({
registerClearCallbackRef.current(paneId, handleClear);
registerScrollToBottomCallbackRef.current(paneId, handleScrollToBottom);

const handleGetSelection = () => {
const selection = xterm.getSelection();
if (!selection) return "";
return selection
.split("\n")
.map((line) => line.trimEnd())
.join("\n");
};

const handlePaste = (text: string) => {
if (isExitedRef.current) return;
xterm.paste(text);
};

registerGetSelectionCallbackRef.current(paneId, handleGetSelection);
registerPasteCallbackRef.current(paneId, handlePaste);

const cleanupFocus = setupFocusListener(xterm, () =>
handleTerminalFocusRef.current(),
);
Expand Down Expand Up @@ -558,6 +587,8 @@ export function useTerminalLifecycle({
cleanupQuerySuppression();
unregisterClearCallbackRef.current(paneId);
unregisterScrollToBottomCallbackRef.current(paneId);
unregisterGetSelectionCallbackRef.current(paneId);
unregisterPasteCallbackRef.current(paneId);

if (isPaneDestroyedInStore()) {
// Pane was explicitly destroyed, so kill the session.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { useRef } from "react";
import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks";

type RegisterCallback = (paneId: string, callback: () => void) => void;
type RegisterGetSelectionCallback = (
paneId: string,
callback: () => string,
) => void;
type RegisterPasteCallback = (
paneId: string,
callback: (text: string) => void,
) => void;
type UnregisterCallback = (paneId: string) => void;

export interface UseTerminalRefsOptions {
Expand Down Expand Up @@ -37,6 +45,10 @@ export interface UseTerminalRefsReturn {
unregisterClearCallbackRef: MutableRefObject<UnregisterCallback>;
registerScrollToBottomCallbackRef: MutableRefObject<RegisterCallback>;
unregisterScrollToBottomCallbackRef: MutableRefObject<UnregisterCallback>;
registerGetSelectionCallbackRef: MutableRefObject<RegisterGetSelectionCallback>;
unregisterGetSelectionCallbackRef: MutableRefObject<UnregisterCallback>;
registerPasteCallbackRef: MutableRefObject<RegisterPasteCallback>;
unregisterPasteCallbackRef: MutableRefObject<UnregisterCallback>;
}

export function useTerminalRefs({
Expand Down Expand Up @@ -90,6 +102,18 @@ export function useTerminalRefs({
const unregisterScrollToBottomCallbackRef = useRef(
useTerminalCallbacksStore.getState().unregisterScrollToBottomCallback,
);
const registerGetSelectionCallbackRef = useRef(
useTerminalCallbacksStore.getState().registerGetSelectionCallback,
);
const unregisterGetSelectionCallbackRef = useRef(
useTerminalCallbacksStore.getState().unregisterGetSelectionCallback,
);
const registerPasteCallbackRef = useRef(
useTerminalCallbacksStore.getState().registerPasteCallback,
);
const unregisterPasteCallbackRef = useRef(
useTerminalCallbacksStore.getState().unregisterPasteCallback,
);

return {
isFocused,
Expand All @@ -106,5 +130,9 @@ export function useTerminalRefs({
unregisterClearCallbackRef,
registerScrollToBottomCallbackRef,
unregisterScrollToBottomCallbackRef,
registerGetSelectionCallbackRef,
unregisterGetSelectionCallbackRef,
registerPasteCallbackRef,
unregisterPasteCallbackRef,
};
}
56 changes: 56 additions & 0 deletions apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { create } from "zustand";
interface TerminalCallbacksState {
clearCallbacks: Map<string, () => void>;
scrollToBottomCallbacks: Map<string, () => void>;
getSelectionCallbacks: Map<string, () => string>;
pasteCallbacks: Map<string, (text: string) => void>;
registerClearCallback: (paneId: string, callback: () => void) => void;
unregisterClearCallback: (paneId: string) => void;
getClearCallback: (paneId: string) => (() => void) | undefined;
Expand All @@ -12,12 +14,26 @@ interface TerminalCallbacksState {
) => void;
unregisterScrollToBottomCallback: (paneId: string) => void;
getScrollToBottomCallback: (paneId: string) => (() => void) | undefined;
registerGetSelectionCallback: (
paneId: string,
callback: () => string,
) => void;
unregisterGetSelectionCallback: (paneId: string) => void;
getGetSelectionCallback: (paneId: string) => (() => string) | undefined;
registerPasteCallback: (
paneId: string,
callback: (text: string) => void,
) => void;
unregisterPasteCallback: (paneId: string) => void;
getPasteCallback: (paneId: string) => ((text: string) => void) | undefined;
}

export const useTerminalCallbacksStore = create<TerminalCallbacksState>()(
(set, get) => ({
clearCallbacks: new Map(),
scrollToBottomCallbacks: new Map(),
getSelectionCallbacks: new Map(),
pasteCallbacks: new Map(),

registerClearCallback: (paneId, callback) => {
set((state) => {
Expand Down Expand Up @@ -58,5 +74,45 @@ export const useTerminalCallbacksStore = create<TerminalCallbacksState>()(
getScrollToBottomCallback: (paneId) => {
return get().scrollToBottomCallbacks.get(paneId);
},

registerGetSelectionCallback: (paneId, callback) => {
set((state) => {
const newCallbacks = new Map(state.getSelectionCallbacks);
newCallbacks.set(paneId, callback);
return { getSelectionCallbacks: newCallbacks };
});
},

unregisterGetSelectionCallback: (paneId) => {
set((state) => {
const newCallbacks = new Map(state.getSelectionCallbacks);
newCallbacks.delete(paneId);
return { getSelectionCallbacks: newCallbacks };
});
},

getGetSelectionCallback: (paneId) => {
return get().getSelectionCallbacks.get(paneId);
},

registerPasteCallback: (paneId, callback) => {
set((state) => {
const newCallbacks = new Map(state.pasteCallbacks);
newCallbacks.set(paneId, callback);
return { pasteCallbacks: newCallbacks };
});
},

unregisterPasteCallback: (paneId) => {
set((state) => {
const newCallbacks = new Map(state.pasteCallbacks);
newCallbacks.delete(paneId);
return { pasteCallbacks: newCallbacks };
});
},

getPasteCallback: (paneId) => {
return get().pasteCallbacks.get(paneId);
},
}),
);
Loading