From 760f180303bb65171982c37645f36a01c4640d43 Mon Sep 17 00:00:00 2001 From: Diego Palacios <98249751+CodingDiego@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:03:18 -0300 Subject: [PATCH] app: box selection, delete guard, new tabs and launches at the start place - Details view: a drag that starts on a row's other columns draws a selection rectangle, as in Explorer; a drag from a name still drags the item and a click still selects the row (#5). - Delete does nothing (with a beep) and the command bar's Delete is disabled when a selected item is neither deletable nor a file system item, such as Home's OneDrive cloud recent files, on which the shell failed with 0x80004002 (#3). - Ctrl+T and the tab strip's + open the start place instead of the current folder (#6). - A plain launch opens the start place; only --recover restores the previous session's tabs (#4). Fixes #3, fixes #4, fixes #5, fixes #6. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++--- crates/app/src/commands.rs | 2 +- crates/app/src/list.rs | 63 +++++++++++++++++++++++++++++++++++++- crates/app/src/main.rs | 10 +++--- crates/app/src/nav.rs | 10 ++---- 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index aa86615..53c4d81 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Every change is written to the active profile in settings.ini and applies at onc | F2, Delete, Shift+Delete, Ctrl+C/X/V, Ctrl+A, Alt+Enter | Shell verbs on the selection (F2 on shell items only) | | Right-click | Full shell context menu (Shift adds the extended verbs); the background adds View, Sort by, Refresh, Paste, Pin on top; archives get the extract menu | | Ctrl+Shift+E | Open the current folder in Windows Explorer (an archive: its folder) | -| Ctrl+T, Ctrl+W | New tab at the same place, close tab (closing the last tab closes the window) | +| Ctrl+T, Ctrl+W | New tab at the start place, close tab (closing the last tab closes the window) | | Ctrl+Tab, Ctrl+Shift+Tab, Ctrl+1..8, Ctrl+9 | Next, previous, nth, last tab | | Middle-click a folder | Open it in a background tab (middle-click a tab closes it) | | Ctrl+P | Palette: recent places, bookmarks, cloud roots and the current subfolders, fuzzy matched | @@ -243,8 +243,9 @@ else Explorer-style (`/idlist,...`, an unknown `/` switch, URLs such as `ms-sett Paths) and the process exits. A second launch hands its folders (and `--search`) to the running window and exits (36-78 ms measured from process creation to the new tab's title); a second launch without folders (Win+E once installed) opens a tab at the profile's start place. One instance runs per settings folder, so a launch with -another `%APPDATA%` (the smoke test) runs on its own. A plain first launch restores the session; a launch with -folders opens just those. After a crash the process relaunches itself with `--recover`; each `--recover` start +another `%APPDATA%` (the smoke test) runs on its own. A plain first launch opens a tab at the start place, as +Explorer does (view and panes still come from the session); a launch with folders opens just those. After a crash +the process relaunches itself with `--recover`; each `--recover` start counts itself in `%APPDATA%\rexplorer\crash-count.txt`, and the second within 60 s opens the folder in Windows Explorer instead of running (so a crash loop stops at Explorer). A start without `--recover` resets the count. Ctrl+Shift+E and the fallbacks start `%SystemRoot%\explorer.exe` by full path, so they reach Explorer even when @@ -343,7 +344,7 @@ Home does not exist (older Windows), the default start is This PC. Lines startin profile=default ; the active profile [profile default] -start=shell:::{F874310E-B6B7-47DC-BC84-B9E6B38F5903} ; where a first run (or a launch without a session) starts: Home +start=shell:::{F874310E-B6B7-47DC-BC84-B9E6B38F5903} ; where a launch without folders, and a new tab, start: Home theme=dark ; a file name in themes\ without .ini view=details ; details | icons | thumbnails (the view segment writes it back here) show_tree=false ; the folder tree instead of the sidebar diff --git a/crates/app/src/commands.rs b/crates/app/src/commands.rs index d838769..a421dc4 100644 --- a/crates/app/src/commands.rs +++ b/crates/app/src/commands.rs @@ -67,7 +67,7 @@ pub(crate) unsafe fn update(app: *mut App) { (CMD_PASTE, shell && clipboard_has_files()), (CMD_RENAME, shell && sel == 1), (CMD_SHARE, shell && sel > 0), - (CMD_DELETE, (shell || search) && sel > 0), + (CMD_DELETE, (shell || search) && sel > 0 && list::can_delete(app, &list::selected_indices(a.list))), (CMD_SORT, shell && !home), ] { toolbar::set_enabled(bar, cmd, on); diff --git a/crates/app/src/list.rs b/crates/app/src/list.rs index f0b256c..644f685 100644 --- a/crates/app/src/list.rs +++ b/crates/app/src/list.rs @@ -424,9 +424,28 @@ pub(crate) unsafe fn selection_menu(app: *mut App, rows: &[usize]) -> Option bool { + use windows::Win32::System::SystemServices::{SFGAO_CANDELETE, SFGAO_FILESYSTEM}; + let Some(Source::Shell(l)) = source(app) else { return true }; + if std::path::Path::new(&l.folder.parsing_name).is_absolute() { + return true; + } + let mask = SFGAO_CANDELETE.0 | SFGAO_FILESYSTEM.0; + rows.iter().filter_map(|&i| l.items.get(i)).all(|it| it.attributes(mask) & mask != 0) +} + pub(crate) unsafe fn verb_on_selection(app: *mut App, verb: &str, shift: bool) { let hwnd = (*app).hwnd; - if let Some(menu) = selection_menu(app, &selected_indices((*app).list)) { + let rows = selected_indices((*app).list); + if verb == "delete" && !can_delete(app, &rows) { + let _ = MessageBeep(MB_OK); + return; + } + if let Some(menu) = selection_menu(app, &rows) { let _ = menu.invoke_verb(hwnd, verb, shift); } } @@ -538,6 +557,9 @@ unsafe fn draw_item(app: *mut App, cd: &mut NMLVCUSTOMDRAW) -> u32 { } } +/// The list's own timer that restores full-row select during a marquee (an id the ListView's own timers do not use). +const MARQUEE_TIMER: usize = 0x5258; + /// The header is 32 px high (96 DPI) whatever the font asks for. pub(crate) unsafe extern "system" fn header_subclass(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM, _id: usize, _data: usize) -> LRESULT { let r = DefSubclassProc(hwnd, msg, wparam, lparam); @@ -574,6 +596,45 @@ pub(crate) unsafe extern "system" fn subclass(hwnd: HWND, msg: u32, wparam: WPAR show_menu(app, on_item, Some(menu_point(hwnd, on_item.then_some(focus as usize)))); return LRESULT(0); } + // Explorer box-selects when a drag starts on a row's other columns; a full-row ListView would drag the item. So + // such a press reaches the ListView with full-row select off: to it the press is on blank space and a drag draws + // a marquee. A timer that fires inside the marquee's loop turns full-row back on, else the marquee would only + // take rows whose name it touches. A press released without a drag runs again with full-row on, as a click on + // the row. shortcut: a drag from blank space right of a name drags the item, unlike Explorer; measure the name's + // text width if that matters. + let full = LVS_EX_FULLROWSELECT as usize; + if msg == WM_TIMER && wparam.0 == MARQUEE_TIMER { + let _ = KillTimer(Some(hwnd), MARQUEE_TIMER); + SendMessageW(hwnd, LVM_SETEXTENDEDLISTVIEWSTYLE, Some(WPARAM(full)), Some(LPARAM(full as isize))); + return LRESULT(0); + } + if msg == WM_LBUTTONDOWN && (*app).views.view == views::View::Details { + let pt = POINT { x: lparam.0 as i16 as i32, y: (lparam.0 >> 16) as i16 as i32 }; + let mut ht = LVHITTESTINFO { pt, ..Default::default() }; + SendMessageW(hwnd, LVM_SUBITEMHITTEST, None, Some(LPARAM(&mut ht as *mut _ as isize))); + if ht.iItem >= 0 && ht.iSubItem > 0 { + SendMessageW(hwnd, LVM_SETEXTENDEDLISTVIEWSTYLE, Some(WPARAM(full)), Some(LPARAM(0))); + SetTimer(Some(hwnd), MARQUEE_TIMER, 1, None); + let r = DefSubclassProc(hwnd, msg, wparam, lparam); + let _ = KillTimer(Some(hwnd), MARQUEE_TIMER); + SendMessageW(hwnd, LVM_SETEXTENDEDLISTVIEWSTYLE, Some(WPARAM(full)), Some(LPARAM(full as isize))); + // Where the button came up: the last message the ListView's mouse loop took. + let up = GetMessagePos(); + let mut down = pt; + let _ = ClientToScreen(hwnd, &mut down); + let dragged = (up as i16 as i32 - down.x).abs() > GetSystemMetrics(SM_CXDRAG) + || ((up >> 16) as i16 as i32 - down.y).abs() > GetSystemMetrics(SM_CYDRAG); + if dragged { + return r; + } + // The ListView's click handling waits for a button-up; the first pass took the real one. + let mut queued = MSG::default(); + if !PeekMessageW(&mut queued, Some(hwnd), WM_LBUTTONUP, WM_LBUTTONUP, PM_NOREMOVE).as_bool() { + let _ = PostMessageW(Some(hwnd), WM_LBUTTONUP, WPARAM(wparam.0 & !(windows::Win32::System::SystemServices::MK_LBUTTON.0 as usize)), lparam); + } + return DefSubclassProc(hwnd, msg, wparam, lparam); + } + } if msg == WM_MOUSEMOVE { if !(*app).panes.hover_tracking { let mut tme = TRACKMOUSEEVENT { cbSize: size_of::() as u32, dwFlags: TME_LEAVE, hwndTrack: hwnd, dwHoverTime: 0 }; diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 8ba8171..6ecfe1b 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -436,11 +436,11 @@ fn accels(overrides: &[(String, String)]) -> Vec { keys::apply(&ACCELS, overrides).into_iter().chain(digits.iter().enumerate().map(|(i, &k)| accel(FCONTROL.0, k, CMD_TAB1 + i as u16))).collect() } -/// The session's tabs on a plain launch or --recover; otherwise the command-line folders; else the profile's start. +/// The session's tabs on --recover (as Explorer, a plain launch does not restore them); otherwise the command-line +/// folders; else the profile's start. unsafe fn open_startup_tabs(app: *mut App, args: &cli::Args) { - let plain = args.paths.is_empty() && args.select.is_none(); let saved = std::mem::take(&mut (*app).session.tabs); - if (args.recover || plain) && !saved.is_empty() { + if args.recover && !saved.is_empty() { let active = (*app).session.active; for name in saved { nav::new_tab(app, nav::Target::Name(name), false, None); @@ -629,7 +629,7 @@ unsafe fn on_notify(app: *mut App, lparam: LPARAM) -> LRESULT { match hdr.code { tabs::TN_SELECT => nav::switch_tab(app, nm.index), tabs::TN_CLOSE => nav::close_tab(app, nm.index), - tabs::TN_NEW => nav::duplicate_tab(app), + tabs::TN_NEW => nav::new_start_tab(app), _ => {} } } else if from == (*app).crumbs { @@ -717,7 +717,7 @@ unsafe fn on_command(app: *mut App, cmd: u16) { list::verb_on_selection(app, "properties", false); } } - CMD_NEW_TAB => nav::duplicate_tab(app), + CMD_NEW_TAB => nav::new_start_tab(app), CMD_CLOSE_TAB => nav::close_tab(app, (*app).active), CMD_NEXT_TAB => nav::cycle_tab(app, 1), CMD_PREV_TAB => nav::cycle_tab(app, -1), diff --git a/crates/app/src/nav.rs b/crates/app/src/nav.rs index 3dfc363..2d66b04 100644 --- a/crates/app/src/nav.rs +++ b/crates/app/src/nav.rs @@ -624,13 +624,9 @@ pub(crate) unsafe fn new_tab(app: *mut App, target: Target, activate: bool, sele session_changed(app); } -/// A new tab at the active tab's place (Ctrl+T, the strip's + button); a search tab duplicates its folder. -pub(crate) unsafe fn duplicate_tab(app: *mut App) { - let target = match source(app) { - Some(s) => s.location(), - None => Target::Name((*app).profile.start.clone()), - }; - new_tab(app, target, true, None); +/// Ctrl+T and the tab strip's +: a tab at the profile's start place, as in Explorer. +pub(crate) unsafe fn new_start_tab(app: *mut App) { + new_tab(app, Target::Name((*app).profile.start.clone()), true, None); } pub(crate) unsafe fn switch_tab(app: *mut App, t: usize) {