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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 4 additions & 5 deletions packaging/release-notes.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 16 additions & 1 deletion scripts/check-desktop-package.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,29 @@
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()
root = Path(__file__).resolve().parent.parent
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()
Expand Down
4 changes: 4 additions & 0 deletions site/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ if (checkout) {
<Mascot />
<p class="intro">A native Git diff viewer, built in Rust.</p>
<p class="agent-line">Keep up with your agents’ changes.</p>
<ul class="hero-badges" aria-label="Highlights">
<li>Freakishly fast</li>
<li>Keyboard shortcuts galore</li>
</ul>
<p class="compatibility">{platforms.map((platform, index) => <>{index > 0 && <span aria-hidden="true"> · </span>}{platform.name} {platform.stage.toLowerCase()}</>)} <span aria-hidden="true">·</span> Requires Git</p>
<div class="purchase">
<p class="beta-price"><span class="visually-hidden">Regular price </span><s>{price.regular}</s> <strong>{price.beta}</strong><span> during beta</span></p>
Expand Down
17 changes: 17 additions & 0 deletions site/src/styles/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
131 changes: 120 additions & 11 deletions src/app/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Instant>,
}

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 {
Expand Down Expand Up @@ -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.
Expand All @@ -554,19 +589,28 @@ impl Diffy {
cx: &mut Context<Self>,
) -> 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),
Expand Down Expand Up @@ -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"
);
}
}
1 change: 1 addition & 0 deletions src/app/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ pub struct Diffy {
file_key_probe: fn(isize) -> bool,
file_scroll_remainder: f32,
file_scroll_axis: Option<files::FileScrollAxis>,
file_scroll_gesture: files::FileScrollGesture,
navigation_save: Option<Task<()>>,
persisted_state: Option<state::SavedState>,
preview_cache: crate::repository::SharedPreviewCache,
Expand Down
1 change: 1 addition & 0 deletions src/app/palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
1 change: 1 addition & 0 deletions src/app/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/app/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
38 changes: 38 additions & 0 deletions tests/performance_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down
Loading
Loading