From f0a35e28e5e1237dc17cc851c3ce3befa693b327 Mon Sep 17 00:00:00 2001 From: NiXouYTB Date: Fri, 22 May 2026 12:49:43 +0200 Subject: [PATCH] Add editor tab context menu --- src/components/EditorPane.tsx | 218 +++++++++++++++++++++++++++++++++- src/components/Workspace.tsx | 44 +++++++ src/styles.css | 30 +++++ 3 files changed, 291 insertions(+), 1 deletion(-) diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index 453e534d..2fe7cef4 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -46,8 +46,12 @@ type Props = { activeIndex: number; onActivate: (index: number) => void; onClose: (index: number) => void; + onCloseOthers: (index: number) => void; + onCloseToRight: (index: number) => void; + onCloseAll: () => void; onChange: (index: number, value: string) => void; onSave: (index: number) => void; + onRevealTab: (index: number) => void; onOpenFile?: (path: string) => void; settingsOpen?: boolean; settingsActive?: boolean; @@ -62,8 +66,12 @@ export function EditorPane({ activeIndex, onActivate, onClose, + onCloseOthers, + onCloseToRight, + onCloseAll, onChange, onSave, + onRevealTab, onOpenFile, settingsOpen = false, settingsActive = false, @@ -89,6 +97,11 @@ export function EditorPane({ const [imageMenu, setImageMenu] = useState<{ x: number; y: number } | null>( null, ); + const [tabMenu, setTabMenu] = useState<{ + x: number; + y: number; + index: number; + } | null>(null); const activeTab: EditorTab | undefined = settingsActive ? undefined : tabs[activeIndex]; // Close the image context menu whenever the user switches tabs or // toggles into the settings view, so it never lingers on the wrong file. @@ -312,13 +325,34 @@ export function EditorPane({ return (
-
+
{tabs.map((tab, index) => (
onActivate(index)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivate(index); + return; + } + if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + onActivate(index); + setTabMenu({ x: rect.left + 16, y: rect.bottom - 2, index }); + } + }} + onContextMenu={(event) => { + event.preventDefault(); + onActivate(index); + setTabMenu({ x: event.clientX, y: event.clientY, index }); + }} title={tab.relativePath} > @@ -342,7 +376,16 @@ export function EditorPane({
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSettingsActivate?.(); + } + }} title="Settings" > @@ -387,6 +430,21 @@ export function EditorPane({ )}
+ {tabMenu && tabs[tabMenu.index] && ( + setTabMenu(null)} + onCloseTab={() => onClose(tabMenu.index)} + onCloseOthers={() => onCloseOthers(tabMenu.index)} + onCloseToRight={() => onCloseToRight(tabMenu.index)} + onCloseAll={onCloseAll} + onReveal={() => onRevealTab(tabMenu.index)} + /> + )}
{settingsOpen && ( @@ -499,6 +557,164 @@ function formatBytes(bytes: number): string { return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } +const TAB_MENU_WIDTH = 236; +const TAB_MENU_HEIGHT = 250; + +function EditorTabContextMenu({ + x, + y, + tab, + tabCount, + index, + onClose, + onCloseTab, + onCloseOthers, + onCloseToRight, + onCloseAll, + onReveal, +}: { + x: number; + y: number; + tab: EditorTab; + tabCount: number; + index: number; + onClose: () => void; + onCloseTab: () => void; + onCloseOthers: () => void; + onCloseToRight: () => void; + onCloseAll: () => void; + onReveal: () => void; +}) { + useEffect(() => { + const close = () => onClose(); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("pointerdown", close); + window.addEventListener("keydown", onKey, true); + window.addEventListener("resize", close); + document.addEventListener("scroll", close, true); + return () => { + window.removeEventListener("pointerdown", close); + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("resize", close); + document.removeEventListener("scroll", close, true); + }; + }, [onClose]); + + const clampedX = + typeof window === "undefined" + ? x + : Math.max(8, Math.min(x, window.innerWidth - TAB_MENU_WIDTH - 8)); + const clampedY = + typeof window === "undefined" + ? y + : Math.max(8, Math.min(y, window.innerHeight - TAB_MENU_HEIGHT - 8)); + + const runAction = (action: () => void | Promise) => () => { + onClose(); + void Promise.resolve(action()).catch((err) => + console.error("[tab-menu] action failed", err), + ); + }; + + const copyFullPath = runAction(() => copyText(tab.doc.absolutePath)); + const copyRelativePath = runAction(() => copyText(tab.relativePath)); + + return ( +
event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + > + + + = tabCount - 1} + onClick={runAction(onCloseToRight)} + /> + +
+ + +
+ +
+ ); +} + +function TabMenuItem({ + icon, + label, + shortcut, + disabled = false, + onClick, +}: { + icon: string; + label: string; + shortcut?: string; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +async function copyText(text: string): Promise { + await navigator.clipboard.writeText(text); +} + +function revealLabel(): string { + const platform = + typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; + if (platform.includes("mac")) return "Reveal in Finder"; + if (platform.includes("win")) return "Reveal in File Explorer"; + return "Reveal in File Manager"; +} + function isPreviewableImagePath(relativePath: string): boolean { return /\.(png|jpe?g|gif|webp|svg|bmp|avif|heic|heif)$/i.test(relativePath); } diff --git a/src/components/Workspace.tsx b/src/components/Workspace.tsx index 7267bb8d..cce0451e 100644 --- a/src/components/Workspace.tsx +++ b/src/components/Workspace.tsx @@ -663,6 +663,46 @@ export function Workspace({ }); }, []); + const closeOtherTabs = useCallback((index: number) => { + const tab = tabsRef.current[index]; + if (!tab) return; + setTabs([tab]); + setActiveTabIndex(0); + setSettingsActive(false); + }, []); + + const closeTabsToRight = useCallback((index: number) => { + const tabCount = tabsRef.current.length; + if (index < 0 || index >= tabCount - 1) return; + setTabs((prev) => prev.slice(0, index + 1)); + setActiveTabIndex((active) => { + if (active < 0) return active; + return active > index ? index : active; + }); + }, []); + + const closeAllTabs = useCallback(() => { + setTabs([]); + setActiveTabIndex(-1); + }, []); + + const revealTab = useCallback( + (index: number) => { + const tab = tabsRef.current[index]; + if (!tab) return; + if (tab.external) { + void api.revealAbsolutePath(tab.doc.absolutePath).catch((err) => + console.error(err), + ); + return; + } + void api.revealEntry(workspacePath, tab.relativePath).catch((err) => + console.error(err), + ); + }, + [workspacePath], + ); + const handleTreeEntryRenamed = useCallback( (oldRelativePath: string, entry: WorkspaceEntry) => { setTabs((prev) => @@ -1767,8 +1807,12 @@ export function Workspace({ activeIndex={activeTabIndex} onActivate={activateFileTab} onClose={closeTab} + onCloseOthers={closeOtherTabs} + onCloseToRight={closeTabsToRight} + onCloseAll={closeAllTabs} onChange={updateBuffer} onSave={saveTab} + onRevealTab={revealTab} onOpenFile={openChatFile} settingsOpen={settingsOpen} settingsActive={settingsActive} diff --git a/src/styles.css b/src/styles.css index 69f5b356..a049f581 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1160,6 +1160,15 @@ textarea { background: var(--bg-4); color: var(--text-0); } +.tree-menu__item:disabled { + cursor: default; + color: var(--text-faint); + opacity: 0.62; +} +.tree-menu__item:disabled:hover { + background: transparent; + color: var(--text-faint); +} .tree-menu__item[data-danger="true"] { color: var(--danger); } @@ -1175,6 +1184,10 @@ textarea { .tree-menu__item:hover svg { color: var(--text-1); } +.tree-menu__item:disabled svg, +.tree-menu__item:disabled:hover svg { + color: var(--text-faint); +} .tree-menu__item[data-danger="true"] svg, .tree-menu__item[data-danger="true"]:hover svg { color: var(--danger); @@ -1185,6 +1198,23 @@ textarea { background: var(--line-soft); } +.tab-menu { + min-width: 236px; +} +.tab-menu__item { + gap: 9px; +} +.tab-menu__item span { + flex: 1 1 auto; +} +.tab-menu__shortcut { + flex: 0 0 auto; + color: var(--text-faint); + font-family: var(--font-ui); + font-size: var(--fs-xs); + font-weight: 500; +} + /* Search */ .search-pane { flex: 1 1 0;