From 09a80ae943e9997c0031aa07ce22d66ce1a7d42f Mon Sep 17 00:00:00 2001 From: Ocean572 <45051223+Ocean572@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:18:47 -0600 Subject: [PATCH] =?UTF-8?q?Copy=20the=20selected=20result's=20path=20as=20?= =?UTF-8?q?text=20on=20a=20hotkey=20=E2=80=94=20Ctrl+Shift+C=20absolute,?= =?UTF-8?q?=20Ctrl+Shift+R=20relative=20to=20its=20search=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #148 asked for a hotkey that puts the path of the selected search result on the clipboard as text, to paste into open/upload dialogs: the existing Ctrl+C copies the file itself (file-manager/chat paste), which does not paste into a dialog's name field, and the window's "Copy Path" menu line used `navigator.clipboard`, which needs Tauri's clipboard permission the shell's capability file doesn't carry — so it did nothing on Linux/Windows (and the panel had no path copy at all). The shell (Windows/Linux): a shared `copyPathText` in `results.js` takes Ctrl+Shift+C / Ctrl+Shift+R on a focused result in the panel and the window, and the window's right-click menu gains "Copy Relative Path" beside "Copy Path". The relative spelling comes from a Rust helper, `relative_path` (the longest containing search folder, `/` shown, case-insensitive like Windows spells folders; absolute when no root holds the file), exposed as `relative_path_text`. The text goes onto the system clipboard through a new `copy_text` command in `clipboard.rs` — CF_UNICODETEXT on Windows, GTK clipboard text on Linux, the native channel Ctrl+C's file copy already uses. The pre-push review rejected the original `navigator.clipboard.writeText` plan: Tauri serves its pages from a custom scheme (`http://tauri.localhost` on Windows), a non-secure context where the web clipboard API is unavailable — the native route needs no capability, no plugin, and no secure origin, and it is the mechanism this app has always pasted with. The panel's `panel-shown` event carries the shell's current folders, which the page keeps for the relative spelling (and the panel page gained its `state.info` — the review caught that the panel's state literal had none, so the first listener line threw on every show and the field lost focus: a visible panel breakage no suite at that commit exercised). Text selected in the query field or page keeps its own copy — Ctrl+Shift+C is WebKitGTK's copy-selection shortcut, and the guard lets it through. The Mac app mirrors it: ⌘⇧C / ⌘⇧R in the panel (SearchPanel key handling, same selected-text rule as ⌘C's), the panel key hints say "⌘⇧C Path", and the rows' menu gains "Copy Relative Path" — offered only where the folders are known (the panel threads them; the window rows don't today, and an item that would echo the absolute path under a relative label is a promise not to make). The spelling is one function, `FileDragView.relativeSpelling`, used by keys and menu alike; it trims trailing separators (any number) and treats a root spelled `/` as holding every absolute path, matching the shell's `relative_path` (the shell compares ASCII as Windows spells folder names, the Mac the whole alphabet — disclosed divergence for non-ASCII roots). Verified on this box: cargo test 14 green — `copy_path_tests` covers Windows backslashes, longest-folder nesting, case-insensitive roots, no-root and root-itself fallbacks, the `/` root (and the longest-folder rule beating it), and roots with one or many trailing separators; Swift suite 125 green (base count, app target compiles); shell JS `node --test` 17 pass, all pages pass `node --check`. Not verified: the clipboard writes in a live Tauri webview (the shell app doesn't run on macOS; the Windows `copy_text` block compiles only for Windows targets, disclosed), and the Mac panel keys (app GUI acceptance, morning). Disclosed edges: on Windows WebView2 Ctrl+Shift+C may be captured by the browser control for its own copy — the has-selection guard covers the common case; Ctrl+Shift+R is free. A Linux filename containing a literal `\` gets its separators rewritten in the relative spelling (both impls; near-unseen). Window-page folders refresh at window load (panel folders are fresh at every show) — a mid-session folder change on an open window spells new paths against the list the window was opened with until reload. --- Sources/DigUpApp/SearchPanel.swift | 26 ++++++++ Sources/DigUpApp/SearchPanelView.swift | 47 +++++++++++++- shell/src-tauri/src/clipboard.rs | 57 +++++++++++++++++ shell/src-tauri/src/main.rs | 87 +++++++++++++++++++++++++- shell/ui/panel.js | 16 ++++- shell/ui/results.js | 25 ++++++++ shell/ui/window.js | 13 +++- 7 files changed, 264 insertions(+), 7 deletions(-) diff --git a/Sources/DigUpApp/SearchPanel.swift b/Sources/DigUpApp/SearchPanel.swift index 0134df4e..ad701757 100644 --- a/Sources/DigUpApp/SearchPanel.swift +++ b/Sources/DigUpApp/SearchPanel.swift @@ -91,6 +91,9 @@ final class SearchPanelController { var rows: [ResultRow] { results.groups.flatMap(\.rows) + (showWeaker ? results.weaker : []) } var selectedRow: ResultRow? { rows.first { $0.id == selection } } + /// The search folders, as text: "Copy Relative Path" for the rows' menus (issue #148). + var searchFolderPaths: [String] { engine.folders.roots.map(\.path) } + // MARK: Showing func toggle() { @@ -347,6 +350,14 @@ final class SearchPanelController { case 31 where modifiers == .command: // ⌘O: the same search in the window, for a bigger look openInWindow() return true + case 8, 18 where modifiers == [.command, .shift]: + // ⌘⇧C / ⌘⇧R: the selected file's path as text — absolute, inside its search folder — for + // pasting into an open/upload dialog, where a copied file won't paste (issue #148). Text + // the person selected in the query field stays a text copy, as ⌘C's rule above. + let textSelected = (field?.currentEditor()?.selectedRange.length ?? 0) > 0 + if textSelected, !navigated { return false } + copyPathSelected(relative: event.keyCode == 18) + return true case 8 where modifiers == .command: // ⌘C copies the selected file, except right after you selected some of the query text (the panel opens // with the last query selected, and arrowing to a result means you want the file). @@ -395,6 +406,21 @@ final class SearchPanelController { flash("Copied \(row.name)") } + /// ⌘⇧C / ⌘⇧R: the selected file's path as *text* — absolute, or inside the search folder that + /// contains it (the longest when folders nest; issue #148). For pasting into an open/upload + /// dialog, where a copied file won't paste. Outside every search folder, absolute. The spelling + /// is the row menu's own (`FileDragView.relativeSpelling`), so keys and menu always agree. + func copyPathSelected(relative: Bool) { + guard let row = selectedRow else { return } + let path = row.url.path + let text = relative + ? FileDragView.relativeSpelling(of: path, folders: engine.folders.roots.map(\.path)) + : path + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + flash(text == path ? "Copied the path" : "Copied \(text)") + } + /// The search window, with this query (and only one kind, if given): for a bigger look. func openInWindow(_ kind: ResultGroupKind? = nil) { let text = query diff --git a/Sources/DigUpApp/SearchPanelView.swift b/Sources/DigUpApp/SearchPanelView.swift index 4d1d0d54..c2255cb2 100644 --- a/Sources/DigUpApp/SearchPanelView.swift +++ b/Sources/DigUpApp/SearchPanelView.swift @@ -122,10 +122,10 @@ struct SearchPanelView: View { let row = controller.selectedRow if let row, row.kind == .code { let line = row.segment == .lines ? " at line \(row.codeLine)" : "" - return "↩ \(row.openTitle)\(line) ⌘↩ Reveal ␣ Quick Look ⌘C Copy" + return "↩ \(row.openTitle)\(line) ⌘↩ Reveal ␣ Quick Look ⌘C Copy ⌘⇧C Path" } let place = row?.jump.map { " at \($0)" } ?? "" - return "↩ \(row?.openTitle ?? "Open") ⌘↩ Reveal ␣ Quick Look\(place) ⌘C Copy" + return "↩ \(row?.openTitle ?? "Open") ⌘↩ Reveal ␣ Quick Look\(place) ⌘C Copy ⌘⇧C Path" } private var statusText: String { @@ -163,6 +163,7 @@ private struct ResultList: View { PanelRow(row: row, selected: row.id == controller.selection) .id(row.id) .overlay(FileDragArea(url: row.url, openTitle: row.openTitle, + searchFolders: controller.searchFolderPaths, onClick: { controller.select(row) }, onOpen: { controller.select(row) @@ -195,6 +196,7 @@ private struct ResultList: View { .id(row.id) .opacity(0.85) .overlay(FileDragArea(url: row.url, openTitle: row.openTitle, + searchFolders: controller.searchFolderPaths, onClick: { controller.select(row) }, onOpen: { controller.select(row) @@ -430,6 +432,10 @@ struct FileDragArea: NSViewRepresentable { let url: URL /// "Open at 0:06": what double-click and the menu's first item do. var openTitle = "Open" + /// The search folders' paths: the menu's "Copy Relative Path" (issue #148) makes its spelling + /// against the longest one that contains the file; with none (the window rows pass no folders + /// today) the line is not offered. + var searchFolders: [String] = [] let onClick: () -> Void let onOpen: () -> Void @@ -438,6 +444,7 @@ struct FileDragArea: NSViewRepresentable { func updateNSView(_ view: FileDragView, context: Context) { view.url = url view.openTitle = openTitle + view.searchFolders = searchFolders view.onClick = onClick view.onOpen = onOpen } @@ -446,6 +453,7 @@ struct FileDragArea: NSViewRepresentable { final class FileDragView: NSView, NSDraggingSource { var url: URL? var openTitle = "Open" + var searchFolders: [String] = [] var onClick: () -> Void = {} var onOpen: () -> Void = {} private var mouseDownEvent: NSEvent? @@ -497,9 +505,44 @@ final class FileDragView: NSView, NSDraggingSource { NSPasteboard.general.clearContents() NSPasteboard.general.setString(url.path, forType: .string) }) + if !searchFolders.isEmpty { + // Window rows don't thread their folders (only the panel's pass controller.searchFolderPaths + // today), and with no folders the relative line only echoes the absolute path under a + // relative label — so it is offered where folders are known, not promised everywhere. + menu.addItem(ActionMenuItem("Copy Relative Path") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(Self.relativeSpelling(of: url.path, folders: self.searchFolders), forType: .string) + }) + } return menu } + /// The file's path inside the search folder that contains it — the longest when folders nest, + /// case-insensitive (issue #148, mirroring the shell's `relative_path`; the shell compares ASCII + /// as Windows spells folders, here the whole alphabet — a Mac folder in any script gets its + /// relative spelling too). Trailing separators are trimmed, any number; a root spelled `/` + /// holds every absolute path. Outside every folder, absolute. + static func relativeSpelling(of path: String, folders: [String]) -> String { + let shown = path.replacingOccurrences(of: "\\", with: "/") + let stems = folders.map { folder in + var stem = folder.replacingOccurrences(of: "\\", with: "/") + while stem.hasSuffix("/") && stem != "/" { stem = String(stem.dropLast()) } + return stem == "/" ? "" : stem // the root "/": everything absolute sits under it + } + let holding = stems + .filter { stem in + stem.isEmpty ? shown.hasPrefix("/") + : (shown == stem + || (shown.count > stem.count + && shown[shown.index(shown.startIndex, offsetBy: stem.count)] == "/" + && shown.prefix(stem.count).caseInsensitiveCompare(stem) == .orderedSame)) + } + .max { $0.count < $1.count } + guard let stem = holding else { return path } + let rest = shown.dropFirst(stem.count).drop(while: { $0 == "/" }) + return rest.isEmpty ? path : String(rest) + } + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { // Copy, never move: dropping a result into a Finder folder must not take it away from where it lives. diff --git a/shell/src-tauri/src/clipboard.rs b/shell/src-tauri/src/clipboard.rs index 800f3897..2fc6f64d 100644 --- a/shell/src-tauri/src/clipboard.rs +++ b/shell/src-tauri/src/clipboard.rs @@ -75,3 +75,60 @@ pub fn copy_file(window: &tauri::WebviewWindow, path: &Path) -> Result<(), Strin pub fn copy_file(_: &tauri::WebviewWindow, _: &Path) -> Result<(), String> { Err("the shell copies files on Windows and Linux (on a Mac, the DigUp app does)".into()) } + +/// Copy a result's path as plain text (Ctrl+Shift+C / Ctrl+Shift+R, issue #148): the same native +/// clipboard route as `copy_file` above, because that one is what the app has always pasted with. +/// The webview API (`navigator.clipboard`) is not a dependable floor here — Tauri serves its pages +/// from a custom scheme, `http://tauri.localhost` on Windows, which WebKit/WebView2 treat as a +/// non-secure context where the clipboard API is unavailable — so the hotkeys ride the native +/// channel, which needs no capability, no plugin, and no secure origin. +#[cfg(windows)] +pub fn copy_text(window: &tauri::WebviewWindow, text: &str) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::GlobalFree; + use windows_sys::Win32::System::DataExchange::{CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData}; + use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE}; + const CF_UNICODETEXT: u32 = 13; + let wide: Vec = std::ffi::OsStr::new(text).encode_wide().chain([0]).collect(); + let owner = window.hwnd().map_err(|error| error.to_string())?.0 as _; + // SAFETY: the block is sized for the text and its NUL and written within that; once the + // clipboard takes it, it owns it (freed here only when it didn't). + unsafe { + let memory = GlobalAlloc(GMEM_MOVEABLE, wide.len() * 2); + if memory.is_null() { + return Err("no memory for the clipboard".into()); + } + std::ptr::copy_nonoverlapping(wide.as_ptr().cast::(), GlobalLock(memory) as *mut u8, wide.len() * 2); + GlobalUnlock(memory); + // The window owns the clipboard: without an owner, Windows won't take the data. + if OpenClipboard(owner) == 0 { + GlobalFree(memory); + return Err("the clipboard is busy".into()); + } + EmptyClipboard(); + let taken = !SetClipboardData(CF_UNICODETEXT, memory).is_null(); + CloseClipboard(); + if !taken { + GlobalFree(memory); + return Err("the clipboard didn't take the text".into()); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +pub fn copy_text(window: &tauri::WebviewWindow, text: &str) -> Result<(), String> { + use gtk::prelude::*; + let text = text.to_string(); + window + .run_on_main_thread(move || { + let clipboard = gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD); + clipboard.set_text(&text); + }) + .map_err(|error| error.to_string()) +} + +#[cfg(not(any(windows, target_os = "linux")))] +pub fn copy_text(_: &tauri::WebviewWindow, _: &str) -> Result<(), String> { + Err("the shell copies text on Windows and Linux (on a Mac, the DigUp app does)".into()) +} diff --git a/shell/src-tauri/src/main.rs b/shell/src-tauri/src/main.rs index 375fbee2..5075775e 100644 --- a/shell/src-tauri/src/main.rs +++ b/shell/src-tauri/src/main.rs @@ -421,6 +421,57 @@ fn copy_file(window: tauri::WebviewWindow, path: String) -> Result<(), String> { clipboard::copy_file(&window, Path::new(&path)).inspect_err(|error| eprintln!("digup-shell: can't copy {path}: {error}")) } +/// Ctrl+Shift+C / Ctrl+Shift+R on a result (and the window menu's two lines): the file's path as *text* +/// — absolute, or inside the search folder that contains it — for pasting into an open/upload dialog, +/// where a copied file won't paste (issue #148). `roots` is the page's list of search folders; a path +/// no root contains copies absolute. The page hands the text to `copy_text` below. +#[tauri::command] +fn relative_path_text(path: String, roots: Vec) -> String { + relative_path(&path, &roots).unwrap_or(path) +} + +/// The path text onto the system clipboard: Windows CF_UNICODETEXT / GTK clipboard text — the native +/// channel the file copy (Ctrl+C) already uses, needing no capability and no secure web origin +/// (issue #148: the web `navigator.clipboard` is unavailable on Tauri's custom-scheme origins). +#[tauri::command] +fn copy_text(window: tauri::WebviewWindow, text: String) -> Result<(), String> { + clipboard::copy_text(&window, &text).inspect_err(|error| eprintln!("digup-shell: can't copy text: {error}")) +} + +/// The path inside the search folder that contains it — the longest match wins (a folder inside +/// another listed one); compared case-insensitively, as Windows spells folders; `/` shown. None when +/// no root holds it (then the caller copies the absolute path) — and when it *is* a root itself, +/// where there is no shorter spelling left. A root spelled `/` (searching the whole Unix filesystem) +/// holds every absolute path. +fn relative_path(path: &str, roots: &[String]) -> Option { + let shown = path.replace('\\', "/"); + roots + .iter() + .map(|root| { + let stem = root.replace('\\', "/"); + let stem = stem.trim_end_matches('/'); // any number of trailing separators: "C:\Initial\" + let hit = if stem.is_empty() { + usize::from(shown.starts_with('/')) // the root "/" — the trimmed stem matches by prefix + } else if shown == stem { + stem.len() + } else if shown.len() > stem.len() + && shown.as_bytes()[stem.len()] == b'/' + && shown[..stem.len()].eq_ignore_ascii_case(stem) + { + stem.len() + } else { + 0 + }; + (hit, stem.len()) + }) + .filter(|(hit, _)| *hit > 0) + .max_by_key(|(hit, _)| *hit) + .and_then(|(_, stem_len)| { + let rest = shown[stem_len..].trim_start_matches('/').to_string(); + (!rest.is_empty()).then_some(rest) + }) +} + /// A result dragged out of the window (to a file manager, a mail, a chat) on Windows and the Mac: the system's own drag /// of the file, with its thumbnail (`icon`, a picture already rendered) or DigUp's icon under the pointer, started while /// the button is down. On Linux the page drags it itself (WebKit's drag starts from the press, as Wayland wants). @@ -652,7 +703,7 @@ fn main() { panel_area, settings_info, set_open_at_login, set_center_panel, set_processor_only, debug_query, debug_report, window_info, app_state, open_window, open_settings, choose_folders, index_now, quit_app, close_window, pause_indexing, remove_folder, set_skipped, set_skip_types, set_index_hours, code_setup_info, set_code_folders, choose_code_folders, - remove_code_folder, turn_off_code, set_code_editor, window_resized, copy_file, drag_out, set_hotkey, hold_hotkey, + remove_code_folder, turn_off_code, set_code_editor, window_resized, copy_file, relative_path_text, copy_text, drag_out, set_hotkey, hold_hotkey, set_tray_click, check_for_updates, offer_update, set_update_checks, open_licenses ]) .build(tauri::generate_context!()) @@ -1051,7 +1102,9 @@ fn show_panel(app: &AppHandle) { } let _ = panel.show(); let _ = panel.set_focus(); - let _ = panel.emit("panel-shown", ()); + // The folders travel with the event: the panel's relative-path copy (issue #148) needs the shell's + // current list, and a panel page never asks for one. + let _ = panel.emit("panel-shown", app.state::>().inner().folders()); if cfg!(debug_assertions) { println!("panel: shown"); } @@ -2016,6 +2069,36 @@ fn reload(app: &AppHandle, now: bool) { }); } +#[cfg(test)] +mod copy_path_tests { + use super::relative_path; + + /// Issue #148's shapes: the folder that contains the file, the longest when folders nest; + /// Windows' backslashes spelled as `/`; case-insensitive as Windows spells them; no root, the + /// file outside every root, and the root itself give nothing (the callers copy absolute then). + #[test] + fn relative_to_the_search_folder_that_contains_it() { + assert_eq!(relative_path("/home/me/Pics/a/b.png", &["/home/me/Pics".into()]), + Some("a/b.png".into())); + assert_eq!(relative_path("C:\\Initial\\Secret\\note.md", &["C:\\Initial\\".into()]), + Some("Secret/note.md".into())); + assert_eq!(relative_path("/home/me/Pics/a.png", &["/home/me".into(), "/home/me/Pics".into()]), + Some("a.png".into())); // the longest folder wins + assert_eq!(relative_path("/HOME/me/Pics/a.png", &["/home/Me/Pics/".into()]), + Some("a.png".into())); // Windows' spelling + assert_eq!(relative_path("/home/me/Else/a.png", &["/home/me/Pics".into()]), None); + assert_eq!(relative_path("/home/me/Pics", &["/home/me/Pics".into()]), None); // the root itself + assert_eq!(relative_path("/home/me/Pics/a.png", &[]), None); + // Review cases: the root spelled "/" holds every absolute path (the longest-folder rule + // still picks a deeper folder first), and a root with trailing separators — one or many. + assert_eq!(relative_path("/home/me/Pics/a.png", &["/".into()]), Some("home/me/Pics/a.png".into())); + assert_eq!(relative_path("/home/me/Pics/a.png", &["/".into(), "/home/me".into()]), Some("Pics/a.png".into())); + assert_eq!(relative_path("/", &["/".into()]), None); + assert_eq!(relative_path("C:\\Initial\\Secret\\note.md", &["C:\\Initial\\\\".into()]), + Some("Secret/note.md".into())); // two trailing separators + } +} + #[cfg(test)] mod tests { #[test] diff --git a/shell/ui/panel.js b/shell/ui/panel.js index a928069e..9f31743b 100644 --- a/shell/ui/panel.js +++ b/shell/ui/panel.js @@ -42,6 +42,8 @@ const state = { error: null, indexing: null, // {done, total} while the index helper works through new files caveat: null, // why results may be incomplete right now (the shell's status.rs) + info: { folders: [] }, // the shell's search folders, fresh with every panel-shown: the + // relative-path copy (results.js) spells paths against these }; let debugPending = false; @@ -481,9 +483,16 @@ document.addEventListener("keydown", (event) => { break; case "c": case "C": - if (!modifier || event.altKey || event.shiftKey || state.selection === WEAKER) return; + if (!modifier || event.altKey) return; + if (event.shiftKey) { copyPathText(event, selectedHit(), state.navigated, true); return; } + if (state.selection === WEAKER) return; copyResult(event, selectedHit(), state.navigated); break; + case "r": + case "R": + if (!modifier || event.altKey || !event.shiftKey) return; + copyPathText(event, selectedHit(), state.navigated, false); + break; case "o": case "O": if (!modifier || event.altKey || event.shiftKey) return; @@ -493,7 +502,10 @@ document.addEventListener("keydown", (event) => { } }); -tauri.event.listen("panel-shown", () => { +tauri.event.listen("panel-shown", ({ payload }) => { + // The shell's current search folders ride along (issue #148: Ctrl+Shift+R copies a path relative to + // them; showing the panel is as good a moment as any to have them be current). + if (Array.isArray(payload)) state.info.folders = payload; closeBig(); // Like Spotlight: the last query, selected. $query.focus(); diff --git a/shell/ui/results.js b/shell/ui/results.js index 63d39e96..d61c4e2a 100644 --- a/shell/ui/results.js +++ b/shell/ui/results.js @@ -61,6 +61,31 @@ function copyResult(event, hit, navigated) { return true; } +/// Ctrl+Shift+C / Ctrl+Shift+R on a result: its path as *text* — absolute, or inside the search folder +/// that contains it — for pasting into an open/upload dialog, where a copied file won't paste (issue +/// #148). Text selected in the page, or in the field before the arrows chose a result, stays a text +/// copy (WebKitGTK copies it on the same keys). True when it took the keys. +async function copyPathText(event, hit, navigated, absolute) { + const field = document.activeElement; + const fieldText = field?.selectionStart != null && field.selectionStart !== field.selectionEnd; + const textSelected = fieldText ? !navigated : Boolean(window.getSelection()?.toString()); + if (!hit || textSelected) return false; + event.preventDefault(); + const text = absolute ? hit.path + : await invoke("relative_path_text", { path: hit.path, roots: state.info.folders ?? [] }); + try { + // The native clipboard — the same route Ctrl+C's copy_file takes. navigator.clipboard is + // not dependable in Tauri webviews (their pages are served from a custom scheme, + // http://tauri.localhost on Windows, a non-secure context where the web clipboard is + // unavailable), so the hotkeys ride the native channel: copy_text needs no capability + // and no secure origin (issue #148 pre-push review). + await invoke("copy_text", { text }); + } catch (error) { + failed(error); + } + return true; +} + /// Where a result opens: a PDF's page past the first, a recording's or video's moment past its first second (the Mac /// app's Moments). function placeToOpen(hit) { diff --git a/shell/ui/window.js b/shell/ui/window.js index 5cf22828..52aeb418 100644 --- a/shell/ui/window.js +++ b/shell/ui/window.js @@ -380,7 +380,8 @@ function wire(item, hit) { [`Show in ${FILE_MANAGER}`, () => reveal(hit)], null, ["Copy", () => invoke("copy_file", { path: hit.path }).catch(failed)], - ["Copy Path", () => navigator.clipboard.writeText(hit.path).catch(() => {})], + ["Copy Path", () => navigator.clipboard.writeText(hit.path).catch(failed)], + ["Copy Relative Path", () => copyPathText({ preventDefault: () => {} }, hit, true, false)], ]); }; } @@ -1002,6 +1003,16 @@ document.addEventListener("keydown", (event) => { hideMenu(); return; } + if (modifier && event.shiftKey && !event.altKey) { + // Ctrl+Shift+C / Ctrl+Shift+R on a result: its path as text, absolute or inside its search folder + // (issue #148). Text selected in the field/page keeps its own copy — copyPathText declines then. + const key = event.key.toLowerCase(); + if (key === "c" || key === "r") { + copyPathText(event, state.hasResults && state.query.trim() ? selectedHit() : null, + state.navigated, key === "c"); + return; + } + } if (modifier && !event.altKey && !event.shiftKey) { const number = Number(event.key); if (number >= 1 && number <= 6) {