diff --git a/apps/tauri/src-tauri/src/main.rs b/apps/tauri/src-tauri/src/main.rs index be6dcf235..12cd0e68b 100644 --- a/apps/tauri/src-tauri/src/main.rs +++ b/apps/tauri/src-tauri/src/main.rs @@ -1684,22 +1684,24 @@ fn setup_menu(app: &AppHandle) -> Result<(), Box> { .item(&delete_item) .build()?; - // Edit menu with custom file operations and text operations + // Edit menu with custom file operations and native text operations + // Accelerators are handled smartly: native clipboard for text inputs, file ops for explorer + // IMPORTANT: Keep these always enabled so accelerators work in text inputs let cut_item = MenuItemBuilder::with_id("cut", "Cut") .accelerator("Cmd+X") - .enabled(false) + .enabled(true) .build(app)?; menu_items_map.insert("cut".to_string(), cut_item.clone()); let copy_item = MenuItemBuilder::with_id("copy", "Copy") .accelerator("Cmd+C") - .enabled(false) + .enabled(true) .build(app)?; menu_items_map.insert("copy".to_string(), copy_item.clone()); let paste_item = MenuItemBuilder::with_id("paste", "Paste") .accelerator("Cmd+V") - .enabled(false) + .enabled(true) .build(app)?; menu_items_map.insert("paste".to_string(), paste_item.clone()); @@ -1907,10 +1909,18 @@ fn setup_menu(app: &AppHandle) -> Result<(), Box> { tracing::error!("Failed to emit menu action: {}", e); } } - // Edit menu clipboard actions - trigger keybind handlers + // Edit menu clipboard actions - emit event for smart handling in frontend "cut" | "copy" | "paste" => { - let keybind_id = format!("explorer.{}", event_id); - keybinds::emit_keybind_triggered(&app_handle, &keybind_id); + tracing::info!("[Menu] Clipboard action triggered: {}", event_id); + // Emit generic clipboard event - frontend will decide if it's a text or file operation + if let Err(e) = app_handle.emit("clipboard-action", event_id) { + tracing::error!("Failed to emit clipboard action: {}", e); + } else { + tracing::info!( + "[Menu] Clipboard action event emitted successfully: {}", + event_id + ); + } } _ => {} } diff --git a/apps/tauri/src/keybinds.ts b/apps/tauri/src/keybinds.ts index 113b2ee7f..feb01c4c0 100644 --- a/apps/tauri/src/keybinds.ts +++ b/apps/tauri/src/keybinds.ts @@ -9,6 +9,50 @@ type KeybindHandler = () => void | Promise; const keybindHandlers = new Map(); let eventUnlisten: UnlistenFn | null = null; +let clipboardUnlisten: UnlistenFn | null = null; + +// Check if an input element is currently focused +function isInputFocused(): boolean { + const activeElement = document.activeElement; + console.log('[Clipboard] Active element:', { + element: activeElement, + tagName: activeElement?.tagName, + type: (activeElement as HTMLInputElement)?.type, + contenteditable: activeElement?.getAttribute('contenteditable') + }); + + if (!activeElement) { + console.log('[Clipboard] No active element'); + return false; + } + + const tagName = activeElement.tagName.toLowerCase(); + if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') { + console.log('[Clipboard] Input element focused:', tagName); + return true; + } + + // Check for contenteditable + if (activeElement.getAttribute('contenteditable') === 'true') { + console.log('[Clipboard] Contenteditable element focused'); + return true; + } + + console.log('[Clipboard] Non-input element focused:', tagName); + return false; +} + +// Execute native clipboard operation (for text inputs) +function executeNativeClipboard(action: 'copy' | 'cut' | 'paste'): void { + console.log(`[Clipboard] Executing native ${action} operation`); + try { + // Use execCommand for compatibility (deprecated but still works) + const result = document.execCommand(action); + console.log(`[Clipboard] execCommand('${action}') result:`, result); + } catch (err) { + console.error(`[Clipboard] Failed to execute native ${action}:`, err); + } +} // Initialize Tauri keybind listener export async function initializeKeybindHandler(): Promise { @@ -27,6 +71,36 @@ export async function initializeKeybindHandler(): Promise { } }); + // Listen for clipboard actions from native menu + clipboardUnlisten = await listen('clipboard-action', async (event) => { + const action = event.payload as 'copy' | 'cut' | 'paste'; + console.log(`[Clipboard] Received clipboard-action event:`, action); + + // Check if an input is focused + if (isInputFocused()) { + // Execute native browser clipboard operation + console.log('[Clipboard] Input focused, executing native operation'); + executeNativeClipboard(action); + } else { + // Trigger file operation via keybind system + const keybindId = `explorer.${action}`; + console.log('[Clipboard] No input focused, triggering file operation:', keybindId); + const handler = keybindHandlers.get(keybindId); + if (handler) { + try { + await handler(); + console.log(`[Clipboard] File operation ${keybindId} completed`); + } catch (err) { + console.error(`[Clipboard] Handler error for ${keybindId}:`, err); + } + } else { + console.warn(`[Clipboard] No handler registered for ${keybindId}`); + } + } + }); + + console.log('[Clipboard] Action listener initialized'); + console.log('[Keybind] Handler initialized'); } @@ -69,6 +143,11 @@ export async function cleanupKeybindHandler(): Promise { eventUnlisten = null; } + if (clipboardUnlisten) { + clipboardUnlisten(); + clipboardUnlisten = null; + } + // Unregister all keybinds const ids = Array.from(keybindHandlers.keys()); for (const id of ids) { diff --git a/packages/interface/src/components/Explorer/SelectionContext.tsx b/packages/interface/src/components/Explorer/SelectionContext.tsx index ecdf8147b..9c1976249 100644 --- a/packages/interface/src/components/Explorer/SelectionContext.tsx +++ b/packages/interface/src/components/Explorer/SelectionContext.tsx @@ -82,15 +82,13 @@ export function SelectionProvider({ const hasSelection = selectedFiles.length > 0; const isSingleSelection = selectedFiles.length === 1; - const hasClipboard = clipboard.hasClipboard(); platform.updateMenuItems?.([ - { id: "copy", enabled: hasSelection }, - { id: "cut", enabled: hasSelection }, + // NOTE: copy/cut/paste are always enabled to support text input operations + // They intelligently route to file ops or native clipboard based on focus { id: "duplicate", enabled: hasSelection }, { id: "rename", enabled: isSingleSelection }, { id: "delete", enabled: hasSelection }, - { id: "paste", enabled: hasClipboard }, ]); }, [selectedFiles, clipboard, platform, isActiveTab]); diff --git a/packages/interface/src/components/Explorer/hooks/useFileContextMenu.ts b/packages/interface/src/components/Explorer/hooks/useFileContextMenu.ts index 5849fff87..6c85c6f54 100644 --- a/packages/interface/src/components/Explorer/hooks/useFileContextMenu.ts +++ b/packages/interface/src/components/Explorer/hooks/useFileContextMenu.ts @@ -34,7 +34,7 @@ import { useSelection } from "../SelectionContext"; import { useOpenWith } from "../../../hooks/useOpenWith"; interface UseFileContextMenuProps { - file: File; + file?: File | null; selectedFiles: File[]; selected: boolean; } @@ -59,7 +59,7 @@ export function useFileContextMenu({ const targets = selected && selectedFiles.length > 0 ? selectedFiles : [file]; return targets - .filter((f) => "Physical" in f.sd_path) + .filter((f) => f && f.sd_path && "Physical" in f.sd_path) .map((f) => (f.sd_path as any).Physical.path); }; @@ -73,13 +73,13 @@ export function useFileContextMenu({ const targets = selected && selectedFiles.length > 0 ? selectedFiles : [file]; // Filter out virtual files - they cannot be copied/moved/deleted - return targets.filter((f) => !isVirtualFile(f)); + return targets.filter((f) => f && !isVirtualFile(f)); }; // Check if any selected files are virtual (to disable certain operations) const hasVirtualFiles = selected ? selectedFiles.some((f) => isVirtualFile(f)) - : isVirtualFile(file); + : file ? isVirtualFile(file) : false; return useContextMenu({ items: [ @@ -87,15 +87,18 @@ export function useFileContextMenu({ icon: Eye, label: "Quick Look", onClick: () => { + if (!file) return; console.log("Quick Look:", file.name); // TODO: Implement quick look }, keybind: "Space", + condition: () => !!file, }, { icon: FolderOpen, label: "Open", onClick: async () => { + if (!file) return; if (file.kind === "Directory") { navigateToPath(file.sd_path); } else if ("Physical" in file.sd_path) { @@ -104,19 +107,21 @@ export function useFileContextMenu({ } }, keybind: "⌘O", - condition: () => file.kind === "Directory" || file.kind === "File", + condition: () => !!file && (file.kind === "Directory" || file.kind === "File"), }, { type: "submenu", icon: ArrowSquareOut, label: "Open With", condition: () => + !!file && file.kind === "File" && "Physical" in file.sd_path && apps.length > 0, submenu: apps.map((app) => ({ label: app.name, onClick: async () => { + if (!file) return; if (selected && selectedFiles.length > 1) { await openMultipleWithApp(physicalPaths, app.id); } else if ("Physical" in file.sd_path) { @@ -131,6 +136,7 @@ export function useFileContextMenu({ icon: MagnifyingGlass, label: "Show in Finder", onClick: async () => { + if (!file) return; // Extract the physical path from SdPath if ("Physical" in file.sd_path) { const physicalPath = file.sd_path.Physical.path; @@ -152,17 +158,18 @@ export function useFileContextMenu({ }, keybind: "⌘⇧R", condition: () => - "Physical" in file.sd_path && !!platform.revealFile, + !!file && "Physical" in file.sd_path && !!platform.revealFile, }, { type: "separator" }, { icon: Pencil, label: "Rename", onClick: () => { + if (!file) return; startRename(file.id); }, keybindId: "explorer.renameFile", - condition: () => selected && selectedFiles.length === 1 && !hasVirtualFiles, + condition: () => !!file && selected && selectedFiles.length === 1 && !hasVirtualFiles, }, { icon: FolderPlus, @@ -292,7 +299,7 @@ export function useFileContextMenu({ type: "submenu", icon: Image, label: "Image Processing", - condition: () => getContentKind(file) === "image", + condition: () => !!file && getContentKind(file) === "image", submenu: [ { icon: Sparkle, @@ -304,7 +311,7 @@ export function useFileContextMenu({ generate_blurhash: true, }); }, - condition: () => !file.image_media_data?.blurhash, + condition: () => !!file && !file.image_media_data?.blurhash, }, { icon: Crop, @@ -334,7 +341,7 @@ export function useFileContextMenu({ type: "submenu", icon: Video, label: "Video Processing", - condition: () => getContentKind(file) === "video", + condition: () => !!file && getContentKind(file) === "video", submenu: [ { icon: FilmStrip, @@ -347,6 +354,7 @@ export function useFileContextMenu({ }); }, condition: () => + !!file && !file.sidecars?.some( (s) => s.kind === "thumbstrip", ), @@ -361,7 +369,7 @@ export function useFileContextMenu({ generate_blurhash: true, }); }, - condition: () => !file.video_media_data?.blurhash, + condition: () => !!file && !file.video_media_data?.blurhash, }, { icon: Crop, @@ -403,7 +411,7 @@ export function useFileContextMenu({ type: "submenu", icon: Microphone, label: "Audio Processing", - condition: () => getContentKind(file) === "audio", + condition: () => !!file && getContentKind(file) === "audio", submenu: [ { icon: TextAa, @@ -424,6 +432,7 @@ export function useFileContextMenu({ icon: FileText, label: "Document Processing", condition: () => + !!file && file.kind === "File" && ["pdf", "doc", "docx"].includes(file.extension || ""), submenu: [ @@ -506,7 +515,7 @@ export function useFileContextMenu({ const message = targets.length > 1 ? `Delete ${targets.length} items?` - : `Delete "${file.name}"?`; + : `Delete "${file?.name ?? "this file"}"?`; if (confirm(message)) { console.log(