diff --git a/AGENTS.md b/AGENTS.md index 0342b30c..14aa7d5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,7 @@ and `cargo build --release` directly; CI runs the gate on both systems. | the mosaic's painting or labels | `crates/disktree-app/src/treemap_view.rs` | | layout of a screen | `crates/disktree-app/src/views.rs` | | colours derived from the theme | `crates/disktree-app/src/palette.rs` | +| the title strip, window controls, the client-side frame | `crates/disktree-app/src/chrome.rs` | ## Verification expectations diff --git a/crates/disktree-app/src/chrome.rs b/crates/disktree-app/src/chrome.rs new file mode 100644 index 00000000..c64dfe6f --- /dev/null +++ b/crates/disktree-app/src/chrome.rs @@ -0,0 +1,767 @@ +//! The window's title strip, and the frame around the window where disktree +//! has to draw one. +//! +//! The native title bar is gone wherever the platform lets it go: the strip +//! is the title bar, the masthead and the view settings in one band. Each +//! end of it may hold window controls — the stoplights on the left on +//! macOS, painted caption buttons on the right on Windows, and, under a Linux +//! compositor that leaves the frame to the client, whichever buttons its +//! `button-layout` asks for on whichever side it names. That is the one +//! platform decision: [`Frame`] says what each end holds, and the strip lays +//! the brand and its contents out between them. +//! +//! The window title is still set and kept current (see `state.rs`): the +//! Window menu, Mission Control, the taskbar and Waybar read it. It is only +//! no longer painted. + +use std::sync::{Arc, LazyLock}; + +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + AnyElement, Context, CursorStyle, Decorations, Div, Edges, FontWeight, + Hsla, Image, ImageFormat, InteractiveElement as _, IntoElement as _, + MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement as _, Pixels, + ResizeEdge, Stateful, StatefulInteractiveElement as _, Styled as _, Tiling, + Window, WindowButton, WindowButtonLayout, WindowControlArea, + WindowControls, div, img, px, rgb, svg, white, +}; +use gpui_omarchy::{ActiveTheme as _, IconName, Theme, with_tooltip}; + +use crate::palette; +use crate::state::Disktree; +use crate::ui::{chrome, space}; + +/// Which system the window is on, as far as its frame is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Platform { + MacOs, + Windows, + Linux, +} + +impl Platform { + pub const fn current() -> Self { + if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(windows) { + Self::Windows + } else { + Self::Linux + } + } +} + +/// What one end of the strip holds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Controls { + /// Nothing: the strip's own padding. + None, + /// The macOS stoplights. The system draws them; the strip leaves room. + Stoplights, + /// Window buttons disktree paints, in this order. + Buttons(Vec), +} + +/// The window's frame: what each end of the strip holds, and whether +/// disktree draws the edges too. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Frame { + pub platform: Platform, + pub leading: Controls, + pub trailing: Controls, + /// Set when disktree draws the whole frame — a Linux compositor that + /// offers no server-side decorations, such as GNOME's — with the sides + /// the compositor has tiled, which keep no resize ring. + pub client_side: Option, +} + +/// GPUI's own fallback when the desktop's setting cannot be read: every +/// button, on the right. It only ever reports none off Linux. +const FALLBACK_LAYOUT: WindowButtonLayout = WindowButtonLayout { + left: [None; 3], + right: [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close), + ], +}; + +impl Frame { + /// The frame this window has now. + pub fn detect(window: &Window, cx: &gpui_kit::App) -> Self { + Self::new( + Platform::current(), + window.window_decorations(), + cx.button_layout(), + window.window_controls(), + window.is_fullscreen(), + ) + } + + pub fn new( + platform: Platform, + decorations: Decorations, + layout: Option, + controls: WindowControls, + fullscreen: bool, + ) -> Self { + let none = Self { + platform, + leading: Controls::None, + trailing: Controls::None, + client_side: None, + }; + match platform { + // In full screen AppKit takes the stoplights away with the menu + // bar, and the room kept for them would be a dead gap. + Platform::MacOs if fullscreen => none, + Platform::MacOs => Self { + leading: Controls::Stoplights, + ..none + }, + // GPUI removes the caption and draws no buttons in its place. + Platform::Windows => Self { + trailing: Controls::Buttons(vec![ + WindowButton::Minimize, + WindowButton::Maximize, + WindowButton::Close, + ]), + ..none + }, + // Hyprland and KDE grant server-side decorations: Hyprland draws + // no frame by design and KDE draws its own, so the strip keeps + // out of both. + Platform::Linux => match decorations { + Decorations::Server => none, + Decorations::Client { tiling } => { + let layout = layout.unwrap_or(FALLBACK_LAYOUT); + // A button for something the compositor will not do + // would do nothing; close is always honoured. + let side = |buttons: [Option; 3]| { + let shown: Vec<_> = buttons + .into_iter() + .flatten() + .filter(|button| match button { + WindowButton::Minimize => controls.minimize, + WindowButton::Maximize => controls.maximize, + WindowButton::Close => true, + }) + .collect(); + if shown.is_empty() { + Controls::None + } else { + Controls::Buttons(shown) + } + }; + Self { + leading: side(layout.left), + trailing: side(layout.right), + client_side: Some(tiling), + ..none + } + } + }, + } + } + + /// The ring kept on each side for the shadow and the resize grip: none + /// on a side the compositor has tiled, nor anywhere it draws the frame. + fn ring(&self) -> Edges { + let open = |tiled: bool| { + if tiled { px(0.) } else { chrome::RESIZE_RING } + }; + self.client_side + .map_or_else(Edges::default, |tiling| Edges { + top: open(tiling.top), + right: open(tiling.right), + bottom: open(tiling.bottom), + left: open(tiling.left), + }) + } + + /// How far the content sits inside the window on each side: the ring, + /// then the hairline border, where disktree draws the frame. The + /// pointer and the window's size come in window coordinates, and + /// anything placed against them in the content has to take this off. + pub fn content_edges(&self) -> Edges { + let border = if self.client_side.is_some() { + px(1.) + } else { + px(0.) + }; + let ring = self.ring(); + Edges { + top: ring.top + border, + right: ring.right + border, + bottom: ring.bottom + border, + left: ring.left + border, + } + } + + /// Whether dragging the strip has to move the window by asking for it. + /// Windows does it from the caption area it is told about, and a + /// compositor that draws the frame has its own way to move a window. + const fn moves_itself(&self) -> bool { + match self.platform { + Platform::MacOs => true, + Platform::Windows => false, + Platform::Linux => self.client_side.is_some(), + } + } +} + +/// The app icon, from the same file the Dock, the desktop entry and the +/// installer use, so the window shows the mark people already know. +static MARK: LazyLock> = LazyLock::new(|| { + Arc::new(Image::from_bytes( + ImageFormat::Svg, + include_bytes!("../../../assets/disktree.svg").to_vec(), + )) +}); + +/// Windows' own close-button red, which it uses whatever the theme. +const CLOSE_RED: u32 = 0x00c4_2b1c; + +/// The strip: window controls at either end, the brand, then `content` — +/// the trail and the settings on explore, nothing on the other screens, +/// which carry their own heading. +pub fn strip( + frame: &Frame, + wordmark: bool, + content: impl gpui_kit::IntoElement, + window: &Window, + cx: &Context<'_, Disktree>, +) -> Stateful
{ + let theme = cx.omarchy().clone(); + let maximized = window.is_maximized(); + let leading = controls( + frame, + &frame.leading, + "window-controls-leading", + maximized, + &theme, + cx, + ); + let trailing = controls( + frame, + &frame.trailing, + "window-controls-trailing", + maximized, + &theme, + cx, + ); + div() + .id("title-strip") + .debug_selector(|| "title-strip".into()) + .flex() + .flex_row() + .items_center() + .flex_shrink_0() + // The band, then the divider under it: a divider inside the band + // would leave an odd height the stoplights cannot centre in. + .min_h(chrome::STRIP + px(1.)) + .border_b_1() + .border_color(theme.divider()) + .when(frame.trailing == Controls::None, |strip| strip.pr(space::LG)) + .children(leading) + // The brand replaces the old logo's source picker action. Keep + // its full-height padding clickable so the target has no dead edge. + .child(with_tooltip( + div() + .id("strip-brand") + .debug_selector(|| "strip-brand".into()) + .flex() + .items_center() + .self_stretch() + .flex_shrink_0() + .cursor_pointer() + .when(frame.leading == Controls::None, |area| { + area.pl(space::LG) + }) + .pr(space::LG) + .child(brand(wordmark, &theme)) + .on_click(cx.listener(|this, _, window, cx| { + this.open_volumes(cx); + this.apply_focus(window, cx); + })), + "Choose local or SSH source · v", + )) + .child( + div() + .flex() + .flex_row() + .items_center() + .self_stretch() + .flex_1() + .min_w_0() + .when(frame.trailing != Controls::None, |row| { + row.mr(space::SM) + }) + .child(content), + ) + .children(trailing) +} + +/// Part of the strip that moves the window: everything that is not a +/// control. On Windows the system is told where its caption is and does +/// the rest — moving, snapping, maximizing on a double click and the system +/// menu on a right click. +pub fn drag_area( + frame: &Frame, + id: &'static str, + cx: &Context<'_, Disktree>, +) -> Stateful
{ + let area = div().id(id).debug_selector(move || id.into()); + let platform = frame.platform; + if platform == Platform::Windows { + return area.window_control_area(WindowControlArea::Drag); + } + if !frame.moves_itself() { + return area; + } + area.on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, _| { + this.strip_drag = event.click_count < 2; + if event.click_count >= 2 { + if platform == Platform::MacOs { + // Zoom, minimize or nothing, as System Settings says. + window.titlebar_double_click(); + } else { + window.zoom_window(); + } + } + }), + ) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, _| { + // The move starts on the first motion, not on the press, so that + // a second press can still arrive as a double click. + if this.strip_drag && event.pressed_button == Some(MouseButton::Left) { + this.strip_drag = false; + window.start_window_move(); + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, _| this.strip_drag = false), + ) + // Motion only reaches the area while the pointer is over it, so a + // press that leaves before its first motion is released elsewhere; it + // must not stay armed for the next drag that crosses the strip. + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _, _, _| this.strip_drag = false), + ) + .when(platform == Platform::Linux, |area| { + area.on_mouse_down(MouseButton::Right, |event, window, _| { + window.show_window_menu(event.position); + }) + }) +} + +/// The icon and the name, centred on the band. +fn brand(wordmark: bool, theme: &Theme) -> Div { + div() + .flex() + .flex_row() + .items_center() + .gap(chrome::LOCKUP_GAP) + .child(img(MARK.clone()).size(chrome::MARK).flex_shrink_0()) + .when(wordmark, |brand| { + brand.child( + div() + .text_size(chrome::WORDMARK) + .line_height(chrome::WORDMARK) + .font_weight(FontWeight::BOLD) + .text_color(theme.bright) + .whitespace_nowrap() + .child("disktree"), + ) + }) +} + +/// One end of the strip. +fn controls( + frame: &Frame, + controls: &Controls, + selector: &'static str, + maximized: bool, + theme: &Theme, + cx: &Context<'_, Disktree>, +) -> Option { + match controls { + Controls::None => None, + Controls::Stoplights => Some( + drag_area(frame, selector, cx) + .w(chrome::STOPLIGHT_ZONE) + .h(chrome::STRIP) + .flex_shrink_0() + .into_any_element(), + ), + Controls::Buttons(buttons) => { + let row = div() + .id(selector) + .debug_selector(move || selector.into()) + .flex() + .flex_row() + .items_center() + .flex_shrink_0(); + let row = + if frame.platform == Platform::Windows { + row.children(buttons.iter().map(|&button| { + caption_button(button, maximized, theme) + })) + } else { + row.gap(space::SM).px(space::SM).children( + buttons.iter().map(|&button| { + window_button(button, maximized, theme) + }), + ) + }; + Some(row.into_any_element()) + } + } +} + +/// A Windows caption button. It has no click handler: the system hit-tests +/// it as the real button, so hovering maximize offers snap layouts and the +/// press does what Windows does. +fn caption_button( + button: WindowButton, + maximized: bool, + theme: &Theme, +) -> Stateful
{ + let group = button.id(); + let area = match button { + WindowButton::Minimize => WindowControlArea::Min, + WindowButton::Maximize => WindowControlArea::Max, + WindowButton::Close => WindowControlArea::Close, + }; + let close = button == WindowButton::Close; + let hover = theme.hover_fill(); + let ink = theme.foreground; + let lit = if close { white() } else { ink }; + div() + .id(button.id()) + .group(group) + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .w(chrome::CAPTION_BUTTON) + .h(chrome::STRIP) + .window_control_area(area) + .hover(move |style| { + if close { + style.bg(rgb(CLOSE_RED)) + } else { + style.bg(hover) + } + }) + .child(glyph(button, maximized, ink, group, lit)) +} + +/// A painted Linux window button. Square, like every other surface here. +fn window_button( + button: WindowButton, + maximized: bool, + theme: &Theme, +) -> Stateful
{ + let group = button.id(); + let (rest, hover) = palette::window_button(theme); + div() + .id(button.id()) + .group(group) + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .size(chrome::WINDOW_BUTTON) + .bg(rest) + .hover(move |style| style.bg(hover)) + .on_click(move |_, window, _| match button { + WindowButton::Minimize => window.minimize_window(), + WindowButton::Maximize => window.zoom_window(), + WindowButton::Close => window.remove_window(), + }) + .child(glyph(button, maximized, theme.bright, group, theme.bright)) +} + +/// The mark on a window button, drawn rather than taken from a font so it +/// looks the same everywhere; `lit` is its colour while `group` is hovered. +fn glyph( + button: WindowButton, + maximized: bool, + ink: Hsla, + group: &'static str, + lit: Hsla, +) -> AnyElement { + match button { + WindowButton::Minimize => div() + .w(chrome::GLYPH) + .h(px(1.)) + .bg(ink) + .group_hover(group, move |style| style.bg(lit)) + .into_any_element(), + // Restore: the window it would go back to, behind the one it is. + WindowButton::Maximize if maximized => div() + .relative() + .size(chrome::GLYPH) + .child( + div() + .absolute() + .top_0() + .right_0() + .size(chrome::GLYPH_BEHIND) + .border_t_1() + .border_r_1() + .border_color(ink), + ) + .child( + div() + .absolute() + .bottom_0() + .left_0() + .size(chrome::GLYPH_BEHIND) + .border_1() + .border_color(ink), + ) + .into_any_element(), + WindowButton::Maximize => div() + .size(chrome::GLYPH) + .border_1() + .border_color(ink) + .group_hover(group, move |style| style.border_color(lit)) + .into_any_element(), + WindowButton::Close => svg() + .path(IconName::Close.path()) + .size(chrome::CLOSE_GLYPH) + .text_color(ink) + .group_hover(group, move |style| style.text_color(lit)) + .into_any_element(), + } +} + +/// Wrap the window's content in the frame a client-side window needs: a +/// ring for the shadow and the resize grip on every side the compositor has +/// not tiled, and a hairline border. Anywhere else the content is the whole +/// window. +pub fn client_frame( + frame: &Frame, + content: Stateful
, + window: &mut Window, + theme: &Theme, +) -> AnyElement { + let Some(tiling) = frame.client_side else { + // A compositor that takes the frame back takes the whole surface: + // an inset left over would draw its frame inside the shadow ring. + if window.client_inset().is_some_and(|inset| inset > px(0.)) { + window.set_client_inset(px(0.)); + } + return content.into_any_element(); + }; + let ring = chrome::RESIZE_RING; + if window.client_inset() != Some(ring) { + window.set_client_inset(ring); + } + let open = frame.ring(); + let mut outer = div() + .id("client-frame") + .debug_selector(|| "client-frame".into()) + .relative() + .size_full() + .pt(open.top) + .pb(open.bottom) + .pl(open.left) + .pr(open.right) + .child( + div() + .size_full() + .border_1() + .border_color(theme.border) + .when(!tiling.is_tiled(), gpui_kit::Styled::shadow_lg) + .child(content), + ); + for (edge, tiled) in [ + (ResizeEdge::Top, tiling.top), + (ResizeEdge::Bottom, tiling.bottom), + (ResizeEdge::Left, tiling.left), + (ResizeEdge::Right, tiling.right), + ] { + if !tiled { + outer = outer.child(grip(edge, ring)); + } + } + for (edge, a, b) in [ + (ResizeEdge::TopLeft, tiling.top, tiling.left), + (ResizeEdge::TopRight, tiling.top, tiling.right), + (ResizeEdge::BottomLeft, tiling.bottom, tiling.left), + (ResizeEdge::BottomRight, tiling.bottom, tiling.right), + ] { + if !a && !b { + outer = outer.child(grip(edge, ring)); + } + } + outer.into_any_element() +} + +/// One resize handle in the ring, with the cursor that says so. +fn grip(edge: ResizeEdge, ring: Pixels) -> Stateful
{ + let (cursor, id) = match edge { + ResizeEdge::Top => (CursorStyle::ResizeUpDown, "grip-top"), + ResizeEdge::Bottom => (CursorStyle::ResizeUpDown, "grip-bottom"), + ResizeEdge::Left => (CursorStyle::ResizeLeftRight, "grip-left"), + ResizeEdge::Right => (CursorStyle::ResizeLeftRight, "grip-right"), + ResizeEdge::TopLeft => { + (CursorStyle::ResizeUpLeftDownRight, "grip-top-left") + } + ResizeEdge::BottomRight => { + (CursorStyle::ResizeUpLeftDownRight, "grip-bottom-right") + } + ResizeEdge::TopRight => { + (CursorStyle::ResizeUpRightDownLeft, "grip-top-right") + } + ResizeEdge::BottomLeft => { + (CursorStyle::ResizeUpRightDownLeft, "grip-bottom-left") + } + }; + let grip = div() + .id(id) + .debug_selector(move || id.into()) + .absolute() + .cursor(cursor) + .on_mouse_down(MouseButton::Left, move |_, window, _| { + window.start_window_resize(edge); + }); + // Edges run between the corners; corners are a ring-sized square. + match edge { + ResizeEdge::Top => grip.top_0().left(ring).right(ring).h(ring), + ResizeEdge::Bottom => grip.bottom_0().left(ring).right(ring).h(ring), + ResizeEdge::Left => grip.left_0().top(ring).bottom(ring).w(ring), + ResizeEdge::Right => grip.right_0().top(ring).bottom(ring).w(ring), + ResizeEdge::TopLeft => grip.top_0().left_0().size(ring), + ResizeEdge::TopRight => grip.top_0().right_0().size(ring), + ResizeEdge::BottomLeft => grip.bottom_0().left_0().size(ring), + ResizeEdge::BottomRight => grip.bottom_0().right_0().size(ring), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame( + platform: Platform, + decorations: Decorations, + layout: Option, + ) -> Frame { + Frame::new( + platform, + decorations, + layout, + WindowControls::default(), + false, + ) + } + + const CLIENT: Decorations = Decorations::Client { + tiling: Tiling { + top: false, + left: false, + right: false, + bottom: false, + }, + }; + + #[test] + fn macos_leaves_room_for_the_stoplights_on_the_left() { + let mac = frame(Platform::MacOs, Decorations::Server, None); + assert_eq!(mac.leading, Controls::Stoplights); + assert_eq!(mac.trailing, Controls::None); + assert!(mac.client_side.is_none()); + let full = Frame::new( + Platform::MacOs, + Decorations::Server, + None, + WindowControls::default(), + true, + ); + assert_eq!(full.leading, Controls::None, "no dead gap in full screen"); + } + + #[test] + fn windows_paints_its_caption_buttons_on_the_right() { + let windows = frame(Platform::Windows, Decorations::Server, None); + assert_eq!(windows.leading, Controls::None); + assert_eq!( + windows.trailing, + Controls::Buttons(vec![ + WindowButton::Minimize, + WindowButton::Maximize, + WindowButton::Close, + ]) + ); + } + + #[test] + fn a_linux_compositor_that_draws_the_frame_gets_no_controls() { + let hyprland = frame(Platform::Linux, Decorations::Server, None); + assert_eq!(hyprland.leading, Controls::None); + assert_eq!(hyprland.trailing, Controls::None); + assert!(hyprland.client_side.is_none()); + } + + #[test] + fn a_client_side_frame_follows_the_desktops_button_layout() { + // GNOME's "close,minimize:maximize": two on the left, one on the + // right, in that order. + let layout = WindowButtonLayout { + left: [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None, + ], + right: [Some(WindowButton::Maximize), None, None], + }; + let gnome = frame(Platform::Linux, CLIENT, Some(layout)); + assert_eq!( + gnome.leading, + Controls::Buttons(vec![ + WindowButton::Close, + WindowButton::Minimize + ]) + ); + assert_eq!( + gnome.trailing, + Controls::Buttons(vec![WindowButton::Maximize]) + ); + assert!(gnome.client_side.is_some()); + + // Unreadable: every button on the right. + let fallback = frame(Platform::Linux, CLIENT, None); + assert_eq!(fallback.leading, Controls::None); + assert_eq!( + fallback.trailing, + Controls::Buttons(vec![ + WindowButton::Minimize, + WindowButton::Maximize, + WindowButton::Close, + ]) + ); + } + + #[test] + fn buttons_for_what_the_compositor_will_not_do_are_left_out() { + let controls = WindowControls { + fullscreen: false, + maximize: false, + minimize: false, + window_menu: true, + }; + let frame = Frame::new(Platform::Linux, CLIENT, None, controls, false); + assert_eq!( + frame.trailing, + Controls::Buttons(vec![WindowButton::Close]) + ); + } +} diff --git a/crates/disktree-app/src/main.rs b/crates/disktree-app/src/main.rs index 904a9319..cf6fb1c9 100644 --- a/crates/disktree-app/src/main.rs +++ b/crates/disktree-app/src/main.rs @@ -13,6 +13,7 @@ mod app_menu; mod appearance; +mod chrome; mod git; mod marks; mod palette; @@ -210,8 +211,22 @@ fn run() -> Result<()> { ) .into(), ), - ..Default::default() + // The title strip is the title bar: macOS and + // Windows give it the top of the window, and + // AppKit puts its stoplights into it, centred on + // the band. Linux reads neither; there the + // compositor decides (see `chrome::Frame`). + appears_transparent: true, + traffic_light_position: Some(gpui_kit::point( + px(ui::chrome::STOPLIGHTS.0), + px(ui::chrome::STOPLIGHTS.1), + )), }), + // On macOS the strip moves the window itself, so + // AppKit does not hold back clicks on the trail and + // the settings to see whether they are a double + // click on the title bar. + app_owns_titlebar_drag: true, // Wayland app id. Hyprland reports it as the window // class, and the desktop entry's StartupWMClass and // the documented window rule both match `disktree`. @@ -228,6 +243,13 @@ fn run() -> Result<()> { appearance::follow(window); } cx.new(|cx| { + // A desktop that moves its window buttons to + // the other side moves them here too. + cx.observe_button_layout_changed( + window, + |_, _, cx| cx.notify(), + ) + .detach(); let mut app = if args.choose_root { Disktree::choose( options.clone(), diff --git a/crates/disktree-app/src/palette.rs b/crates/disktree-app/src/palette.rs index 1f5ad7e7..805dc7ec 100644 --- a/crates/disktree-app/src/palette.rs +++ b/crates/disktree-app/src/palette.rs @@ -149,6 +149,16 @@ pub fn hatch(theme: &Theme) -> Hsla { } } +/// A window button disktree paints where a Linux compositor leaves the +/// frame to it, at rest and under the pointer: a quiet square that reads as +/// a control on any theme without competing with the trail beside it. +pub fn window_button(theme: &Theme) -> (Hsla, Hsla) { + ( + theme.foreground.opacity(0.08), + theme.foreground.opacity(0.16), + ) +} + /// Linear interpolation between two colours, in RGB: interpolating hue /// would drag a colour around the wheel on its way to a grey. pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla { diff --git a/crates/disktree-app/src/state.rs b/crates/disktree-app/src/state.rs index efb9730a..50207f25 100644 --- a/crates/disktree-app/src/state.rs +++ b/crates/disktree-app/src/state.rs @@ -31,7 +31,7 @@ use gpui_kit::{ MouseDownEvent, MouseMoveEvent, NavigationDirection, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Size, Window, px, size, }; -use gpui_omarchy::Status; +use gpui_omarchy::{ActiveTheme as _, Status}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::git::GitState; @@ -387,8 +387,21 @@ pub struct Disktree { /// Focus to move on the next occasion a window is in hand. Key handling /// has no window, and opening or closing the dialog must move focus. pub focus_request: Option, - /// What the titlebar says, so it is only set when it changes. + /// What the titlebar says, so it is only set when it changes. The strip + /// paints no title, but the Window menu, Mission Control, the taskbar + /// and Waybar still read it. window_title: String, + /// The left button went down on the title strip and has not moved yet: + /// the first motion starts a window move. + pub strip_drag: bool, + /// A window frame to draw instead of asking the platform. The + /// window-harness tests pin one per layout: their window has no + /// compositor to ask. + pub chrome_pin: Option, + /// How far the content sits inside the window, from this frame's + /// [`crate::chrome::Frame`]: what turns a pointer position into one in + /// the root's coordinates. + pub content_edges: gpui_kit::Edges, /// The window's `rem` in pixels, read each frame. The mosaic is laid out /// in pixels, so its header band and label thresholds are scaled by this /// to follow interface zoom like the rest of the interface. @@ -575,6 +588,9 @@ impl Disktree { confirm_focus: cx.focus_handle(), focus_request: None, window_title: String::new(), + strip_drag: false, + chrome_pin: None, + content_edges: gpui_kit::Edges::default(), rem: crate::ui::BASE_REM, run: None, run_epoch: 0, @@ -948,7 +964,12 @@ impl Disktree { cx.notify(); return; } - if !path.is_absolute() { + let absolute = if self.volumes_source.is_some() { + disktree_core::remote::is_posix_absolute_path(&path) + } else { + path.is_absolute() + }; + if !absolute { self.picker_error = Some("Enter an absolute directory path.".into()); cx.notify(); @@ -3735,7 +3756,14 @@ impl Render for Disktree { window.set_window_title(&title); self.window_title = title; } - crate::views::root(self, window, cx) + let frame = self + .chrome_pin + .clone() + .unwrap_or_else(|| crate::chrome::Frame::detect(window, cx)); + self.content_edges = frame.content_edges(); + let root = crate::views::root(self, &frame, window, cx); + let theme = cx.omarchy().clone(); + crate::chrome::client_frame(&frame, root, window, &theme) } } diff --git a/crates/disktree-app/src/tests.rs b/crates/disktree-app/src/tests.rs index 0520e4cb..0afc8878 100644 --- a/crates/disktree-app/src/tests.rs +++ b/crates/disktree-app/src/tests.rs @@ -236,6 +236,19 @@ fn startup_chooser_never_scans_before_explicit_acceptance( && app.scan.is_none())); } +#[gpui_kit::test] +fn title_brand_opens_source_picker(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + draw(cx); + let brand = cx.debug_bounds("strip-brand").expect("title brand"); + cx.simulate_click(brand.center(), gpui_kit::Modifiers::none()); + draw(cx); + assert!(read(&view, cx, |app| app.volumes_open)); + assert!(cx.debug_bounds("source-picker").is_some()); +} + #[gpui_kit::test] fn entering_a_directory_scans_only_that_explicit_root(cx: &mut TestAppContext) { cx.update(gpui_omarchy::init); @@ -2357,3 +2370,275 @@ fn the_power_menu_offers_only_what_the_cpus_can_tell_apart( draw(cx); assert!(read(&view, cx, |app| app.power_menu.is_none())); } + +/// Every frame the title strip can be drawn in, as the platforms report +/// them: the window harness has no compositor to ask, so each is pinned. +fn window_frames() -> Vec<(&'static str, crate::chrome::Frame)> { + use crate::chrome::{Frame, Platform}; + use gpui_kit::{ + Decorations, Tiling, WindowButton, WindowButtonLayout, WindowControls, + }; + + let frame = |platform, decorations, layout, fullscreen| { + Frame::new( + platform, + decorations, + layout, + WindowControls::default(), + fullscreen, + ) + }; + let floating = Decorations::Client { + tiling: Tiling::default(), + }; + // Close, minimize, maximize on the left: the layout that puts the + // controls where macOS has its stoplights. + let on_the_left = WindowButtonLayout { + left: [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + ], + right: [None; 3], + }; + vec![ + ( + "macOS", + frame(Platform::MacOs, Decorations::Server, None, false), + ), + ( + "macOS full screen", + frame(Platform::MacOs, Decorations::Server, None, true), + ), + ( + "Windows", + frame(Platform::Windows, Decorations::Server, None, false), + ), + ( + "Hyprland or KDE", + frame(Platform::Linux, Decorations::Server, None, false), + ), + ( + "GNOME, controls right", + frame(Platform::Linux, floating, None, false), + ), + ( + "GNOME, controls left", + frame(Platform::Linux, floating, Some(on_the_left), false), + ), + ( + "GNOME, maximized", + frame( + Platform::Linux, + Decorations::Client { + tiling: Tiling::tiled(), + }, + None, + false, + ), + ), + ] +} + +#[gpui_kit::test] +fn the_title_strip_draws_on_every_window_frame(cx: &mut TestAppContext) { + use crate::chrome::Controls; + use crate::ui::chrome; + + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + for (name, frame) in window_frames() { + for screen in [Screen::Explore, Screen::Review, Screen::Done] { + update(&view, cx, |app, cx| { + app.chrome_pin = Some(frame.clone()); + app.screen = screen; + cx.notify(); + }); + draw(cx); + + let strip = cx.debug_bounds("title-strip").expect(name); + // The band the stoplights centre in, then the divider below it. + assert_eq!( + strip.size.height, + chrome::STRIP + px(1.), + "{name}, {screen:?}" + ); + let brand = cx.debug_bounds("strip-brand").expect(name); + assert!(brand.size.width > px(0.), "{name}: the brand is drawn"); + + // The brand stays clear of the controls at either end. + match &frame.leading { + // The brand's area runs to the edge, padding and all. + Controls::None => assert_eq!(brand.left(), strip.left()), + Controls::Stoplights => assert!( + brand.left() - strip.left() >= chrome::STOPLIGHT_ZONE, + "{name}: brand at {:?}", + brand.left() - strip.left() + ), + Controls::Buttons(_) => { + let controls = + cx.debug_bounds("window-controls-leading").expect(name); + assert!(controls.left() >= strip.left()); + assert!(brand.left() >= controls.right(), "{name}"); + } + } + if let Controls::Buttons(_) = &frame.trailing { + let controls = + cx.debug_bounds("window-controls-trailing").expect(name); + assert!( + (strip.right() - controls.right()).abs() <= px(1.), + "{name}: the controls sit at the right edge" + ); + assert!(brand.right() < controls.left(), "{name}"); + } + } + } +} + +#[gpui_kit::test] +fn a_client_side_frame_keeps_a_resize_ring_until_it_is_tiled( + cx: &mut TestAppContext, +) { + use crate::ui::chrome; + + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + let frames = window_frames(); + let pin = |name: &str| { + frames + .iter() + .find(|(each, _)| *each == name) + .map(|(_, frame)| frame.clone()) + }; + + update(&view, cx, |app, cx| { + app.chrome_pin = pin("GNOME, controls right"); + cx.notify(); + }); + draw(cx); + let root = cx.debug_bounds("disktree-root").expect("root"); + let frame = cx.debug_bounds("client-frame").expect("client frame"); + // The ring, then the window's hairline border. + let inset = chrome::RESIZE_RING + px(1.); + assert_eq!(root.left() - frame.left(), inset); + assert_eq!(root.top() - frame.top(), inset); + for grip in ["grip-top", "grip-bottom-right", "grip-left"] { + assert!(cx.debug_bounds(grip).is_some(), "{grip}"); + } + + // Maximized or tiled, the compositor owns the edges: no ring, no grips. + update(&view, cx, |app, cx| { + app.chrome_pin = pin("GNOME, maximized"); + cx.notify(); + }); + draw(cx); + let root = cx.debug_bounds("disktree-root").expect("root"); + let frame = cx.debug_bounds("client-frame").expect("client frame"); + assert_eq!(root.left() - frame.left(), px(1.)); + assert_eq!(root.top() - frame.top(), px(1.)); + assert!(cx.debug_bounds("grip-top").is_none()); + + // Where the compositor draws the frame, the content is the window. + update(&view, cx, |app, cx| { + app.chrome_pin = pin("Hyprland or KDE"); + cx.notify(); + }); + draw(cx); + assert!(cx.debug_bounds("client-frame").is_none()); +} + +#[gpui_kit::test] +fn the_wordmark_gives_way_in_a_narrow_window(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + update(&view, cx, |app, cx| { + app.chrome_pin = window_frames().first().map(|(_, f)| f.clone()); + cx.notify(); + }); + let brand_width = |cx: &mut Window| { + draw(cx); + cx.debug_bounds("strip-brand").expect("brand").size.width + }; + + cx.simulate_resize(gpui_kit::size(px(1440.), px(900.))); + let wide = brand_width(cx); + cx.simulate_resize(gpui_kit::size(px(900.), px(600.))); + let narrow = brand_width(cx); + assert!( + narrow < wide / 2., + "only the mark is left at 900 pt: {narrow:?} of {wide:?}" + ); + // The trail keeps the crumb you are in. + assert!(cx.debug_bounds("title-strip").is_some()); +} + +#[gpui_kit::test] +fn a_press_on_the_strip_arms_a_move_and_a_click_disarms_it( + cx: &mut TestAppContext, +) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + update(&view, cx, |app, cx| { + app.chrome_pin = window_frames().first().map(|(_, f)| f.clone()); + cx.notify(); + }); + draw(cx); + let empty = cx.debug_bounds("strip-drag").expect("empty strip"); + let at = empty.center(); + let none = gpui_kit::Modifiers::none(); + let left = gpui_kit::MouseButton::Left; + + // A press arms the move. The move itself, on the first motion with + // the button down, cannot run here: the test platform leaves + // `start_window_move` unimplemented. + cx.simulate_mouse_down(at, left, none); + assert!(read(&view, cx, |app| app.strip_drag)); + + // Released without moving, it was a click, and leaves nothing armed + // for the next time the pointer passes over with a button held. + cx.simulate_mouse_up(at, left, none); + assert!(!read(&view, cx, |app| app.strip_drag)); + + // The keyboard still belongs to the treemap. + let focused = cx.update(|window, cx| { + let focus = view.read(cx).focus.clone(); + focus.is_focused(window) + }); + assert!(focused, "a press on the strip leaves the treemap focused"); +} + +#[gpui_kit::test] +fn the_tooltip_follows_the_pointer_inside_a_client_side_frame( + cx: &mut TestAppContext, +) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + // Floating under GNOME: the content sits inside the resize ring. + update(&view, cx, |app, cx| { + app.chrome_pin = window_frames() + .into_iter() + .find(|(name, _)| *name == "GNOME, controls right") + .map(|(_, frame)| frame); + cx.notify(); + }); + draw(cx); + let origin = read(&view, cx, |app| app.treemap_origin.get()); + let at = origin + Point::new(px(40.), px(40.)); + cx.simulate_mouse_move(at, None, gpui_kit::Modifiers::default()); + draw(cx); + + let tip = cx.debug_bounds("cursor-tooltip").expect("a tooltip"); + // Beside the pointer by the tooltip's gap, wherever the root starts. + let gap = crate::ui::space::MD.0 * crate::ui::BASE_REM; + assert_eq!( + tip.left(), + at.x + px(gap), + "{tip:?} for a pointer at {at:?}" + ); + assert_eq!(tip.top(), at.y + px(gap)); +} diff --git a/crates/disktree-app/src/ui.rs b/crates/disktree-app/src/ui.rs index c21d0268..1580d995 100644 --- a/crates/disktree-app/src/ui.rs +++ b/crates/disktree-app/src/ui.rs @@ -107,6 +107,46 @@ pub mod size { pub const KEY_LANE: Rems = Rems(7.0); } +/// The title strip across the top of the window. +/// +/// Pixels, not rems: these line up with what the system draws or measures — +/// the macOS stoplights, Windows' caption buttons, a compositor's resize +/// grip — and none of those follow interface zoom. The strip's own contents +/// (the trail, the settings) stay in rems and scale as they always have. +pub mod chrome { + use gpui_kit::{Pixels, px}; + + /// The band's height. Even, so a 14 pt stoplight centres on a whole + /// point; the divider below it is drawn under the band, not inside it. + pub const STRIP: Pixels = px(40.); + /// Where macOS puts the close button, from the window's top-left: + /// 16 in, and `(40 - 14) / 2` down, so the stoplights centre on y=20. + pub const STOPLIGHTS: (f32, f32) = (16., 13.); + /// Room left for the stoplights: they end at x=76, and the brand keeps + /// 20 clear of them. + pub const STOPLIGHT_ZONE: Pixels = px(96.); + /// A Windows caption button: the system's own 46 wide, the strip tall. + pub const CAPTION_BUTTON: Pixels = px(46.); + /// A painted Linux window button, square like the rest of the app. + pub const WINDOW_BUTTON: Pixels = px(24.); + /// The glyph inside either kind of button. + pub const GLYPH: Pixels = px(10.); + /// Each of the two windows in the restore glyph. + pub const GLYPH_BEHIND: Pixels = px(8.); + /// The close cross, a little larger: its strokes are diagonal, so at + /// the same box it reads smaller than the square beside it. + pub const CLOSE_GLYPH: Pixels = px(12.); + /// The app icon beside the wordmark. + pub const MARK: Pixels = px(20.); + /// The wordmark, bold, and as tall as its line so it centres by cap + /// height rather than by the font's ascent. + pub const WORDMARK: Pixels = px(17.); + /// The mark and the wordmark. + pub const LOCKUP_GAP: Pixels = px(8.); + /// The ring a client-side window keeps for its shadow and resize grip. + pub const RESIZE_RING: Pixels = px(10.); +} + /// Interface zoom steps, as a factor of the 16 px default rem. pub const ZOOM_STEPS: [f32; 7] = [0.75, 0.875, 1.0, 1.125, 1.25, 1.5, 1.75]; diff --git a/crates/disktree-app/src/views.rs b/crates/disktree-app/src/views.rs index 32aedf5f..41006275 100644 --- a/crates/disktree-app/src/views.rs +++ b/crates/disktree-app/src/views.rs @@ -25,6 +25,7 @@ use gpui_omarchy::{ use gpui_kit::prelude::FluentBuilder as _; +use crate::chrome::{self, Frame}; use crate::palette; use crate::state::{ ColorMode, Crumb, Disktree, PANEL_REMS, Screen, panel_width, @@ -40,6 +41,7 @@ const LIST_LIMIT: usize = 1200; /// The whole window. pub fn root( app: &mut Disktree, + frame: &Frame, window: &mut Window, cx: &mut Context<'_, Disktree>, ) -> Stateful
{ @@ -48,6 +50,7 @@ pub fn root( } app.apply_focus(window, cx); let theme = cx.omarchy().clone(); + let strip = title_strip(app, frame, window, cx); let body = match app.screen { Screen::Explore => explore(app, window, cx), Screen::Review => review(app, window, cx), @@ -113,6 +116,7 @@ pub fn root( .text_color(theme.foreground) .font_family(theme.font) .text_size(text::BODY) + .child(strip) .child(body); if let Some(tip) = cursor_tooltip(app, window, cx) { root = root.child(tip); @@ -418,8 +422,8 @@ fn delete_dialog( fn explore( app: &mut Disktree, - window: &mut Window, - cx: &mut Context<'_, Disktree>, + window: &Window, + cx: &Context<'_, Disktree>, ) -> Div { let theme = cx.omarchy().clone(); if app.awaiting_root { @@ -484,7 +488,6 @@ fn explore( .flex_col() .flex_1() .min_h_0() - .child(top_bar(app, &theme, window, cx)) .child( div() .id("explore-body") @@ -496,9 +499,16 @@ fn explore( // pointer from here, wherever it goes in the window. .on_drag_move(cx.listener( |this, event: &DragMoveEvent, window, cx| { + // The pointer is in window coordinates; the + // panel's right edge is the content's, inset by a + // client-side frame. + let inset = &this.content_edges; this.panel_rems = panel_width( - event.event.position.x.as_f32(), - window.viewport_size().width.as_f32(), + (event.event.position.x - inset.left).as_f32(), + (window.viewport_size().width + - inset.left + - inset.right) + .as_f32(), this.rem, ); cx.notify(); @@ -567,37 +577,52 @@ impl gpui_kit::Render for NoGhost { } } -// ── top bar ───────────────────────────────────────────────────────────── +// ── title strip ───────────────────────────────────────────────────────── -/// The app, what the whole scan found, and the controls that decide what -/// is measured, on the edge they own. -fn top_bar( +/// Width, in rem, below which the wordmark gives the trail its room: the +/// settings do not shrink, and at the minimum window the stoplights' zone +/// would otherwise squeeze the trail to nothing. The mark stays. +const WORDMARK_SHOWN_REMS: f32 = 70.0; + +/// The window's title strip. On explore it carries what the whole window +/// is about — where you are and how it is measured — after the brand; the +/// other screens have a heading of their own, so it is only the brand there. +fn title_strip( app: &Disktree, - theme: &Theme, + frame: &Frame, window: &mut Window, cx: &mut Context<'_, Disktree>, -) -> Div { - div() - .flex() - .flex_row() - .items_center() - .gap(space::LG) - .px(space::LG) - .py(space::MD) - .border_b_1() - .border_color(theme.divider()) - .child(with_tooltip( - logo(theme).id("choose-source").cursor_pointer() - .on_click(cx.listener(|this, _, window, cx| { - this.open_volumes(cx); - this.apply_focus(window, cx); - })), - "Choose local or SSH source · v", - )) - // Where you are is navigation, and it belongs to the whole window. - .child(trail(app, theme, cx)) - .child(div().flex_1()) - .child(view_settings(app, window, cx)) +) -> Stateful
{ + let theme = cx.omarchy().clone(); + let explore = app.screen == Screen::Explore; + let width_rems = window.viewport_size().width.as_f32() / app.rem; + let wordmark = !explore || width_rems >= WORDMARK_SHOWN_REMS; + let content = if explore { + div() + .flex() + .flex_row() + .items_center() + .flex_1() + .min_w_0() + // Where you are is navigation, and it belongs to the whole window. + .child(trail(app, &theme, cx)) + // The space between the trail and the settings is all title + // bar: a gap there would look like it and not move the window. + .child( + chrome::drag_area(frame, "strip-drag", cx) + .flex_1() + .min_w(space::XXL) + .self_stretch(), + ) + .child(view_settings(app, window, cx)) + } else { + div() + .flex() + .flex_1() + .self_stretch() + .child(chrome::drag_area(frame, "strip-drag", cx).flex_1()) + }; + chrome::strip(frame, wordmark, content, window, cx) } /// The trail, from `/`. Above the scanned root, a crumb widens the scan; @@ -877,44 +902,6 @@ fn sibling_menu( panel } -/// Four tiles in category colours, and the name. -fn logo(theme: &Theme) -> Div { - let tile = |category| { - div() - .size(space::SM) - .bg(palette::category_accent(theme, category)) - }; - let column = |top, bottom| { - div() - .flex() - .flex_col() - .gap(space::XXS) - .child(tile(top)) - .child(tile(bottom)) - }; - div() - .flex() - .flex_row() - .items_center() - .gap(space::SM) - .flex_shrink_0() - .child( - div() - .flex() - .flex_row() - .gap(space::XXS) - .child(column(Category::Code, Category::Synced)) - .child(column(Category::AgentScratch, Category::Toolchain)), - ) - .child( - div() - .text_size(text::HEADING) - .font_weight(FontWeight::BOLD) - .text_color(theme.bright) - .child("disktree"), - ) -} - /// How the mosaic is measured and drawn, as real controls with visible state. /// /// Every control hands the keyboard straight back to the treemap: arrows, @@ -3559,16 +3546,22 @@ pub fn cursor_tooltip( let content = hover_tooltip(app, cx)?; let origin = app.treemap_origin.get(); let window_size = window.bounds().size; + // The tooltip is placed in the root's coordinates, which a client-side + // frame insets from the window's by its resize ring and border. + let inset = &app.content_edges; + let content_width = (window_size.width - inset.left - inset.right).as_f32(); + let content_height = + (window_size.height - inset.top - inset.bottom).as_f32(); // Flip to the other side of the pointer rather than overflow the window. - let anchor_x = origin.x.as_f32() + pointer.x.as_f32(); - let anchor_y = origin.y.as_f32() + pointer.y.as_f32(); - let x = if anchor_x + gap + width > window_size.width.as_f32() { + let anchor_x = (origin.x + pointer.x - inset.left).as_f32(); + let anchor_y = (origin.y + pointer.y - inset.top).as_f32(); + let x = if anchor_x + gap + width > content_width { (anchor_x - gap - width).max(edge) } else { anchor_x + gap }; - let y = if anchor_y + gap + height > window_size.height.as_f32() { + let y = if anchor_y + gap + height > content_height { (anchor_y - gap - height).max(edge) } else { anchor_y + gap @@ -3576,6 +3569,7 @@ pub fn cursor_tooltip( Some( card_surface(cx) + .debug_selector(|| "cursor-tooltip".into()) .absolute() .left(px(x)) .top(px(y)) diff --git a/crates/disktree-core/src/remote.rs b/crates/disktree-core/src/remote.rs index 27da007f..90eda1a8 100644 --- a/crates/disktree-core/src/remote.rs +++ b/crates/disktree-core/src/remote.rs @@ -5,7 +5,7 @@ //! and stream without making a second serialized copy of the whole tree. use std::io::{self, BufRead, BufReader, Read as _, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex, mpsc}; @@ -23,6 +23,17 @@ const VERSION: u32 = 1; const MAX_LINE: u64 = 1 << 20; const MAX_DIAGNOSTICS: usize = 16 << 10; +/// The helper runs on Linux, even when the desktop runs on Windows. +pub fn is_posix_absolute_path(path: &Path) -> bool { + path.to_str().is_some_and(|path| path.starts_with('/')) +} + +// Local protocol fixtures use native paths; a Windows desktop must also +// accept slash-rooted paths returned by the Linux helper. +fn is_absolute_path(path: &Path) -> bool { + path.is_absolute() || is_posix_absolute_path(path) +} + // Non-interactive shells often omit ~/.local/bin. Do not require users to // edit the server's startup files after the documented user-local install. const REMOTE_COMMAND: &str = r#"if command -v disktree-scan >/dev/null 2>&1; then @@ -84,8 +95,11 @@ pub fn read_roots(input: &mut impl BufRead) -> io::Result { || roots .volumes .iter() - .any(|volume| !volume.point.is_absolute()) - || roots.home.as_ref().is_some_and(|home| !home.is_absolute()) + .any(|volume| !is_absolute_path(&volume.point)) + || roots + .home + .as_ref() + .is_some_and(|home| !is_absolute_path(home)) { return Err(io::Error::other("incompatible remote root discovery")); } @@ -312,7 +326,7 @@ fn read_snapshot_with_progress( shared: &Shared, ) -> io::Result { let header: Header = receive(input)?; - if header.version != VERSION || !header.root.is_absolute() { + if header.version != VERSION || !is_absolute_path(&header.root) { return Err(io::Error::other("incompatible remote helper protocol")); } let mut progress = Progress { @@ -577,7 +591,7 @@ impl RemoteScan { Self::spawn_with_archive(command, host, request, archive) } - #[cfg(test)] + #[cfg(all(test, unix))] fn spawn_command( command: Command, host: &str, @@ -736,6 +750,13 @@ impl Drop for RemoteJob { mod tests { use super::*; + #[test] + fn remote_absolute_paths_use_the_servers_syntax() { + assert!(is_posix_absolute_path(Path::new("/var/log"))); + assert!(!is_posix_absolute_path(Path::new("var/log"))); + assert!(!is_posix_absolute_path(Path::new(r"C:\Windows"))); + } + fn request() -> Request { Request { root: None, @@ -744,6 +765,7 @@ mod tests { } } + #[cfg(unix)] fn wait(scan: &RemoteJob) -> io::Result { wait_for(scan, std::time::Duration::from_secs(10)) } @@ -1209,18 +1231,23 @@ mod tests { .unwrap(); assert_eq!(snapshot.tree.bytes, if dedup { 10 } else { 15 }); assert_eq!(snapshot.tree.files, 3); - let preview = shared.lock().unwrap().preview.clone().unwrap(); - let nested = preview - .entries - .iter() - .find(|entry| entry.name == "nested") - .unwrap(); - assert_eq!(nested.bytes, 5); - assert_eq!(nested.files, 1); - assert_eq!( - preview.entries.iter().map(|e| e.bytes).sum::(), - snapshot.tree.bytes - ); + // The non-Unix fallback sends a complete tree without live + // preview records; the Unix scanner streams the preview. + #[cfg(unix)] + { + let preview = shared.lock().unwrap().preview.clone().unwrap(); + let nested = preview + .entries + .iter() + .find(|entry| entry.name == "nested") + .unwrap(); + assert_eq!(nested.bytes, 5); + assert_eq!(nested.files, 1); + assert_eq!( + preview.entries.iter().map(|e| e.bytes).sum::(), + snapshot.tree.bytes + ); + } } }