From 3910c1c2e831dbc5d6c47cb21d74345c7b24d3a8 Mon Sep 17 00:00:00 2001 From: JetDeng Date: Thu, 1 Oct 2026 12:47:57 +0800 Subject: [PATCH 1/2] Fix title bar buttons unclickable on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpui-kit's TitleBar marks its whole content strip as a native WindowControlArea::Drag region. On Windows, WM_NCHITTEST then answers HTCAPTION for every child of the bar: a mouse press is treated as a window move, the click never dispatches, and the press falls into the OS move loop — so the expand-sidebar button did nothing and the language toggle felt stuck. macOS never takes this hit-test path. - Occlude the expand-sidebar, open-data, setup, ui-size and toggle-theme buttons so their hitboxes stop the upward collection and Windows returns HTCLIENT for them; empty bar space still drags. - Consume the left press on the toggle-language button, the same defence gpui-kit's own AppMenuBar uses. - Add headless UI regression tests for the language toggle (switch takes effect, the press does not bubble to ancestors), enabling gpui-kit's test-support feature for the test build. --- Cargo.lock | 109 +++++++++++++++++++++-- Cargo.toml | 3 + src/ui/language_toggle_tests.rs | 152 ++++++++++++++++++++++++++++++++ src/ui/mod.rs | 2 + src/ui/title_bar.rs | 29 ++++++ 5 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 src/ui/language_toggle_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 051ab3a..7fee2e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -862,15 +862,30 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -1469,6 +1484,15 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -3331,6 +3355,7 @@ dependencies = [ "anyhow", "async-channel", "async-task", + "backtrace", "bindgen", "bitflags 2.13.2", "chrono", @@ -3367,6 +3392,7 @@ dependencies = [ "pollster 0.4.0", "postage", "profiling", + "proptest", "rand 0.9.5", "raw-window-handle", "regex", @@ -3589,6 +3615,7 @@ dependencies = [ "gpui-pre-linux", "gpui-pre-macos", "gpui-pre-web", + "gpui-pre-wgpu", "gpui-pre-windows", ] @@ -3757,6 +3784,7 @@ dependencies = [ "gpui-pre", "gpui-pre-collections", "gpui-pre-util", + "image", "itertools 0.14.0", "log", "parking_lot", @@ -4391,7 +4419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -5487,7 +5515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.2", "cfg-if", "cfg_aliases", @@ -6790,6 +6818,38 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.2", + "num-traits", + "proptest-macro", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-macro" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110442e79d698ac2d5913a6b14e99474650e1a77f4af30da67aa77d1c36c2a90" +dependencies = [ + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pulp" version = "0.22.3" @@ -6828,6 +6888,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -7097,6 +7163,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -7153,7 +7228,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -7681,6 +7756,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.20.1" @@ -8739,7 +8826,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg 0.5.15", ] @@ -9191,6 +9278,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -9834,8 +9927,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.9.1", + "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", @@ -9906,7 +9999,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index b5ac9f9..329c51d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,9 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] [dev-dependencies] rust_xlsxwriter = "0.99" +# Same release tag as the build dependency (see above); the extra feature only +# joins the test build, where `gpui_kit::test` and `#[gpui_kit::test]` live. +gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "0c830f4d257e69fdd17200650533ab4ca9a40cc0", features = ["test-support"] } # `gpui-shell` scripts run on the QuickJS fork the toolkit pins; the crates.io # `rquickjs` would be a second, incompatible copy of the same crate. diff --git a/src/ui/language_toggle_tests.rs b/src/ui/language_toggle_tests.rs new file mode 100644 index 0000000..8701901 --- /dev/null +++ b/src/ui/language_toggle_tests.rs @@ -0,0 +1,152 @@ +//! Regression test for the Windows language-toggle report. Two things were +//! wrong there: the press itself fell through to the OS (the title bar is an +//! HTCAPTION region on Windows, so an unconsumed mouse-down starts a window +//! move that eats the mouse-up — the click never fires), which these tests +//! pin down as "a mouse-down on the button must not bubble to an ancestor"; +//! and the switch must re-render the button's label, which rides entirely on +//! `cx.refresh_windows()` since no entity notifies on a language change. + +use std::cell::Cell; +use std::rc::Rc; +use std::sync::Mutex; + +use gpui_kit::component::{Theme, ThemeMode}; +use gpui_kit::test::TestWindowExt; +// No `gpui_kit::*` glob here: it imports gpui's `test` attribute macro, which +// shadows the built-in `#[test]` in the macro-generated code and recurses. +use gpui_kit::{ + AppContext, Bounds, Context, Entity, InteractiveElement, IntoElement, MouseButton, + ParentElement, Point, Render, Styled, TestAppContext, Window, WindowBounds, WindowOptions, + div, px, size, +}; + +use crate::i18n::Language; +use crate::state::AppState; +use crate::ui::title_bar::TitleBarView; + +/// Both tests drive the process-wide language setting, so they must not run +/// at the same time. +static LANGUAGE_LOCK: Mutex<()> = Mutex::new(()); + +fn language_guard() -> std::sync::MutexGuard<'static, ()> { + LANGUAGE_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Records whether a left mouse-down bubbled up past the title bar. +struct Harness { + title_bar: Entity, + bubbled_press: Rc>, +} + +impl Render for Harness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let bubbled = self.bubbled_press.clone(); + div() + .size_full() + .on_mouse_down(MouseButton::Left, move |_, _, _| bubbled.set(true)) + .child(self.title_bar.clone()) + } +} + +#[gpui_kit::test] +fn clicking_toggle_language_switches_language_and_relabels_the_button(cx: &mut TestAppContext) { + let _guard = language_guard(); + crate::i18n::set_current(Language::Zh); + cx.update(|cx| { + gpui_kit::init(cx); + crate::ui::init(cx); + Theme::change(ThemeMode::Light, None, cx); + }); + let (window, _view) = cx + .update(|cx| { + gpui_kit::open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Point::default(), + size: size(px(1024.), px(200.)), + })), + ..Default::default() + }, + cx, + |_, cx| { + let state = cx.new(AppState::new); + cx.new(|cx| TitleBarView::new(state, cx)) + }, + ) + }) + .expect("open test window"); + + cx.update_window(window, |_, window, cx| { + window.render_frame(cx); + let before = window.find("toggle-language"); + assert_eq!( + before.label(), + Some("EN"), + "zh UI offers the switch to English" + ); + + window.click("toggle-language", cx); + assert_eq!( + crate::i18n::current(), + Language::En, + "the click handler switches the global language" + ); + + window.render_frame(cx); + let after = window.find("toggle-language"); + assert_eq!( + after.label(), + Some("中"), + "the button re-renders with the new language's label" + ); + }) + .unwrap(); + + crate::i18n::set_current(Language::Zh); +} + +#[gpui_kit::test] +fn pressing_toggle_language_does_not_bubble_to_ancestors(cx: &mut TestAppContext) { + let _guard = language_guard(); + crate::i18n::set_current(Language::Zh); + cx.update(|cx| { + gpui_kit::init(cx); + crate::ui::init(cx); + Theme::change(ThemeMode::Light, None, cx); + }); + let bubbled_press = Rc::new(Cell::new(false)); + let harness_press = bubbled_press.clone(); + let (window, _view) = cx + .update(|cx| { + gpui_kit::open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Point::default(), + size: size(px(1024.), px(200.)), + })), + ..Default::default() + }, + cx, + |_, cx| { + let state = cx.new(AppState::new); + cx.new(|cx| Harness { + title_bar: cx.new(|cx| TitleBarView::new(state, cx)), + bubbled_press: harness_press.clone(), + }) + }, + ) + }) + .expect("open test window"); + + cx.update_window(window, |_, window, cx| { + window.render_frame(cx); + window.click("toggle-language", cx); + assert!( + !bubbled_press.get(), + "the button consumes the press, so Windows never turns it into a window drag" + ); + }) + .unwrap(); + + crate::i18n::set_current(Language::Zh); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1b64a83..e477c8f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,6 +3,8 @@ pub mod chart; pub mod completion; pub mod geo; +#[cfg(test)] +mod language_toggle_tests; pub mod results; pub mod scale; pub mod setup_dialog; diff --git a/src/ui/title_bar.rs b/src/ui/title_bar.rs index 2543aa4..5dc1836 100644 --- a/src/ui/title_bar.rs +++ b/src/ui/title_bar.rs @@ -372,6 +372,8 @@ impl TitleBarView { .dropdown_caret(true) .loading(opening) .disabled(opening) + // Out of the Windows caption hit-test, like "expand-sidebar". + .occlude() .dropdown_menu(move |menu, _, _| { let (files_state, folder_state, memory_state) = (state.clone(), state.clone(), state.clone()); @@ -464,6 +466,12 @@ impl Render for TitleBarView { &crate::ui::ToggleSidebar, None, ) + // The TitleBar marks its whole content + // strip as a native drag area; on Windows + // that answers HTCAPTION for every child + // and swallows its clicks. Occluding keeps + // this button out of the caption hit-test. + .occlude() .on_click(cx.listener(|this, _, _, cx| { this.state .update(cx, |state, cx| state.toggle_sidebar(cx)); @@ -507,6 +515,9 @@ impl Render for TitleBarView { Button::new("setup") .icon(IconName::Bot) .tooltip(tr("setup.title")) + // Out of the Windows caption hit-test, like + // "expand-sidebar". + .occlude() .on_click(|_, window, cx| { crate::ui::setup_dialog::open(window, cx) }), @@ -518,6 +529,18 @@ impl Render for TitleBarView { Language::En => "中", }) .tooltip(tr("title_bar.toggle_language")) + // Windows turns the title bar into an + // HTCAPTION region; a mouse-down that GPUI + // leaves unhandled falls through to + // DefWindowProc, which starts a window move + // and swallows the mouse-up, so the click + // never fires. Consume the press, the same + // way gpui-kit's own AppMenuBar does. + .on_mouse_down(MouseButton::Left, |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + gpui_kit::component::GlobalState::suppress_text_selection(cx); + }) .on_click(Self::toggle_language), ) // `dropdown_menu` wraps the Button in a popover that is @@ -530,6 +553,9 @@ impl Render for TitleBarView { .xsmall() .icon(IconName::ALargeSmall) .tooltip(tr("title_bar.ui_size")) + // Out of the Windows caption hit-test, like + // "expand-sidebar". + .occlude() .dropdown_menu(|menu, _, _| { let current = crate::ui::scale::current(); crate::ui::scale::UiSize::ALL.into_iter().fold( @@ -550,6 +576,9 @@ impl Render for TitleBarView { Button::new("toggle-theme") .icon(if dark { IconName::Sun } else { IconName::Moon }) .tooltip(tr("title_bar.toggle_theme")) + // Out of the Windows caption hit-test, like + // "expand-sidebar". + .occlude() .on_click(Self::toggle_theme), ), ), From f1646e97b39e45467b606b6e0bf68384e63584f3 Mon Sep 17 00:00:00 2001 From: JetDeng Date: Thu, 1 Oct 2026 15:21:05 +0800 Subject: [PATCH 2/2] Fix Windows path handling and run the tests on Windows in CI - Wildcard paths under a drive letter or share (`C:\data\*.csv`) matched nothing: glob() gave up on any path with a prefix component. Start the walk from the drive or share root instead. - canonicalize() returns verbatim paths on Windows (`\?\C:\...`). The `?` read as a pattern, so reopening an attached file failed, and the prefix showed in the sidebar. Strip it for drive and UNC paths. - `~` never expanded or compacted outside a Unix-style shell, because Windows does not set HOME. Fall back to USERPROFILE, as setup.rs does, and accept `\` as a separator. - CI built Windows but ran the tests only on macOS, so none of this surfaced; run them on the Windows job too. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 +++++- src/db.rs | 38 ++++++++++++++++++++++---- src/sources.rs | 59 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa55f4a..58457e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,9 @@ jobs: build: # The release ships Linux and Windows builds too; nothing else compiles # them, so a change that breaks either shows up here, on the pull request, - # rather than on a release tag. Build only: the tests run on macOS above. + # rather than on a release tag. Linux builds only; Windows runs the tests + # as well, since its paths (drive letters, `\`, no `$HOME`) are where the + # macOS run cannot catch a mistake. strategy: fail-fast: false matrix: @@ -61,3 +63,7 @@ jobs: - name: Build run: cargo build --locked + + - name: Run tests + if: runner.os == 'Windows' + run: cargo test --locked diff --git a/src/db.rs b/src/db.rs index 2c2767e..d875e26 100644 --- a/src/db.rs +++ b/src/db.rs @@ -75,10 +75,18 @@ impl DatabaseTarget { /// `$HOME`, read once. `file_label` runs on every frame of both the title /// bar and the status bar, so it should not go back to the environment each -/// time. +/// time. Windows does not set `HOME` outside a Unix-style shell; its home is +/// `USERPROFILE`. fn home() -> Option<&'static str> { static HOME: std::sync::OnceLock> = std::sync::OnceLock::new(); - HOME.get_or_init(|| std::env::var("HOME").ok()).as_deref() + HOME.get_or_init(|| { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .ok() + .map(|home| home.trim_end_matches(std::path::is_separator).to_string()) + .filter(|home| !home.is_empty()) + }) + .as_deref() } /// Display `$HOME` as `~`. @@ -93,16 +101,19 @@ pub(crate) fn compact_home(path: &str) -> String { /// home of `/Users/al` leaves `/Users/alice/x` alone. fn compact_home_under(path: &str, home: &str) -> String { match path.strip_prefix(home) { - Some(rest) if rest.is_empty() || rest.starts_with('/') => format!("~{rest}"), + Some(rest) if rest.is_empty() || rest.starts_with(std::path::is_separator) => { + format!("~{rest}") + } _ => path.to_string(), } } -/// Replace a `~/` prefix with `$HOME` expanded. +/// Replace a `~/` prefix (or `~\` on Windows) with `$HOME` expanded. pub fn expand_tilde(path: &str) -> String { - if let Some(rest) = path.strip_prefix("~/") { + let mut chars = path.chars(); + if chars.next() == Some('~') && chars.next().is_some_and(std::path::is_separator) { if let Some(home) = home() { - return format!("{home}/{rest}"); + return format!("{home}{}", &path[1..]); } } path.to_string() @@ -486,6 +497,21 @@ mod tests { assert_eq!(compact_home_under("/Users/alice/x", "/Users/al"), "/Users/alice/x"); } + #[cfg(windows)] + #[test] + fn windows_home_is_compacted_at_a_backslash() { + assert_eq!(compact_home_under(r"C:\Users\al\x.csv", r"C:\Users\al"), r"~\x.csv"); + assert_eq!(compact_home_under(r"C:\Users\alice\x", r"C:\Users\al"), r"C:\Users\alice\x"); + } + + #[cfg(windows)] + #[test] + fn windows_tilde_expansion_takes_a_backslash() { + let expanded = expand_tilde(r"~\data\x.duckdb"); + assert!(!expanded.starts_with('~')); + assert!(expanded.ends_with(r"\data\x.duckdb")); + } + #[test] fn tilde_expansion() { let expanded = expand_tilde("~/data/x.duckdb"); diff --git a/src/sources.rs b/src/sources.rs index 6b9eb12..976cbbd 100644 --- a/src/sources.rs +++ b/src/sources.rs @@ -128,7 +128,22 @@ fn is_hidden(path: &Path) -> bool { } pub fn canonical_file_path(path: &str) -> std::io::Result { - std::fs::canonicalize(expand_tilde(path)).map(|path| path.to_string_lossy().into_owned()) + std::fs::canonicalize(expand_tilde(path)) + .map(|path| strip_verbatim(&path.to_string_lossy()).to_string()) +} + +/// Windows' `canonicalize` answers with a verbatim path (`\\?\C:\data\x.csv`). +/// Its `?` would read as a pattern on the next open, and the prefix is noise in +/// the sidebar, so drop it: `\\?\C:\…` becomes `C:\…` and `\\?\UNC\host\…` +/// becomes `\\host\…`. Elsewhere the path passes through unchanged. +fn strip_verbatim(path: &str) -> std::borrow::Cow<'_, str> { + if let Some(unc) = path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{unc}").into(); + } + match path.strip_prefix(r"\\?\") { + Some(disk) if disk.as_bytes().get(1) == Some(&b':') => disk.into(), + _ => path.into(), + } } /// Add one file to the request, ignoring repeats and everything past the cap. @@ -160,19 +175,24 @@ fn is_pattern(path: &str) -> bool { /// Expand a pattern in every path component against the filesystem. fn glob(pattern: &str) -> Vec { let path = Path::new(pattern); - let mut dirs: Vec = if path.is_absolute() { - vec![PathBuf::from("/")] - } else { + // The fixed start of the path: `/`, or on Windows a drive or share and its + // root (`C:\`, `\\host\share\`). Patterns only ever come after it. + let root: PathBuf = path + .components() + .take_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)) + .collect(); + let mut dirs: Vec = if root.as_os_str().is_empty() { vec![PathBuf::from(".")] + } else { + vec![root] }; for component in path.components() { let part = match component { - Component::CurDir | Component::RootDir => continue, + Component::Prefix(_) | Component::CurDir | Component::RootDir => continue, // `..` is a location, not a pattern: follow it literally. Component::ParentDir => "..".to_string(), Component::Normal(name) => name.to_string_lossy().to_string(), - Component::Prefix(_) => return Vec::new(), }; let mut next = Vec::new(); @@ -322,6 +342,33 @@ mod tests { assert!(sources.problems.is_empty()); } + #[cfg(windows)] + #[test] + fn glob_expands_under_a_drive_letter() { + let dir = scratch("glob_drive"); + touch(&dir.join("data/logs-a.csv")); + touch(&dir.join("data/logs-b.csv")); + let root = canonical_file_path(&dir.to_string_lossy()).unwrap(); + assert!(root.as_bytes().get(1) == Some(&b':'), "{root}"); + + let sources = resolve(&[format!(r"{root}\data\*.csv")]); + + assert_eq!(sources.files.len(), 2); + assert!(sources.problems.is_empty()); + } + + #[test] + fn verbatim_prefixes_are_dropped() { + assert_eq!(strip_verbatim(r"\\?\C:\data\x.csv"), r"C:\data\x.csv"); + assert_eq!(strip_verbatim(r"\\?\UNC\host\share\x.csv"), r"\\host\share\x.csv"); + assert_eq!(strip_verbatim("/home/al/x.csv"), "/home/al/x.csv"); + // A verbatim path that is not a drive path keeps its prefix. + assert_eq!( + strip_verbatim(r"\\?\Volume{1234}\x.csv"), + r"\\?\Volume{1234}\x.csv" + ); + } + #[test] fn unmatched_glob_is_reported() { let dir = scratch("no_match");