diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx index f5e525e3625..3f66c8552c5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx @@ -8,8 +8,14 @@ import { SUPERSET_THEME, useMonacoReady, } from "renderer/contexts/MonacoProvider"; +import type { Tab } from "renderer/stores/tabs/types"; import type { DiffViewMode, FileContents } from "shared/changes-types"; -import { registerCopyPathLineAction } from "./editor-actions"; +import { + EditorContextMenu, + type PaneActions, + registerCopyPathLineAction, + useEditorActions, +} from "../../../components/EditorContextMenu"; function scrollToFirstDiff( editor: Monaco.editor.IStandaloneDiffEditor, @@ -29,6 +35,16 @@ function scrollToFirstDiff( } } +export interface DiffViewerContextMenuProps { + onSplitHorizontal: () => void; + onSplitVertical: () => void; + onClosePane: () => void; + currentTabId: string; + availableTabs: Tab[]; + onMoveToTab: (tabId: string) => void; + onMoveToNewTab: () => void; +} + interface DiffViewerProps { contents: FileContents; viewMode: DiffViewMode; @@ -36,6 +52,8 @@ interface DiffViewerProps { editable?: boolean; onSave?: (content: string) => void; onChange?: (content: string) => void; + // Optional context menu props - when provided, wraps editor with context menu + contextMenuProps?: DiffViewerContextMenuProps; } export function DiffViewer({ @@ -45,6 +63,7 @@ export function DiffViewer({ editable = false, onSave, onChange, + contextMenuProps, }: DiffViewerProps) { const isMonacoReady = useMonacoReady(); const diffEditorRef = useRef( @@ -131,6 +150,20 @@ export function DiffViewer({ }; }, [isEditorMounted, onChange]); + // Get the active editor (modified or original) + const getEditor = useCallback(() => { + return ( + modifiedEditorRef.current || diffEditorRef.current?.getOriginalEditor() + ); + }, []); + + // Use shared editor actions hook - diff viewer is read-only (no cut/paste) + const editorActions = useEditorActions({ + getEditor, + filePath, + editable: false, + }); + if (!isMonacoReady) { return (
@@ -140,30 +173,51 @@ export function DiffViewer({ ); } + const diffEditor = ( + + + Loading editor... +
+ } + options={{ + ...MONACO_EDITOR_OPTIONS, + renderSideBySide: viewMode === "side-by-side", + readOnly: !editable, + originalEditable: false, + renderOverviewRuler: false, + diffWordWrap: "on", + contextmenu: !contextMenuProps, // Disable Monaco's context menu if we have custom props + }} + /> + ); + + // If no context menu props, return plain editor + if (!contextMenuProps) { + return
{diffEditor}
; + } + + // Wrap with custom context menu + const paneActions: PaneActions = { + onSplitHorizontal: contextMenuProps.onSplitHorizontal, + onSplitVertical: contextMenuProps.onSplitVertical, + onClosePane: contextMenuProps.onClosePane, + currentTabId: contextMenuProps.currentTabId, + availableTabs: contextMenuProps.availableTabs, + onMoveToTab: contextMenuProps.onMoveToTab, + onMoveToNewTab: contextMenuProps.onMoveToNewTab, + }; + return ( -
- - - Loading editor... -
- } - options={{ - ...MONACO_EDITOR_OPTIONS, - renderSideBySide: viewMode === "side-by-side", - readOnly: !editable, - originalEditable: false, - renderOverviewRuler: false, - diffWordWrap: "on", - }} - /> - + +
{diffEditor}
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index 146be2b3600..5e9db6410f3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -2,7 +2,7 @@ import type * as Monaco from "monaco-editor"; import { useCallback, useEffect, useRef, useState } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import { useTabsStore } from "renderer/stores/tabs/store"; -import type { Pane } from "renderer/stores/tabs/types"; +import type { Pane, Tab } from "renderer/stores/tabs/types"; import type { FileViewerMode } from "shared/tabs-types"; import { BasePaneWindow } from "../components"; import { FileViewerContent } from "./components/FileViewerContent"; @@ -24,8 +24,21 @@ interface FileViewerPaneProps { dimensions: { width: number; height: number }, path?: MosaicBranch[], ) => void; + splitPaneHorizontal: ( + tabId: string, + sourcePaneId: string, + path?: MosaicBranch[], + ) => void; + splitPaneVertical: ( + tabId: string, + sourcePaneId: string, + path?: MosaicBranch[], + ) => void; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; + availableTabs: Tab[]; + onMoveToTab: (targetTabId: string) => void; + onMoveToNewTab: () => void; } export function FileViewerPane({ @@ -36,8 +49,13 @@ export function FileViewerPane({ tabId, worktreePath, splitPaneAuto, + splitPaneHorizontal, + splitPaneVertical, removePane, setFocusedPane, + availableTabs, + onMoveToTab, + onMoveToNewTab, }: FileViewerPaneProps) { const editorRef = useRef(null); const [isDirty, setIsDirty] = useState(false); @@ -290,6 +308,14 @@ export function FileViewerPane({ onEditorChange={handleEditorChange} onDiffChange={isDiffEditable ? handleDiffChange : undefined} setIsDirty={setIsDirty} + // Context menu props + onSplitHorizontal={() => splitPaneHorizontal(tabId, paneId, path)} + onSplitVertical={() => splitPaneVertical(tabId, paneId, path)} + onClosePane={() => removePane(paneId)} + currentTabId={tabId} + availableTabs={availableTabs} + onMoveToTab={onMoveToTab} + onMoveToNewTab={onMoveToNewTab} /> ; + filePath: string; + onSplitHorizontal: () => void; + onSplitVertical: () => void; + onClosePane: () => void; + currentTabId: string; + availableTabs: Tab[]; + onMoveToTab: (tabId: string) => void; + onMoveToNewTab: () => void; +} + +export function FileEditorContextMenu({ + children, + editorRef, + filePath, + onSplitHorizontal, + onSplitVertical, + onClosePane, + currentTabId, + availableTabs, + onMoveToTab, + onMoveToNewTab, +}: FileEditorContextMenuProps) { + const getEditor = useCallback(() => editorRef.current, [editorRef]); + + const editorActions = useEditorActions({ + getEditor, + filePath, + editable: true, + }); + + return ( + + {children} + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileEditorContextMenu/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileEditorContextMenu/index.ts new file mode 100644 index 00000000000..6b5fcc00473 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileEditorContextMenu/index.ts @@ -0,0 +1 @@ +export { FileEditorContextMenu } from "./FileEditorContextMenu"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx index f6945203db9..755fccc5dfd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx @@ -9,9 +9,12 @@ import { SUPERSET_THEME, useMonacoReady, } from "renderer/contexts/MonacoProvider"; +import type { Tab } from "renderer/stores/tabs/types"; import { detectLanguage } from "shared/detect-language"; import type { FileViewerMode } from "shared/tabs-types"; import { DiffViewer } from "../../../../../ChangesContent/components/DiffViewer"; +import { registerCopyPathLineAction } from "../../../../../components/EditorContextMenu"; +import { FileEditorContextMenu } from "../FileEditorContextMenu"; interface RawFileData { ok: true; @@ -54,6 +57,14 @@ interface FileViewerContentProps { onEditorChange: (value: string | undefined) => void; onDiffChange?: (content: string) => void; setIsDirty: (dirty: boolean) => void; + // Context menu props + onSplitHorizontal: () => void; + onSplitVertical: () => void; + onClosePane: () => void; + currentTabId: string; + availableTabs: Tab[]; + onMoveToTab: (tabId: string) => void; + onMoveToNewTab: () => void; } export function FileViewerContent({ @@ -74,6 +85,14 @@ export function FileViewerContent({ onEditorChange, onDiffChange, setIsDirty, + // Context menu props + onSplitHorizontal, + onSplitVertical, + onClosePane, + currentTabId, + availableTabs, + onMoveToTab, + onMoveToNewTab, }: FileViewerContentProps) { const isMonacoReady = useMonacoReady(); const hasAppliedInitialLocationRef = useRef(false); @@ -96,8 +115,16 @@ export function FileViewerContent({ } setIsDirty(editor.getValue() !== originalContentRef.current); registerSaveAction(editor, onSaveRaw); + registerCopyPathLineAction(editor, filePath); }, - [onSaveRaw, editorRef, originalContentRef, draftContentRef, setIsDirty], + [ + onSaveRaw, + editorRef, + originalContentRef, + draftContentRef, + setIsDirty, + filePath, + ], ); useEffect(() => { @@ -164,6 +191,15 @@ export function FileViewerContent({ editable={isDiffEditable} onSave={isDiffEditable ? onSaveDiff : undefined} onChange={isDiffEditable ? onDiffChange : undefined} + contextMenuProps={{ + onSplitHorizontal, + onSplitVertical, + onClosePane, + currentTabId, + availableTabs, + onMoveToTab, + onMoveToNewTab, + }} /> ); } @@ -212,21 +248,38 @@ export function FileViewerContent({ } return ( - - - Loading editor... - - } - options={MONACO_EDITOR_OPTIONS} - /> + +
+ + + Loading editor... +
+ } + options={{ + ...MONACO_EDITOR_OPTIONS, + contextmenu: false, // Disable Monaco's native context menu to use our custom one + }} + /> + +
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index 766f486ae62..4804d3f7ce9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -114,8 +114,13 @@ export function TabView({ tab, panes }: TabViewProps) { tabId={tab.id} worktreePath={worktreePath} splitPaneAuto={splitPaneAuto} + splitPaneHorizontal={splitPaneHorizontal} + splitPaneVertical={splitPaneVertical} removePane={removePane} setFocusedPane={setFocusedPane} + availableTabs={workspaceTabs} + onMoveToTab={(targetTabId) => movePaneToTab(paneId, targetTabId)} + onMoveToNewTab={() => movePaneToNewTab(paneId)} /> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/EditorContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/EditorContextMenu.tsx new file mode 100644 index 00000000000..482891153e9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/EditorContextMenu.tsx @@ -0,0 +1,190 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import type { ReactNode } from "react"; +import { + LuClipboard, + LuClipboardCopy, + LuColumns2, + LuFile, + LuLink, + LuMousePointerClick, + LuMoveRight, + LuPlus, + LuReplace, + LuRows2, + LuScissors, + LuSearch, + LuX, +} from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import type { Tab } from "renderer/stores/tabs/types"; + +export interface EditorActions { + onCut?: () => void; + onCopy: () => void; + onPaste?: () => void; + onSelectAll: () => void; + onCopyPath?: () => void; + onCopyPathWithLine?: () => void; + onFind?: () => void; + onChangeAllOccurrences?: () => void; +} + +export interface PaneActions { + onSplitHorizontal: () => void; + onSplitVertical: () => void; + onClosePane: () => void; + currentTabId: string; + availableTabs: Tab[]; + onMoveToTab: (tabId: string) => void; + onMoveToNewTab: () => void; +} + +interface EditorContextMenuProps { + children: ReactNode; + editorActions: EditorActions; + paneActions: PaneActions; +} + +export function EditorContextMenu({ + children, + editorActions, + paneActions, +}: EditorContextMenuProps) { + const targetTabs = paneActions.availableTabs.filter( + (t) => t.id !== paneActions.currentTabId, + ); + + const { data: platform } = trpc.window.getPlatform.useQuery(); + const isMac = platform === "darwin"; + const cmdKey = isMac ? "Cmd" : "Ctrl"; + + const { + onCut, + onCopy, + onPaste, + onSelectAll, + onCopyPath, + onCopyPathWithLine, + onFind, + onChangeAllOccurrences, + } = editorActions; + const showCutPaste = !!onCut && !!onPaste; + + return ( + + {children} + + {/* Clipboard Actions */} + {showCutPaste && ( + + + Cut + {cmdKey}+X + + )} + + + Copy + {cmdKey}+C + + {onCopyPath && ( + + + Copy Path + + )} + {onCopyPathWithLine && ( + + + Copy Path:Line + {cmdKey}+Shift+C + + )} + {showCutPaste && ( + + + Paste + {cmdKey}+V + + )} + + + + {/* Editor Actions */} + {onChangeAllOccurrences && ( + + + Change All Occurrences + {cmdKey}+Shift+L + + )} + + + + Select All + {cmdKey}+A + + + {onFind && ( + + + Find + {cmdKey}+F + + )} + + + + {/* Pane Actions */} + + + Split Horizontally + + + + Split Vertically + + + + + + Move to Tab + + + {targetTabs.map((tab) => ( + paneActions.onMoveToTab(tab.id)} + > + {tab.name} + + ))} + {targetTabs.length > 0 && } + + + New Tab + + + + + + + Close File + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/editor-actions.ts similarity index 76% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/editor-actions.ts index 447ffe35f8c..8cead69b686 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/editor-actions.ts @@ -1,6 +1,12 @@ import type * as Monaco from "monaco-editor"; import { monaco } from "renderer/contexts/MonacoProvider"; +/** + * Registers a keyboard shortcut (Cmd+Shift+C / Ctrl+Shift+C) to copy + * the file path with the current line number(s) to the clipboard. + * + * Format: `path/to/file.ts:42` or `path/to/file.ts:42-50` for multi-line selections + */ export function registerCopyPathLineAction( editor: Monaco.editor.IStandaloneCodeEditor, filePath: string, @@ -11,8 +17,6 @@ export function registerCopyPathLineAction( keybindings: [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyC, ], - contextMenuGroupId: "9_cutcopypaste", - contextMenuOrder: 4, run: (ed) => { const selection = ed.getSelection(); if (!selection) return; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/index.ts new file mode 100644 index 00000000000..9d9baaaf8fe --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/index.ts @@ -0,0 +1,4 @@ +export type { EditorActions, PaneActions } from "./EditorContextMenu"; +export { EditorContextMenu } from "./EditorContextMenu"; +export { registerCopyPathLineAction } from "./editor-actions"; +export { useEditorActions } from "./useEditorActions"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/useEditorActions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/useEditorActions.ts new file mode 100644 index 00000000000..e1419ad2e8e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/EditorContextMenu/useEditorActions.ts @@ -0,0 +1,155 @@ +import { toast } from "@superset/ui/sonner"; +import type * as Monaco from "monaco-editor"; +import { useCallback } from "react"; +import type { EditorActions } from "./EditorContextMenu"; + +interface UseEditorActionsProps { + getEditor: () => Monaco.editor.IStandaloneCodeEditor | null | undefined; + filePath: string; + /** If true, includes cut/paste actions (for editable editors) */ + editable?: boolean; +} + +/** + * Hook that creates all editor action handlers for the context menu. + * Shared between FileEditorContextMenu and DiffViewer. + * + * Note: Standalone Monaco editor doesn't include language service features + * like Go to Definition, References, Rename, etc. Those require language + * providers to be registered. We only expose actions that are actually available. + */ +export function useEditorActions({ + getEditor, + filePath, + editable = true, +}: UseEditorActionsProps): EditorActions { + const handleCut = useCallback(() => { + const editor = getEditor(); + if (!editor) return; + editor.focus(); + editor.trigger("contextMenu", "editor.action.clipboardCutAction", null); + }, [getEditor]); + + const handleCopy = useCallback(() => { + const editor = getEditor(); + if (!editor) return; + editor.focus(); + editor.trigger("contextMenu", "editor.action.clipboardCopyAction", null); + }, [getEditor]); + + const handlePaste = useCallback(() => { + const editor = getEditor(); + if (!editor) return; + editor.focus(); + editor.trigger("contextMenu", "editor.action.clipboardPasteAction", null); + }, [getEditor]); + + const handleSelectAll = useCallback(() => { + const editor = getEditor(); + if (!editor) return; + editor.focus(); + const model = editor.getModel(); + if (model) { + const fullRange = model.getFullModelRange(); + editor.setSelection(fullRange); + } + }, [getEditor]); + + const handleCopyPath = useCallback(async () => { + try { + await navigator.clipboard.writeText(filePath); + } catch (error) { + console.error("[handleCopyPath] Failed to copy path to clipboard:", { + error, + filePath, + }); + toast.error("Failed to copy path to clipboard", { + description: String(error), + }); + } + }, [filePath]); + + const handleCopyPathWithLine = useCallback(async () => { + const editor = getEditor(); + if (!editor) { + console.error( + "[handleCopyPathWithLine] Editor is missing, falling back to filePath only", + ); + try { + await navigator.clipboard.writeText(filePath); + } catch (error) { + console.error( + "[handleCopyPathWithLine] Failed to copy path to clipboard:", + { error, filePath }, + ); + toast.error("Failed to copy path to clipboard", { + description: String(error), + }); + } + return; + } + + const selection = editor.getSelection(); + if (!selection) { + console.error( + "[handleCopyPathWithLine] Selection is missing, falling back to filePath only", + ); + try { + await navigator.clipboard.writeText(filePath); + } catch (error) { + console.error( + "[handleCopyPathWithLine] Failed to copy path to clipboard:", + { error, filePath }, + ); + toast.error("Failed to copy path to clipboard", { + description: String(error), + }); + } + return; + } + + const { startLineNumber, endLineNumber } = selection; + const pathWithLine = + startLineNumber === endLineNumber + ? `${filePath}:${startLineNumber}` + : `${filePath}:${startLineNumber}-${endLineNumber}`; + + try { + await navigator.clipboard.writeText(pathWithLine); + } catch (error) { + console.error( + "[handleCopyPathWithLine] Failed to copy path with line to clipboard:", + { error, pathWithLine }, + ); + toast.error("Failed to copy path to clipboard", { + description: String(error), + }); + } + }, [filePath, getEditor]); + + const handleFind = useCallback(() => { + const editor = getEditor(); + if (!editor) return; + editor.focus(); + editor.trigger("contextMenu", "actions.find", null); + }, [getEditor]); + + const handleChangeAllOccurrences = useCallback(() => { + const editor = getEditor(); + if (!editor) return; + editor.focus(); + // Use selectHighlights which is available in standalone Monaco + editor.trigger("contextMenu", "editor.action.selectHighlights", null); + }, [getEditor]); + + return { + onCut: editable ? handleCut : undefined, + onCopy: handleCopy, + onPaste: editable ? handlePaste : undefined, + onSelectAll: handleSelectAll, + onCopyPath: handleCopyPath, + onCopyPathWithLine: handleCopyPathWithLine, + onFind: handleFind, + onChangeAllOccurrences: handleChangeAllOccurrences, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/index.ts new file mode 100644 index 00000000000..d395d82b884 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/index.ts @@ -0,0 +1,2 @@ +export type { EditorActions, PaneActions } from "./EditorContextMenu"; +export { EditorContextMenu, useEditorActions } from "./EditorContextMenu";