Skip to content
Merged
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
22 changes: 16 additions & 6 deletions crates/app/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ pub(crate) unsafe fn clipboard_has_files() -> bool {
IsClipboardFormatAvailable(15).is_ok() || (idlist != 0 && IsClipboardFormatAvailable(idlist).is_ok())
}

/// Whether the active tab is a shell listing that has finished loading (the only place Paste can act).
unsafe fn shell_ready(app: *mut App) -> bool {
let tab = cur(app);
tab.loading.is_none() && matches!(tab.source, Some(Source::Shell(_)))
}

/// Paste's enabled state alone, for a clipboard change: skips the selection and attribute reads of `update`.
pub(crate) unsafe fn update_paste(app: *mut App) {
if (*app).tabs.is_empty() {
return;
}
toolbar::set_enabled((*app).cmdbar, CMD_PASTE, shell_ready(app) && clipboard_has_files());
}

/// Enabled states for the active tab: nothing while it loads; selection verbs in a shell listing (Copy and Delete
/// in search results too, which act on each file's own folder); Paste with files on the clipboard; New and Sort
/// not on the Home page.
Expand All @@ -51,12 +65,8 @@ pub(crate) unsafe fn update(app: *mut App) {
return;
}
let tab = cur(app);
let ready = tab.loading.is_none();
let (shell, search) = match &tab.source {
Some(Source::Shell(_)) => (ready, false),
Some(Source::Search(_)) => (false, ready),
_ => (false, false),
};
let search = tab.loading.is_none() && matches!(tab.source, Some(Source::Search(_)));
let shell = shell_ready(app);
let home = IsWindowVisible(a.home).as_bool();
let sel = SendMessageW(a.list, LVM_GETSELECTEDCOUNT, None, None).0 as usize;
let bar = a.cmdbar;
Expand Down
17 changes: 14 additions & 3 deletions crates/app/src/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,23 +135,34 @@ pub(crate) unsafe fn sort_by(app: *mut App, col: u32, desc: bool) {
// items but not their PIDLs, so a PIDL's address is the item's identity (exact, unlike a case-folded name).
let focus = SendMessageW(list, LVM_GETNEXTITEM, Some(WPARAM(usize::MAX)), Some(LPARAM(LVNI_FOCUSED as isize))).0;
let key = |i: usize| l.items.get(i).map(|it| it.pidl.as_ptr() as usize);
let selected: std::collections::HashSet<usize> = selected_indices(list).into_iter().filter_map(key).collect();
// Ctrl+A then sort: every row stays selected whatever the order, so only the focus moves (collecting and
// re-setting each row costs 0.15 s per 20k rows).
let all = SendMessageW(list, LVM_GETSELECTEDCOUNT, None, None).0 as usize == l.items.len();
let selected: std::collections::HashSet<usize> = if all { Default::default() } else { selected_indices(list).into_iter().filter_map(key).collect() };
let focused = usize::try_from(focus).ok().and_then(key);
l.sort_col = col;
l.sort_desc = desc;
sort_listing(l);
// Icon views keep images per row position: drop them, or thumbnails stay where their files were.
views::reset(app, l.items.len());
set_item_state(list, usize::MAX, LIST_VIEW_ITEM_STATE_FLAGS(0), LIST_VIEW_ITEM_STATE_FLAGS(LVIS_SELECTED.0 | LVIS_FOCUSED.0));
let mask = if all { LVIS_FOCUSED.0 } else { LVIS_SELECTED.0 | LVIS_FOCUSED.0 };
set_item_state(list, usize::MAX, LIST_VIEW_ITEM_STATE_FLAGS(0), LIST_VIEW_ITEM_STATE_FLAGS(mask));
let mut row = None;
for (i, it) in l.items.iter().enumerate() {
let p = it.pidl.as_ptr() as usize;
if selected.contains(&p) {
if !all && selected.contains(&p) {
set_item_state(list, i, LVIS_SELECTED, LVIS_SELECTED);
}
if focused == Some(p) {
set_item_state(list, i, LVIS_FOCUSED, LVIS_FOCUSED);
row = Some(i);
}
}
// The Shift+click anchor follows the focused item to its new row, and that row stays in view.
if let Some(i) = row {
SendMessageW(list, LVM_SETSELECTIONMARK, None, Some(LPARAM(i as isize)));
SendMessageW(list, LVM_ENSUREVISIBLE, Some(WPARAM(i)), Some(LPARAM(0)));
}
let _ = InvalidateRect(Some(list), None, true);
}

Expand Down
33 changes: 27 additions & 6 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ pub(crate) struct App {
change_gate: session::Debounce,
/// A change arrived while minimized: reload once when the window is restored.
stale: bool,
/// TIMER_CHANGE is armed: a notification does not restart it (SetTimer would), so a folder written every few
/// milliseconds still reloads.
change_armed: bool,
recent: Recent,
panes: panes::Panes,
views: views::Views,
Expand Down Expand Up @@ -375,6 +378,7 @@ fn main() -> Result<()> {
debounce: session::Debounce::new(Duration::from_secs(2)),
change_gate: session::Debounce::new(Duration::from_secs(1)),
stale: false,
change_armed: false,
recent: Recent::load(dir.join("recent.txt")),
panes: panes::Panes::default(),
browser: None,
Expand Down Expand Up @@ -494,7 +498,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam:
WM_SIZE => {
ui::layout(app);
if wparam.0 as u32 != SIZE_MINIMIZED && std::mem::take(&mut (*app).stale) {
SetTimer(Some(hwnd), TIMER_CHANGE, 1, None);
arm_change(app, 1);
}
}
WM_PAINT => ui::paint(app),
Expand All @@ -516,7 +520,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam:
WM_SETFOCUS => {
let _ = SetFocus(Some(source::browser_hwnd(app).unwrap_or_else(|| ui::view_hwnd(app))));
}
WM_CLIPBOARDUPDATE => commands::update(app),
WM_CLIPBOARDUPDATE => commands::update_paste(app),
WM_NOTIFY => return on_notify(app, lparam),
WM_COMMAND => {
let (id, code) = (wparam.0 & 0xFFFF, (wparam.0 >> 16) & 0xFFFF);
Expand Down Expand Up @@ -566,7 +570,15 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam:
source::WM_WORKER_ERROR => message(hwnd, &Box::from_raw(lparam.0 as *mut String)),
panes::WM_SIDEBAR_ROWS => panes::on_sidebar_rows(app, Box::from_raw(lparam.0 as *mut panes::SidebarRows)),
panes::WM_TREE_SELECT => panes::on_tree_select(app, Box::from_raw(lparam.0 as *mut Pidl)),
panes::WM_QUICK_ACCESS_DONE => {
if wparam.0 == 1 {
panes::sidebar_changed(app);
} else {
let _ = MessageBeep(MB_OK);
}
}
views::WM_THUMB_DONE => views::on_thumb_done(app, wparam, lparam),
views::WM_ICONS_EXPORTED => views::on_icons_exported(app),
WM_SETCURSOR => {
if HWND(wparam.0 as *mut c_void) == hwnd && (lparam.0 & 0xFFFF) as u32 == HTCLIENT && panes::on_set_cursor(app) {
return LRESULT(1);
Expand All @@ -581,13 +593,16 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam:
if nav::mark_changed(app, &ChangeWatch::acknowledge(wparam, lparam)) {
if IsIconic(hwnd).as_bool() {
(*app).stale = true;
} else {
SetTimer(Some(hwnd), TIMER_CHANGE, 150, None);
} else if !(*app).change_armed {
arm_change(app, 150);
}
}
}
WM_TIMER => {
let _ = KillTimer(Some(hwnd), wparam.0);
if wparam.0 == TIMER_CHANGE {
(*app).change_armed = false;
}
match wparam.0 {
TIMER_REFRESH => nav::refresh(app),
// Armed for the tab and folder active then: a clean one shown since has nothing to catch up on.
Expand All @@ -596,13 +611,13 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam:
let (now, tab) = (Instant::now(), nav::cur(app));
// One change reload at a time (F5 and the rest still restart one): a change during it reloads once more after.
if tab.pending != 0 && tab.loading.is_none() {
SetTimer(Some(hwnd), TIMER_CHANGE, 500, None);
arm_change(app, 500);
} else if (*app).change_gate.should_save(now) {
// Covers an in-app rename or new item whose own follow-up reload is still pending.
let _ = KillTimer(Some(hwnd), TIMER_REFRESH);
nav::refresh(app);
} else {
SetTimer(Some(hwnd), TIMER_CHANGE, (*app).change_gate.remaining(now).as_millis() as u32, None);
arm_change(app, (*app).change_gate.remaining(now).as_millis() as u32);
}
}
TIMER_STATUS => ui::update_status(app),
Expand Down Expand Up @@ -666,6 +681,12 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam:
LRESULT(0)
}

/// Arms TIMER_CHANGE for `ms` and records it, so a notification can tell it is already pending.
unsafe fn arm_change(app: *mut App, ms: u32) {
(*app).change_armed = true;
SetTimer(Some((*app).hwnd), TIMER_CHANGE, ms, None);
}

unsafe fn on_notify(app: *mut App, lparam: LPARAM) -> LRESULT {
let hdr = &*(lparam.0 as *const NMHDR);
let from = hdr.hwndFrom;
Expand Down
45 changes: 34 additions & 11 deletions crates/app/src/nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,16 +490,33 @@ unsafe fn show_source(app: *mut App, mode: Show) {
update_chrome(app);
}

/// Indices of the items whose parsing name is one of `names` (ASCII case-insensitive), stopping at the last match.
fn selection_matches(names: &[String], parsing: impl Iterator<Item = String>) -> Vec<usize> {
let mut wanted: std::collections::HashSet<String> = names.iter().map(|n| n.to_ascii_lowercase()).collect();
let mut parsing = parsing.enumerate();
/// Indices of the items whose parsing name is one of `names`, stopping at the last match. Exact matches come
/// first (a case-sensitive folder can hold `a.txt` and `A.txt`); a name with none falls back to an ASCII
/// case-insensitive match on an item not already hit, so a rename that only changed case still reselects.
fn selection_matches(names: &[String], mut parsing: impl Iterator<Item = String>) -> Vec<usize> {
let mut wanted: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
let mut seen = Vec::new(); // kept for the fallback pass: parsing a name is a shell call
let mut hits = Vec::new();
while !wanted.is_empty() {
let Some((i, p)) = parsing.next() else { break };
if wanted.remove(&p.to_ascii_lowercase()) {
hits.push(i);
let Some(p) = parsing.next() else { break };
if wanted.remove(p.as_str()) {
hits.push(seen.len());
}
seen.push(p);
}
if !wanted.is_empty() {
// The walk ran out of items, so `seen` is all of them.
let mut loose: std::collections::HashSet<String> = wanted.iter().map(|n| n.to_ascii_lowercase()).collect();
let exact: std::collections::HashSet<usize> = hits.iter().copied().collect();
for (i, p) in seen.iter().enumerate() {
if loose.is_empty() {
break;
}
if !exact.contains(&i) && loose.remove(&p.to_ascii_lowercase()) {
hits.push(i);
}
}
hits.sort_unstable();
}
hits
}
Expand Down Expand Up @@ -636,6 +653,7 @@ pub(crate) unsafe fn refresh(app: *mut App) {
};
// This reload covers the changes notified so far.
let _ = KillTimer(Some((*app).hwnd), crate::TIMER_CHANGE);
(*app).change_armed = false;
go(app, target, History::Refresh);
}

Expand Down Expand Up @@ -988,13 +1006,18 @@ mod tests {
}

#[test]
fn selection_matches_ascii_case_insensitively_and_stops_at_the_last() {
let names = ["C:\\A.txt".to_string(), "c:\\B.TXT".to_string()];
let parsing = ["c:\\a.TXT", "c:\\x", "C:\\b.txt", "never"].into_iter().enumerate().map(|(i, p)| {
assert!(i < 3, "read past the last match");
fn selection_matches_exact_first_then_ascii_case_insensitively() {
let s = |v: &[&str]| v.iter().map(|p| p.to_string()).collect::<Vec<_>>();
let names = s(&["C:\\a.txt", "c:\\b.txt"]);
let parsing = ["C:\\a.txt", "c:\\x", "c:\\b.txt", "never"].into_iter().enumerate().map(|(i, p)| {
assert!(i < 3, "read past the last exact match");
p.to_string()
});
assert_eq!(selection_matches(&names, parsing), [0, 2]);
assert_eq!(selection_matches(&s(&["C:\\a.txt"]), s(&["C:\\A.txt", "C:\\a.txt"]).into_iter()), [1], "the exact one wins in a case-sensitive folder");
assert_eq!(selection_matches(&s(&["C:\\a.txt"]), s(&["C:\\A.txt"]).into_iter()), [0], "a rename that only changed case still reselects");
assert_eq!(selection_matches(&s(&["C:\\a.txt", "C:\\A.txt"]), s(&["C:\\a.txt"]).into_iter()), [0], "an item already hit is not taken twice");
assert_eq!(selection_matches(&s(&["C:\\A.txt", "c:\\B.TXT"]), s(&["c:\\a.TXT", "c:\\x", "C:\\b.txt"]).into_iter()), [0, 2]);
assert!(selection_matches(&["É".to_string()], ["é".to_string()].into_iter()).is_empty(), "ASCII only, as eq_ignore_ascii_case");
assert!(selection_matches(&[], std::iter::once_with(|| -> String { panic!("no names, nothing to parse") })).is_empty());
}
Expand Down
32 changes: 19 additions & 13 deletions crates/app/src/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ use windows::core::{GUID, Interface};
pub(crate) const WM_TREE_SELECT: u32 = WM_APP + 3;
/// lparam = Box<SidebarRows> built by the sidebar worker.
pub(crate) const WM_SIDEBAR_ROWS: u32 = WM_APP + 8;
/// wparam = 1 when the shell ran the pin or unpin verb, 0 when it refused it.
pub(crate) const WM_QUICK_ACCESS_DONE: u32 = WM_APP + 9;
/// Gap between the side panes and the cards, and the cards' margin from the window edges; it is the splitter.
const SPLIT: i32 = 8;
const MIN_WIDTH: i32 = 120;
Expand Down Expand Up @@ -347,20 +349,24 @@ pub(crate) unsafe fn sidebar_menu(app: *mut App, index: usize) {
}

/// A canonical verb ("unpinfromhome", "pintohome") on the Quick Access item for `target`; beeps when the shell
/// refuses it (the item left Quick Access meanwhile).
/// refuses it (the item left Quick Access meanwhile). On a worker: Quick Access can hold network folders, and a
/// shell context menu needs an STA. One at a time: two quick unpins racing on the shell's Quick Access list lose one.
unsafe fn quick_access_verb(app: *mut App, target: &str, verb: &str) {
let hwnd = (*app).hwnd;
let done = (|| {
let qa = ShellFolder::parse(&format!("shell:{}", nav::QUICK_ACCESS)).ok()?;
let items = qa.enumerate().ok()?;
let item = items.iter().find(|i| i.parsing_name().eq_ignore_ascii_case(target))?;
ContextMenu::for_items(&qa, &[item], hwnd).ok()?.invoke_verb(hwnd, verb, false).ok()
})();
if done.is_some() {
sidebar_changed(app);
} else {
let _ = MessageBeep(MB_OK);
}
static QUICK_ACCESS: std::sync::Mutex<()> = std::sync::Mutex::new(());
let hwnd = (*app).hwnd.0 as isize;
let (target, verb) = (target.to_string(), verb.to_string());
std::thread::spawn(move || {
let _one = QUICK_ACCESS.lock().unwrap_or_else(|e| e.into_inner());
let _com = ComInit::sta();
let hwnd = HWND(hwnd as *mut c_void);
let done = (|| {
let qa = ShellFolder::parse(&format!("shell:{}", nav::QUICK_ACCESS)).ok()?;
let items = qa.enumerate().ok()?;
let item = items.iter().find(|i| i.parsing_name().eq_ignore_ascii_case(&target))?;
ContextMenu::for_items(&qa, &[item], hwnd).ok()?.invoke_verb(hwnd, &verb, false).ok()
})();
unsafe { PostMessageW(Some(hwnd), WM_QUICK_ACCESS_DONE, WPARAM(done.is_some() as usize), LPARAM(0)) }.ok();
});
}

fn small_icon(pidl: &Pidl) -> Option<HICON> {
Expand Down
22 changes: 13 additions & 9 deletions crates/app/src/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ pub(crate) const WM_WORKER_ERROR: u32 = WM_APP + 6;
/// Most results the volume index returns per search.
const USN_LIMIT: usize = 5000;

pub(crate) type UsnCache = Arc<Mutex<HashMap<char, Arc<VolumeIndex>>>>;
/// Volume indexes per drive letter. Each drive has its own slot, so a build (seconds) stalls only searches of that
/// drive, which then share its index instead of building their own.
pub(crate) type UsnCache = Arc<Mutex<HashMap<char, Arc<Mutex<Option<Arc<VolumeIndex>>>>>>>;

pub(crate) enum Source {
Shell(Listing),
Expand Down Expand Up @@ -537,15 +539,13 @@ pub(crate) unsafe fn start_search(app: *mut App, t: usize, needle: &str, push: b

/// Elevated search of a whole NTFS volume through its MFT index (built once per drive, kept for the session).
fn usn_search(cache: &UsnCache, drive: char, needle: &str, cancel: &AtomicBool, sink: &mut dyn FnMut(Vec<Hit>)) -> Result<()> {
let slot = cache.lock().unwrap_or_else(|e| e.into_inner()).entry(drive).or_default().clone();
let index = {
let mut m = cache.lock().unwrap_or_else(|e| e.into_inner());
match m.get(&drive) {
let mut slot = slot.lock().unwrap_or_else(|e| e.into_inner());
match &*slot {
Some(i) => i.clone(),
None => {
let i = Arc::new(usn::build(drive)?);
m.insert(drive, i.clone());
i
}
None if cancel.load(Ordering::Relaxed) => return Ok(()),
None => slot.insert(Arc::new(usn::build(drive)?)).clone(),
}
};
// A superseded search must not scan a whole index it no longer needs.
Expand Down Expand Up @@ -656,6 +656,10 @@ pub(crate) unsafe fn cancel_search(app: *mut App) -> bool {
}
}

/// As-you-type searches start at this many characters; Enter searches any non-empty text. Below it a showing search
/// stays: going back to the folder would rewrite the box under the user's typing.
const MIN_TYPED: usize = 2;

/// TIMER_SEARCH: the box text settled. The box also changes when a tab shows its own state; that matches the
/// active search (or is empty with none), so only a real edit gets through.
pub(crate) unsafe fn on_search_timer(app: *mut App) {
Expand All @@ -671,7 +675,7 @@ pub(crate) unsafe fn on_search_timer(app: *mut App) {
true => nav::go_up(app),
}
}
} else if !same && matches!(cur(app).source, Some(Source::Shell(_) | Source::Search(_))) {
} else if !same && text.chars().count() >= MIN_TYPED && matches!(cur(app).source, Some(Source::Shell(_) | Source::Search(_))) {
start_search(app, active, text, !searching);
}
}
Expand Down
Loading
Loading