Skip to content

Commit 6778494

Browse files
committed
Quick Look: native ⇧Space preview + Finder-convert hint
- ⇧Space opens `QLPreviewPanel` over Cmdr; cursor-follow keeps the preview tracking the focused pane's selection (100 ms trailing-edge debounce + generation counter, same pattern as type-to-jump) - Backend `QuickLookController` is a `Mutex<...>` singleton driving the AppKit shared `QLPreviewPanel`. All AppKit calls hop to the main thread via `app.run_on_main_thread()` + `mpsc` channel, same pattern as `clipboard/pasteboard.rs`. First codebase use of `define_class!` (`MainThreadOnly` NSObject) — documented in the new `src-tauri/src/quick_look/CLAUDE.md` - Panel becomes key (no supported `orderFront:` mode). Key forwarding: panel delegate's `previewPanel:handleEvent:` emits a `quick-look-key` Tauri event for arrows / typing / ⇧Space; the frontend listener calls `explorerRef.routePanelKey(payload)`, which fans out to the focused pane's existing nav primitives. Esc returns NO so AppKit's native close path handles it, surfacing via the `NSWindowWillCloseNotification` observer - Volume gate via `Volume::supports_local_fs_access()` — MTP and virtual git portal entries silently no-op (debug log under `target: "quick_look"`); never `Path::exists()`-based, because MTP paths look real - Three IPC commands replace the old fire-and-forget `quick_look`: `quick_look_open`, `quick_look_set_path`, `quick_look_close`. Bindings regenerated - Menu accelerator updated `Some("Space")` → `Some("Shift+Space")`. FilePane Space handler guarded with `!e.shiftKey`. JS shortcut path also has `⇧Space` on `file.quickLook`. Empirically both fire per keystroke (AppKit menu + WKWebView keydown), so the dispatcher arms a 200 ms `quickLookDispatchGuard` on entry and swallows the second fire - Focus-watchdog teaches itself about the panel: while `quickLookState.isOpen`, `shouldSuppress()` returns true (panel-is-key by design — the warning was a known not-a-bug) - Educational toast for Finder converts: pressing plain Space in the file list pops a persistent info toast explaining Space-vs-⇧Space + a deep link to Settings > Keyboard shortcuts. The toast reappears on each Space press as a gentle reminder until the user clicks "Don't show again" (flips `fileExplorer.suppressQuickLookHint` in Settings > Advanced) or while the toast is already on screen. The X button just closes the instance, not the future ones — matching the user's intent - Tests: 9 Rust controller state-machine tests, 20 Vitest tests (`quick-look-state`, `quick-look-hint`, `QuickLookHintToastContent.a11y`), 1 new `focus-watchdog` suppression case. Manual MCP smoke procedure at `apps/desktop/test/manual/quick-look-mcp.md` (native panel is outside Playwright's reach) - Docs: new `apps/desktop/src-tauri/src/quick_look/CLAUDE.md`, new row in `docs/architecture.md`, Selection section in `apps/desktop/src/lib/file-explorer/CLAUDE.md` updated for both the ⇧Space shortcut and the hint behavior. Plan doc at `docs/specs/quick-look-plan.md` retained for reference
1 parent c68630e commit 6778494

34 files changed

Lines changed: 2505 additions & 28 deletions

Cargo.lock

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,11 @@ objc2-app-kit = { version = "0.3", features = [
191191
"NSDragging", "NSDraggingItem", "NSImage", "NSColor", "NSColorSpace",
192192
"NSApplication", "NSMenu", "NSMenuItem", "NSPasteboard", "NSRunningApplication",
193193
"NSWorkspace", "NSPanel", "NSOpenPanel", "NSSavePanel", "NSWindow", "NSResponder",
194+
"NSEvent",
194195
] }
196+
# Native macOS Quick Look panel (QLPreviewPanel + protocols). Published 2025-10-04, well
197+
# outside the 14-day cool-off; tracks the same objc2 0.6 family as the other bindings above.
198+
objc2-quick-look-ui = { version = "0.3.2", features = ["QLPreviewPanel", "QLPreviewItem", "objc2-app-kit"] }
195199
block2 = "0.6"
196200
security-framework = "3.2"
197201
# Drive indexing: macOS FSEvents watcher with event IDs and sinceWhen replay

apps/desktop/src-tauri/src/commands/ui.rs

Lines changed: 142 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -283,26 +283,160 @@ pub fn copy_to_clipboard<R: Runtime>(app: AppHandle<R>, text: String) -> Result<
283283
app.clipboard().write_text(text).map_err(|e| e.to_string())
284284
}
285285

