Skip to content
Open
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
26 changes: 26 additions & 0 deletions Sources/DigUpApp/SearchPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
47 changes: 45 additions & 2 deletions Sources/DigUpApp/SearchPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
}
Expand All @@ -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?
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions shell/src-tauri/src/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16> = 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::<u8>(), 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(&gtk::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())
}
87 changes: 85 additions & 2 deletions shell/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> 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<String> {
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).
Expand Down Expand Up @@ -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!())
Expand Down Expand Up @@ -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::<Arc<Shell>>().inner().folders());
if cfg!(debug_assertions) {
println!("panel: shown");
}
Expand Down Expand Up @@ -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]
Expand Down
16 changes: 14 additions & 2 deletions shell/ui/panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
Loading