diff --git a/Cargo.lock b/Cargo.lock index 1e93edc..3cf8d63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "diffy" -version = "0.2.4" +version = "0.2.5" dependencies = [ "agent-sessions", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index 81104c4..89829a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "diffy" -version = "0.2.4" +version = "0.2.5" edition = "2024" description = "A small desktop Git diff viewer" publish = false diff --git a/packaging/release-notes.md b/packaging/release-notes.md index 87e1991..fefca67 100644 --- a/packaging/release-notes.md +++ b/packaging/release-notes.md @@ -1,8 +1,7 @@ -Diffy macOS beta, Windows/Linux alpha +Diffy 0.2.5 — smoother file navigation -- Add Windows x86-64 per-user installers and Linux x86-64 AppImages with signed automatic updates. -- Use native settings directories and forward terminal requests to the running Windows/Linux app. -- Add platform-aware file-manager, terminal/editor launch, shortcuts, and window controls. -- Offer one purchase for every platform: $14.99 during beta, regular price $24.99. +- Pressing Command (Ctrl on Windows/Linux) during an ongoing text scroll now keeps scrolling the text, including momentum, instead of jumping between files. Start a fresh scroll with the modifier held to navigate files. +- Releasing the modifier, cancelling a gesture, or opening a dialog cancels file navigation for the rest of that gesture. +- Add “Freakishly fast” and “Keyboard shortcuts galore” badges to the landing page. Windows and Linux are early alphas with limited hardware testing. Windows installers have no publisher signature. Linux initially targets Ubuntu 24.04+ with Wayland or X11 and a working Vulkan driver. Git is required on every platform. diff --git a/scripts/check-desktop-package.py b/scripts/check-desktop-package.py index 36e9fd3..ebec4b8 100644 --- a/scripts/check-desktop-package.py +++ b/scripts/check-desktop-package.py @@ -7,6 +7,21 @@ import time from pathlib import Path + +class PackageDirectory(tempfile.TemporaryDirectory): + def cleanup(self): + # The relaunched Windows app reports its frame just before quitting. + # Its process can still hold the installation directory briefly. + deadline = time.monotonic() + 10 + while True: + try: + return super().cleanup() + except PermissionError: + if os.name != "nt" or time.monotonic() >= deadline: + raise + time.sleep(0.1) + + parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--platform", choices=["windows", "linux"], required=True) args = parser.parse_args() @@ -14,7 +29,7 @@ package = root / "dist/unsigned" / args.platform / ("Diffy-windows-x86_64-setup.exe" if args.platform == "windows" else "Diffy-linux-x86_64.AppImage") if not os.environ.get("CI"): raise RuntimeError("Run installer checks only on disposable CI machines.") -with tempfile.TemporaryDirectory(prefix="diffy-package-") as directory: +with PackageDirectory(prefix="diffy-package-") as directory: directory = Path(directory) repo = directory / "项目 with spaces" repo.mkdir() diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index b448e6a..c4b09df 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -24,6 +24,10 @@ if (checkout) {

A native Git diff viewer, built in Rust.

Keep up with your agents’ changes.

+

{platforms.map((platform, index) => <>{index > 0 && }{platform.name} {platform.stage.toLowerCase()})} Requires Git

Regular price {price.regular} {price.beta} during beta

diff --git a/site/src/styles/site.css b/site/src/styles/site.css index f0e6520..29f9d21 100644 --- a/site/src/styles/site.css +++ b/site/src/styles/site.css @@ -189,6 +189,23 @@ h1 { margin: 29px 0 8px; letter-spacing: -0.4px; } +.hero-badges { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 8px; + padding: 0; + margin: 0 0 18px; + list-style: none; +} +.hero-badges li { + padding: 6px 10px; + border: 1px solid #42452c; + border-radius: 5px; + color: #e4ed64; + font-size: 10px; + line-height: 1.6; +} .compatibility { font-size: 11px; color: var(--muted); diff --git a/src/app/files.rs b/src/app/files.rs index d7cae54..6e5914d 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -47,10 +47,44 @@ use gpui_kit::div; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::px; use gpui_kit::rgb; -use std::time::Duration; +use std::time::{Duration, Instant}; /// Pixels of trackpad travel that move one file. const FILE_SCROLL_STEP: f32 = 40.; +const FILE_SCROLL_IDLE: Duration = Duration::from_millis(200); + +#[derive(Default)] +pub(super) struct FileScrollGesture { + command: bool, + phased: bool, + last_event: Option, +} + +impl FileScrollGesture { + /// Returns whether this event starts a new gesture. Keep the initial mode + /// after Ended: macOS delivers the momentum tail as Moved events. + fn update(&mut self, precise: bool, phase: TouchPhase, command: bool, now: Instant) -> bool { + if !precise { + *self = Self::default(); + return true; + } + let fresh = phase == TouchPhase::Started + || self.last_event.is_none() + || (!self.phased + && self + .last_event + .is_some_and(|last| now.duration_since(last) >= FILE_SCROLL_IDLE)); + if fresh { + self.command = command; + self.phased = phase == TouchPhase::Started; + } + self.last_event = Some(now); + if !command || phase == TouchPhase::Cancelled { + self.command = false; + } + fresh + } +} #[derive(Clone, Copy, PartialEq, Eq)] pub(super) enum FileScrollAxis { @@ -542,9 +576,10 @@ impl Diffy { self.reset_file_scroll(); } } - fn reset_file_scroll(&mut self) { + pub(super) fn reset_file_scroll(&mut self) { self.file_scroll_remainder = 0.; self.file_scroll_axis = None; + self.file_scroll_gesture.command = false; } /// Command plus a vertical wheel steps files. A horizontal gesture is left /// for the diff to pan. Returns whether this event was consumed. @@ -554,19 +589,28 @@ impl Diffy { cx: &mut Context, ) -> bool { let mods = event.modifiers; - if !mods.secondary() || mods.shift || mods.alt { - self.reset_file_scroll(); - return false; + let command = mods.secondary() + && !mods.shift + && !mods.alt + && !self.palette_open + && self.settings.is_none() + && self.import.picker.is_none(); + if self.file_scroll_gesture.update( + event.delta.precise(), + event.touch_phase, + command, + Instant::now(), + ) { + self.file_scroll_remainder = 0.; + self.file_scroll_axis = None; } - if self.palette_open || self.settings.is_some() || self.import.picker.is_some() { + if !command + || event.touch_phase == TouchPhase::Cancelled + || (event.delta.precise() && !self.file_scroll_gesture.command) + { self.reset_file_scroll(); return false; } - if event.touch_phase == TouchPhase::Started { - self.reset_file_scroll(); - } else if !event.delta.precise() { - self.file_scroll_axis = None; - } let (dx, dy) = match event.delta { ScrollDelta::Pixels(delta) => (f32::from(delta.x), f32::from(delta.y)), ScrollDelta::Lines(delta) => (delta.x, delta.y), @@ -627,3 +671,68 @@ impl Diffy { .and_then(|p| p.view().selected.as_ref()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phaseless_scroll_requires_an_idle_gap_before_switching_modes() { + let mut gesture = FileScrollGesture::default(); + let start = Instant::now(); + gesture.update(true, TouchPhase::Moved, false, start); + gesture.update( + true, + TouchPhase::Moved, + true, + start + Duration::from_millis(50), + ); + assert!( + !gesture.command, + "Pressing Command cannot take over a text scroll" + ); + gesture.update( + true, + TouchPhase::Moved, + true, + start + Duration::from_millis(250), + ); + assert!( + gesture.command, + "Command can navigate after a 200ms idle gap" + ); + } + + #[test] + fn phased_scroll_keeps_its_mode_through_delayed_momentum() { + let mut gesture = FileScrollGesture::default(); + let start = Instant::now(); + gesture.update(true, TouchPhase::Started, false, start); + gesture.update( + true, + TouchPhase::Ended, + false, + start + Duration::from_millis(50), + ); + gesture.update( + true, + TouchPhase::Moved, + true, + start + Duration::from_secs(1), + ); + assert!( + !gesture.command, + "Momentum belongs to the original text gesture" + ); + gesture.update( + true, + TouchPhase::Started, + true, + start + Duration::from_secs(2), + ); + assert!( + gesture.command, + "A fresh Command gesture enables navigation" + ); + } +} diff --git a/src/app/import.rs b/src/app/import.rs index 30432c0..246dddc 100644 --- a/src/app/import.rs +++ b/src/app/import.rs @@ -404,6 +404,7 @@ impl Diffy { return; } self.stop_file_repeat(); + self.reset_file_scroll(); if self.settings.is_some() { self.close_settings(window, cx); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 23142a5..bee6af7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -213,6 +213,7 @@ pub struct Diffy { file_key_probe: fn(isize) -> bool, file_scroll_remainder: f32, file_scroll_axis: Option, + file_scroll_gesture: files::FileScrollGesture, navigation_save: Option>, persisted_state: Option, preview_cache: crate::repository::SharedPreviewCache, diff --git a/src/app/palette.rs b/src/app/palette.rs index 10fd6ba..2ea6c55 100644 --- a/src/app/palette.rs +++ b/src/app/palette.rs @@ -58,6 +58,7 @@ impl Diffy { self.close_palette(true, window, cx); return; } + self.reset_file_scroll(); self.previous_focus = window.focused(cx); self.palette_open = true; self.command.update(cx, |state, cx| { diff --git a/src/app/session.rs b/src/app/session.rs index 7ada5bc..77f0976 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -157,6 +157,7 @@ impl Diffy { file_key_probe: keyboard::file_shortcut_pressed, file_scroll_remainder: 0., file_scroll_axis: None, + file_scroll_gesture: Default::default(), navigation_save: None, persisted_state: None, preview_cache: Default::default(), diff --git a/src/app/settings.rs b/src/app/settings.rs index 8fcc18d..b3339b3 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -53,6 +53,7 @@ impl Diffy { return; } self.stop_file_repeat(); + self.reset_file_scroll(); let p = self.projects.iter().find(|p| p.id == id).unwrap(); let (name, color, icon) = self.appearance(p); let name = name.clone(); diff --git a/tests/performance_ui.rs b/tests/performance_ui.rs index 3ce8f95..653de73 100644 --- a/tests/performance_ui.rs +++ b/tests/performance_ui.rs @@ -174,6 +174,44 @@ fn main() { dispatch(&mut cx, Box::new(app::NextFile)); dispatch(&mut cx, Box::new(app::NextFile)); wait(&mut cx); + // Text scrolling must continue when Command is pressed mid-gesture, + // including the momentum tail that follows the finger-lift event. + cx.update_window(handle, |_, window, cx| { + use gpui_kit::{InputEvent as _, Modifiers, ScrollDelta, TouchPhase, point, px}; + window.render_frame(cx); + let mut previous_line = view.read(cx).selected_top_line(cx).unwrap_or(0); + for (modifiers, touch_phase, dy) in [ + (Modifiers::none(), TouchPhase::Started, -200.), + (Modifiers::secondary_key(), TouchPhase::Moved, -200.), + (Modifiers::secondary_key(), TouchPhase::Ended, 0.), + (Modifiers::secondary_key(), TouchPhase::Moved, -200.), + ] { + window.dispatch_event( + gpui_kit::ScrollWheelEvent { + position: window.find("diff-body").bounds().center(), + delta: ScrollDelta::Pixels(point(px(0.), px(dy))), + modifiers, + touch_phase, + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + assert_eq!( + view.read(cx).selected_file().map(|p| p.as_str()), + Some("large.txt") + ); + let line = view.read(cx).selected_top_line(cx).unwrap(); + if dy != 0. { + assert!( + line > previous_line, + "Text keeps scrolling after Command is pressed" + ); + } + previous_line = line; + } + }) + .unwrap(); if scroll_report { let requests = cx.update(|cx| view.read(cx).preview_request_count()); for target in ["diff-body", "file-list"] { diff --git a/tests/ui.rs b/tests/ui.rs index 61be167..f46e08e 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -160,6 +160,126 @@ fn main() { Some("src/capture.rs"), "Scrolling without Command leaves the file selected" ); + // Pressing Command during a text gesture must not reinterpret its + // remaining movement, including momentum after fingers leave the pad. + for (modifiers, phase, dy) in [ + (Modifiers::none(), TouchPhase::Started, -80.), + (cmd, TouchPhase::Moved, -80.), + (cmd, TouchPhase::Ended, 0.), + (cmd, TouchPhase::Moved, -80.), + ] { + scroll( + w, + ScrollDelta::Pixels(point(px(0.), px(dy))), + modifiers, + phase, + cx, + ); + assert_eq!( + view.read(cx).selected_file().map(|p| p.as_str()), + Some("src/capture.rs"), + "A text gesture stays on its file when Command is pressed" + ); + } + // A modifier release with no intervening wheel event must also cancel + // the accumulated navigation and prevent reactivation in this gesture. + scroll( + w, + ScrollDelta::Pixels(point(px(0.), px(-39.))), + cmd, + TouchPhase::Started, + cx, + ); + { + use gpui_kit::InputEvent as _; + for modifiers in [Modifiers::none(), cmd] { + w.dispatch_event( + gpui_kit::ModifiersChangedEvent { + modifiers, + ..Default::default() + } + .to_platform_input(), + cx, + ); + } + } + scroll( + w, + ScrollDelta::Pixels(point(px(0.), px(-80.))), + cmd, + TouchPhase::Moved, + cx, + ); + assert_eq!( + view.read(cx).selected_file().map(|p| p.as_str()), + Some("src/capture.rs"), + "Repressing Command cannot reactivate the same gesture" + ); + scroll( + w, + ScrollDelta::Pixels(point(px(0.), px(-39.))), + cmd, + TouchPhase::Started, + cx, + ); + scroll( + w, + ScrollDelta::Pixels(point(px(0.), px(-80.))), + cmd, + TouchPhase::Cancelled, + cx, + ); + scroll( + w, + ScrollDelta::Pixels(point(px(0.), px(-80.))), + cmd, + TouchPhase::Moved, + cx, + ); + assert_eq!( + view.read(cx).selected_file().map(|p| p.as_str()), + Some("src/capture.rs"), + "Cancelled gestures cannot continue navigating files" + ); + scroll( + w, + ScrollDelta::Pixels(point(px(0.), px(-39.))), + cmd, + TouchPhase::Started, + cx, + ); + w.press("secondary-p", cx); + w.render_frame(cx); + assert!(view.read(cx).palette_is_open()); + w.press("escape", cx); + w.render_frame(cx); + assert!(!view.read(cx).palette_is_open()); + scroll( + w, + ScrollDelta::Pixels(point(px(0.), px(-80.))), + cmd, + TouchPhase::Moved, + cx, + ); + assert_eq!( + view.read(cx).selected_file().map(|p| p.as_str()), + Some("src/capture.rs"), + "Opening and closing the palette cancels an ongoing file gesture" + ); + scroll( + w, + ScrollDelta::Pixels(point(px(0.), px(-40.))), + cmd, + TouchPhase::Started, + cx, + ); + assert_eq!( + view.read(cx).selected_file().map(|p| p.as_str()), + Some("src/duration.rs"), + "A fresh Command gesture enables file navigation again" + ); + w.press("secondary-up", cx); + w.render_frame(cx); scroll( w, ScrollDelta::Lines(point(0., -1.)),