286-
/// Quick Look preview (macOS only)
286+
// ============================================================================
287+
// Quick Look (native QLPreviewPanel on macOS, stubs elsewhere)
288+
// ============================================================================
289+
//
290+
// Three commands rather than one because the panel is a process-wide singleton
291+
// owned by AppKit — we can `open` it, re-target it via `set_path`, or `close`
292+
// it, but we don't get to construct fresh instances. The frontend tracks
293+
// `isOpen` and picks the right call. See `crate::quick_look` for the full
294+
// design (and the why-singleton, why-main-thread, why-events arguments).
295+
//
296+
// All three commands wrap their main-thread hop in `blocking_with_timeout` (2 s)
297+
// so a wedged AppKit pump never freezes the IPC blocking pool.
298+
299+
/// Open (or re-open) Quick Look on the given path.
287300
#[tauri::command]
288301
#[specta::specta]
289302
#[cfg(target_os = "macos")]
290-
pub fn quick_look(path: String) -> Result<(), String> {
291-
Command::new("qlmanage")
292-
.arg("-p")
293-
.arg(&path)
294-
.spawn()
295-
.map_err(|e| e.to_string())?;
303+
pub async fn quick_look_open(app: AppHandle, path: String, volume_id: String) -> Result<(), String> {
304+
use crate::commands::util::blocking_with_timeout;
305+
use std::sync::mpsc::channel;
306+
use tokio::time::Duration;
307+
308+
if !volume_supports_local_fs(&volume_id) {
309+
log::debug!(
310+
target: "quick_look",
311+
"skipping open: volume {volume_id} doesn't support local fs access (path={path})"
312+
);
313+
return Ok(());
314+
}
315+
316+
let app_inner = app.clone();
317+
let path_inner = path;
318+
blocking_with_timeout(Duration::from_secs(2), Err("timed out".to_string()), move || {
319+
let (tx, rx) = channel();
320+
let app_for_closure = app_inner.clone();
321+
let path_main = std::path::PathBuf::from(path_inner);
322+
app_inner
323+
.run_on_main_thread(move || {
324+
let state = app_for_closure.state::<crate::quick_look::QuickLookState>();
325+
if let Ok(mut ctrl) = state.lock() {
326+
ctrl.open_on_main(&app_for_closure, path_main);
327+
}
328+
let _ = tx.send(());
329+
})
330+
.map_err(|e| format!("run_on_main_thread failed: {e}"))?;
331+
rx.recv().map_err(|_| "main-thread reply lost".to_string())?;
332+
Ok::<(), String>(())
333+
})
334+
.await
335+
}
336+
337+
#[tauri::command]
338+
#[specta::specta]
339+
#[cfg(target_os = "macos")]
340+
pub async fn quick_look_set_path(app: AppHandle, path: String, volume_id: String) -> Result<(), String> {
341+
use crate::commands::util::blocking_with_timeout;
342+
use std::sync::mpsc::channel;
343+
use tokio::time::Duration;
344+
345+
if !volume_supports_local_fs(&volume_id) {
346+
log::debug!(
347+
target: "quick_look",
348+
"skipping set_path: volume {volume_id} doesn't support local fs access (path={path})"
349+
);
350+
return Ok(());
351+
}
352+
353+
let app_inner = app.clone();
354+
let path_inner = path;
355+
blocking_with_timeout(Duration::from_secs(2), Err("timed out".to_string()), move || {
356+
let (tx, rx) = channel();
357+
let app_for_closure = app_inner.clone();
358+
let path_main = std::path::PathBuf::from(path_inner);
359+
app_inner
360+
.run_on_main_thread(move || {
361+
let state = app_for_closure.state::<crate::quick_look::QuickLookState>();
362+
if let Ok(mut ctrl) = state.lock() {
363+
ctrl.set_path_on_main(path_main);
364+
}
365+
let _ = tx.send(());
366+
})
367+
.map_err(|e| format!("run_on_main_thread failed: {e}"))?;
368+
rx.recv().map_err(|_| "main-thread reply lost".to_string())?;
369+
Ok::<(), String>(())
370+
})
371+
.await
372+
}
373+
374+
#[tauri::command]
375+
#[specta::specta]
376+
#[cfg(target_os = "macos")]
377+
pub async fn quick_look_close(app: AppHandle) -> Result<(), String> {
378+
use crate::commands::util::blocking_with_timeout;
379+
use std::sync::mpsc::channel;
380+
use tokio::time::Duration;
381+
382+
let app_inner = app.clone();
383+
blocking_with_timeout(Duration::from_secs(2), Err("timed out".to_string()), move || {
384+
let (tx, rx) = channel();
385+
let app_for_closure = app_inner.clone();
386+
app_inner
387+
.run_on_main_thread(move || {
388+
let state = app_for_closure.state::<crate::quick_look::QuickLookState>();
389+
if let Ok(mut ctrl) = state.lock() {
390+
ctrl.close_on_main();
391+
}
392+
let _ = tx.send(());
393+
})
394+
.map_err(|e| format!("run_on_main_thread failed: {e}"))?;
395+
rx.recv().map_err(|_| "main-thread reply lost".to_string())?;
396+
Ok::<(), String>(())
397+
})
398+
.await
399+
}
400+
401+
#[tauri::command]
402+
#[specta::specta]
403+
#[cfg(not(target_os = "macos"))]
404+
pub async fn quick_look_open(_app: AppHandle, _path: String, _volume_id: String) -> Result<(), String> {
296405
Ok(())
297406
}
298407

299408
#[tauri::command]
300409
#[specta::specta]
301410
#[cfg(not(target_os = "macos"))]
302-
pub fn quick_look(_path: String) -> Result<(), String> {
411+
pub async fn quick_look_set_path(_app: AppHandle, _path: String, _volume_id: String) -> Result<(), String> {
303412
Ok(())
304413
}
305414

415+
#[tauri::command]
416+
#[specta::specta]
417+
#[cfg(not(target_os = "macos"))]
418+
pub async fn quick_look_close(_app: AppHandle) -> Result<(), String> {
419+
Ok(())
420+
}
421+
422+
/// Helper: returns true if the named volume supports `std::fs`-style access
423+
/// (local POSIX, OS-mounted SMB). False for MTP and other protocol-only
424+
/// volumes — those have no NSURL the Quick Look panel can preview.
425+
#[cfg(target_os = "macos")]
426+
fn volume_supports_local_fs(volume_id: &str) -> bool {
427+
let manager = crate::file_system::get_volume_manager();
428+
match manager.get(volume_id) {
429+
Some(volume) => volume.supports_local_fs_access(),
430+
None => {
431+
// Unknown volume id — assume yes so we don't accidentally silence
432+
// working previews. The frontend always sends a real id for entries
433+
// it just listed.
434+
log::debug!(target: "quick_look", "volume {volume_id} not found; assuming local fs access");
435+
true
436+
}
437+
}
438+
}
439+
306440
/// Open the Get Info window for a file (macOS only, no-op on other platforms)
307441
#[tauri::command]
308442
#[specta::specta]

apps/desktop/src-tauri/src/ipc.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,9 @@ pub fn builder() -> Builder<tauri::Wry> {
148148
crate::commands::ui::update_view_mode_menu,
149149
crate::commands::ui::show_in_finder,
150150
crate::commands::ui::copy_to_clipboard,
151-
crate::commands::ui::quick_look,
151+
crate::commands::ui::quick_look_open,
152+
crate::commands::ui::quick_look_set_path,
153+
crate::commands::ui::quick_look_close,
152154
crate::commands::ui::get_info,
153155
crate::commands::ui::open_in_editor,
154156
crate::commands::ui::cloud_make_available_offline,

apps/desktop/src-tauri/src/ipc_collectors.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ pub(crate) fn collect_cross_platform_types(types: &mut Types) -> Vec<Function> {
8989
crate::commands::ui::show_tab_context_menu,
9090
crate::commands::ui::show_network_host_context_menu,
9191
crate::commands::ui::show_in_finder,
92-
crate::commands::ui::quick_look,
92+
crate::commands::ui::quick_look_open,
93+
crate::commands::ui::quick_look_set_path,
94+
crate::commands::ui::quick_look_close,
9395
crate::commands::ui::get_info,
9496
crate::commands::ui::open_in_editor,
9597
crate::commands::ui::cloud_make_available_offline,

apps/desktop/src-tauri/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ mod permissions;
115115
#[cfg(target_os = "linux")]
116116
mod permissions_linux;
117117
mod pluralize;
118+
mod quick_look;
118119
mod redact;
119120
mod restricted_paths;
120121
pub mod search;
@@ -571,6 +572,12 @@ pub fn run() {
571572
#[cfg(target_os = "macos")]
572573
app.manage(updater::UpdateState::new());
573574

575+
// Native Quick Look controller. Empty on init; populated lazily
576+
// when the user presses Shift+Space. macOS-only state machine;
577+
// on other platforms the type is `Mutex<()>` so this compiles
578+
// everywhere.
579+
app.manage(quick_look::init_state());
580+
574581
// Initialize pane state store for MCP context tools
575582
app.manage(mcp::PaneStateStore::new());
576583

apps/desktop/src-tauri/src/menu/macos.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ pub(crate) fn build_menu_macos<R: Runtime>(
9797
Some(show_in_file_manager_accelerator()),
9898
)?;
9999
let get_info_item = MenuItem::with_id(app, GET_INFO_ID, "Get info", true, Some("Cmd+I"))?;
100-
let quick_look_item = MenuItem::with_id(app, QUICK_LOOK_ID, "Quick look", true, Some("Space"))?;
100+
// Shift+Space rather than plain Space: AppKit consumes modifier
101+
// accelerators before the webview can capture them, so the menu actually
102+
// fires. Plain Space was dead — the webview's Tier-2 selection-toggle
103+
// handler ate the keydown before AppKit's menu dispatcher saw it.
104+
let quick_look_item = MenuItem::with_id(app, QUICK_LOOK_ID, "Quick look", true, Some("Shift+Space"))?;
101105

102106
let file_menu = Submenu::with_items(
103107
app,

0 commit comments

Comments
 (0)