diff --git a/Cargo.lock b/Cargo.lock index 3cf8d63..5c74658 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,6 +551,29 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -818,6 +841,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + [[package]] name = "cargo-packager-updater" version = "0.2.3" @@ -852,6 +884,30 @@ dependencies = [ "ctor", ] +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "cast" version = "0.3.0" @@ -1006,6 +1062,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -1542,12 +1607,14 @@ dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "octocrab", "reqwest", "rustls", "serde", "serde_json", "sha2 0.10.9", "tempfile", + "tokio", "tracing", "tracing-chrome", "tracing-subscriber", @@ -2144,6 +2211,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -4431,6 +4504,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http", + "serde", +] + [[package]] name = "httparse" version = "1.10.1" @@ -4476,6 +4559,7 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", "rustls-native-certs", "tokio", @@ -6049,6 +6133,46 @@ dependencies = [ "memchr", ] +[[package]] +name = "octocrab" +version = "0.54.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1316aeebcf5959e7442809c5446d97df5b37d87742c9e363b3e9920e0b3fb994" +dependencies = [ + "arc-swap", + "base64 0.22.1", + "bytes", + "cargo_metadata", + "cfg-if", + "chrono", + "futures", + "getrandom 0.2.17", + "hex", + "hmac", + "http", + "http-body", + "http-body-util", + "http-serde", + "hyper", + "hyper-rustls", + "hyper-util", + "percent-encoding", + "pin-project", + "reqwest", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2 0.10.9", + "snafu", + "tower", + "tower-http", + "url", + "web-time", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6644,6 +6768,7 @@ version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", @@ -7038,6 +7163,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -7269,6 +7395,8 @@ version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -7341,6 +7469,7 @@ version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -7473,6 +7602,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -7595,6 +7733,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -7827,6 +7976,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "socket2" version = "0.6.5" @@ -8481,8 +8651,10 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -8500,6 +8672,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", "url", ] @@ -9204,6 +9377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 89829a5..1fb9248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ ui-tests = ["gpui-kit/test-support"] [dependencies] agent-sessions = "=0.2.2" +octocrab = { version = "=0.54.5", default-features = false, features = ["default-client", "rustls", "rustls-ring"] } +tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] } base64 = "=0.22.1" minisign-verify = "=0.2.5" reqwest = { version = "=0.13.5", default-features = false, features = ["blocking", "rustls-no-provider", "json", "system-proxy"] } @@ -120,3 +122,8 @@ required-features = ["ui-tests"] name = "worktrees_ui" harness = false required-features = ["ui-tests"] + +[[test]] +name = "github_ui" +harness = false +required-features = ["ui-tests"] diff --git a/README.md b/README.md index 56839ed..c40dff7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A small native Rust/GPUI Kit diff viewer. macOS is in beta; Windows and Linux ar - A compact alphabetical file list on the right and one selected diff in the center. Files are grouped under their folder. A nested folder reads as `src · app`, with the ancestor dimmed and shortened in the middle when it is long. Filenames are indented under that header and shortened in the middle when needed. Root files stay flush. Hover shows the complete path. Find file, under the scope header, filters the list with nucleo. ⌘F focuses it. Stage all / Unstage all still cover the whole scope. - **Staged** first, then **Changes**, with separate counts. Changes is selected initially. - A fixed project header shows a small project icon and the project name, with linked worktrees alongside it. Staged/Changes and review controls stay in the row below for every project. Attached checkout tabs use branch names; detached checkouts are marked explicitly. Hover shows the checkout path and branch, and the overflow menu keeps long lists accessible. -- Checkouts sharing one Git common directory occupy one rail entry, with a combined badge. Each checkout retains its own scope, selected file, mode, and reading position. The rail remembers the last selected checkout; the palette provides worktree selection and Next/Previous worktree commands. Worktree creation/removal refreshes automatically. Removing a project removes its saved checkout group and excludes it from automatic import without changing Git files. +- Checkouts sharing one Git common directory occupy one rail entry, with a combined badge. Each checkout retains its own scope, selected file, mode, and reading position. The rail remembers the last selected checkout; the palette provides worktree selection and Next/Previous worktree commands. Worktree creation/removal refreshes automatically. Removing a project removes its saved checkout group and leaves it unselected on future imports without changing Git files. - Empty diff panes include a little Snake game while you wait for changes. Enter starts or pauses it; arrow keys or WASD steer. Switching checkouts or Staged/Changes pauses the game, and reduced motion disables its animation. - Staged compares HEAD with the index; Changes compares the index with disk and includes untracked files. A file can appear in both views. - Unified or split diffs with highlighting, inline changes, expandable context, source selection, and copying. Long lines wrap; split mode gives 40% to the original and 60% to the modified source. Added files fill one pane. Red and green marks beside the scrollbar show where further changes appear when the diff needs scrolling. @@ -41,14 +41,22 @@ Normal launch restores repositories. The project import picker appears once, inc ## Import projects -Use **Import** at the bottom of the project rail, **File → Import Projects…**, or the command palette to find local Git projects from Herdr, Codex, and Claude Code. Select the projects to add; repositories already in Diffy are marked **Already added**. Unavailable directories are hidden. Results are newest-first within each source; non-Git folders are hidden too. Older sessions without a stored working directory are skipped. +Use **Import** at the bottom of the project rail, **File → Import Projects…**, or the command palette to find local Git projects from Herdr, Codex, and Claude Code. Select the projects to add; repositories already in Diffy are marked **Already added**. Unavailable directories are hidden. Results appear once per checkout in one list, ordered by the latest tool activity; non-Git folders and repositories in system temporary directories are hidden too. Temporary repositories can still be opened manually, and already-added projects are preserved. Older sessions without a stored working directory are skipped. -**Automatically add new projects** is opt in separately for each source. Removed and unselected projects stay excluded until you explicitly import or open them again. Imported repositories remain in Diffy when their Herdr workspace closes or agent history disappears. Importing preserves the project you are viewing. +Discovery runs when you open Import or press Refresh and shows “Pulling latest projects from Herdr, Codex, and Claude Code…” while scanning. Select projects and press **Import** to add them; discovery never adds projects on its own. Removed and unselected projects stay unselected until you explicitly choose them or open them again. Imported repositories remain in Diffy when their Herdr workspace closes or agent history disappears. Importing preserves the project you are viewing. -Discovery reads directory metadata only. It does not run agents, resume sessions, read terminal output, or access credentials. It honors `HERDR_SOCKET_PATH`, `XDG_CONFIG_HOME`, `CODEX_HOME`, and `CLAUDE_CONFIG_DIR`; otherwise it uses each tool's standard local directories. Herdr's saved sessions provide an offline fallback. Remote repositories need a local checkout in this release. +Discovery reads directory metadata only. It does not run agents, resume sessions, read terminal output, or access credentials. It honors `HERDR_SOCKET_PATH`, `XDG_CONFIG_HOME`, `CODEX_HOME`, and `CLAUDE_CONFIG_DIR`; otherwise it uses each tool's standard local directories. Herdr's saved sessions provide an offline fallback. Recency uses session metadata modification times across all three sources, including saved metadata for live Herdr sessions; projects without a timestamp appear last. `--demo` opens sample projects without reading or writing saved preferences. Demo staging is disabled. +## GitHub pull requests + +Open a local repository with a GitHub.com remote, then choose **Pull requests** beside the project name. **Worktrees** returns to the local checkout with its selection, scope, and reading position preserved. The PR list includes all open pull requests and drafts, most recently updated first. Tabs show the PR number and shortened title; hover for details. The **…** menu searches loaded titles and numbers, offers **Load more** after each page of 100, and lets you choose a remote. Diffy prefers `origin`; with several remotes and no `origin`, choose one from that menu. + +Install [GitHub CLI](https://cli.github.com/) and run `gh auth login --hostname github.com` first. Diffy uses that login for Octocrab's GitHub API requests, including private repositories you can access. Credentials and patches are never written to preferences. Login, permission, connection, and rate-limit errors offer a retry; **Open in GitHub** opens the selected PR in your browser. + +PR review is read-only: it doesn't check out branches, stage files, or change your local index. Local file watchers keep working independently. **⌘R** refreshes the loaded PR pages and selected diff; remote updates aren't polled automatically. The existing split/unified viewer, file search, and copying work with the patch supplied by GitHub. Extra source context beyond that patch is unavailable. Patches over 16 MiB or parsed previews over 32 MiB show a message to open the PR in GitHub; previews share the existing bounded cache. GitHub Enterprise hosts, review comments, approvals, merging, and native sign-in aren't included. + ## Terminal command In the installed macOS app, choose **File → Install terminal command…**, or search for **Install terminal command** in the command palette. This installs `~/.local/bin/diffy` for your user. Existing commands from other tools are preserved. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1f69de0..f227a49 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -29,6 +29,11 @@ Diffy uses gix 0.89.0 (MIT/Apache-2.0) for the opt-in read-only repository metad - gix: https://github.com/GitoxideLabs/gitoxide +Diffy uses Octocrab 0.54.5 (MIT/Apache-2.0) for read-only GitHub pull request access, with Tokio for asynchronous networking. The packaged dependency inventory includes their licenses. + +- Octocrab: https://github.com/XAMPPRocky/octocrab +- Tokio: https://github.com/tokio-rs/tokio + Diffy uses cargo-packager (MIT/Apache-2.0) for packaging and cargo-packager-updater (MIT/Apache-2.0) for signed updates. The updater is pinned to revision `0a3ea3a089363b7e77dfca238be5bbcf3f4d75f6` for HTTPS enforcement. - Source: https://github.com/crabnebula-dev/cargo-packager diff --git a/docs/discovery-libraries.md b/docs/discovery-libraries.md index 136fc42..a102cf3 100644 --- a/docs/discovery-libraries.md +++ b/docs/discovery-libraries.md @@ -35,6 +35,6 @@ Inspected `src/runtime.rs` and `src/socket.rs`. ## Herdr fallback -Small read-only transport adapter using the same platform forms as Reviewr: UnixStream on Unix, interprocess named pipes on Windows. Explicit endpoint, nonblocking IO, bounded response, total deadline, cancellation checks, and request-id validation. Calls session.snapshot only. Unknown fields are ignored; terminal output and agent state are not used. Reconcile snapshots periodically rather than maintaining agent state or replaying event payloads. +Small read-only transport adapter using the same platform forms as Reviewr: UnixStream on Unix, interprocess named pipes on Windows. Explicit endpoint, nonblocking IO, bounded response, total deadline, cancellation checks, and request-id validation. Calls session.snapshot only. Unknown fields are ignored; terminal output and agent state are not used. Each explicit import scan requests a snapshot; there is no background source watcher or automatic import. Saved session modification times provide activity ordering for live results when available; polling never counts as new activity. The discovery subsystem cross-compiles for x86_64-pc-windows-gnu. This does not constitute a Windows GUI runtime test. diff --git a/examples/github-check.rs b/examples/github-check.rs new file mode 100644 index 0000000..7045bbf --- /dev/null +++ b/examples/github-check.rs @@ -0,0 +1,23 @@ +//! Opt-in live API check. Prints counts only, never credentials or diff contents. +use diffy::github::{Backend, OctocrabBackend, Repository}; +fn main() -> Result<(), String> { + let repo = std::env::args() + .nth(1) + .and_then(|s| Repository::parse(&format!("https://github.com/{s}"))) + .ok_or("Usage: cargo run --example github-check -- OWNER/REPO [PR_NUMBER]")?; + let page = OctocrabBackend.list(&repo, 1)?; + println!( + "GitHub listing succeeded: {} open PRs on first page; more={}", + page.items.len(), + page.more + ); + let number = std::env::args() + .nth(2) + .and_then(|s| s.parse().ok()) + .or_else(|| page.items.first().map(|p| p.number)); + if let Some(number) = number { + let diff = OctocrabBackend.diff(&repo, number)?; + println!("GitHub diff succeeded: {} files", diff.snapshot.files.len()); + } + Ok(()) +} diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index c4b09df..345b993 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -53,6 +53,10 @@ if (checkout) {
⌘←⌘→ Worktrees
+
+
Read GitHub pull requests.
+

Switch between worktrees and read-only PR diffs without checking out a branch. Search open and draft PRs by title or number, and load more as you go. Loading indicators show when PRs and diffs are being fetched. Uses your existing GitHub CLI login for public and private repositories.

+
Stay on the keyboard.
@@ -63,15 +67,11 @@ if (checkout) {
Find your agent projects.
-

Import local Git projects from Codex and Claude Code. Pick the ones you want, and optionally add new projects automatically.

-
-
-
Bring your Herdr along.
-

Your local Herdr workspaces are ready to import, including saved sessions. Less adding folders. More reading diffs.

+

Find local Git projects from Herdr, Codex, and Claude Code in one list, with duplicates merged and recently active projects first. Repositories in system temporary folders are hidden. Pick the projects you want to import, and refresh the list whenever you need it.

See changes as they happen.
-

Live refresh keeps the diff current. Staged and unstaged changes stay separate. Stage a file, or all of them, when you’re ready.

+

Local diffs refresh as you edit. Staged and unstaged changes stay separate. Stage a file, or all of them, when you’re ready.

Make projects feel familiar.
diff --git a/src/app/files.rs b/src/app/files.rs index 6e5914d..2de1409 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -98,6 +98,27 @@ impl Diffy { window: &mut Window, cx: &mut Context, ) -> Option> { + if self.is_pr() { + let key = self.github_key()?; + if let Some(search) = self.active_view()?.search.clone() { + return Some(search); + } + let search = cx.new(|cx| InputState::new(window, cx).placeholder("Find file")); + self.active_view_mut()?.search = Some(search.clone()); + let subscription = cx.subscribe(&search, move |this, input, event, cx| { + if matches!(event, InputEvent::Change) + && let Some(state) = this.github.states.get_mut(&key) + { + state.view.set_query(input.read(cx).value().to_string(), cx); + } + }); + self.github + .states + .get_mut(&self.github_key()?) + .unwrap() + .add_subscription(subscription); + return Some(search); + } let project = self.projects.get(self.active)?; if let Some(search) = project.view().search.clone() { return Some(search); @@ -125,13 +146,14 @@ impl Diffy { window: &mut Window, cx: &mut Context, ) -> AnyElement { + self.ensure_github_repository(window, cx); let Some(search) = self.ensure_file_search(window, cx) else { return div().size_full().bg(rgb(0)).into_any_element(); }; let Some(p) = self.projects.get(self.active) else { return div().size_full().bg(rgb(0)).into_any_element(); }; - let view = p.view(); + let view = self.active_view().unwrap(); let files = view.files.clone(); let rows = view.rows.clone(); let sidebar = view.sidebar.clone(); @@ -141,8 +163,8 @@ impl Diffy { let owner = cx.entity().downgrade(); let project_id = p.id; let group = p.group; - let local = p.path.is_some(); - let disabled = p.path.is_none() || p.loading; + let local = p.path.is_some() && !self.is_pr(); + let disabled = !local || p.loading; let files_focused = view.focus.contains_focused(window, cx); v_flex() .size_full() @@ -158,7 +180,7 @@ impl Diffy { div() .font_medium() .text_size(px(14.)) - .child(p.group.label()), + .child(if self.is_pr() { "Files" } else { p.group.label() }), ) .child( div() @@ -334,7 +356,7 @@ impl Diffy { .text_color(color) .child(status), ) - .child( + .when(local, |row| row.child( Button::new(("file-action", index)) .ghost() .xsmall() @@ -373,7 +395,7 @@ impl Diffy { }, ); }), - ), + )), ) .on_click(move |_, window, cx| { let _ = owner.update(cx, |this, cx| { @@ -404,7 +426,7 @@ impl Diffy { }) .vertical_scrollbar(&view.scroll), ) - .child( + .when(local, |list| list.child( div().flex_none().px_3().py_3().child( Button::new("stage-all") .ghost() @@ -422,7 +444,7 @@ impl Diffy { .disabled(disabled || view.files.is_empty()) .on_click(cx.listener(|this, _, _, cx| this.stage_all(cx))), ), - ) + )) .into_any_element() } pub(super) fn focus_file_search(&mut self, window: &mut Window, cx: &mut Context) { @@ -430,11 +452,7 @@ impl Diffy { return; } self.stop_file_repeat(); - let Some(search) = self - .projects - .get(self.active) - .and_then(|project| project.view().search.clone()) - else { + let Some(search) = self.active_view().and_then(|view| view.search.clone()) else { return; }; search.update(cx, |input, cx| { @@ -448,10 +466,9 @@ impl Diffy { window: &mut Window, cx: &mut Context, ) { - let Some(p) = self.projects.get_mut(self.active) else { + let Some(view) = self.active_view_mut() else { return; }; - let view = p.view_mut(); view.select(path, cx); view.focus.focus(window, cx); self.ensure_preview(cx); @@ -460,14 +477,13 @@ impl Diffy { } pub(super) fn move_file(&mut self, direction: isize, cx: &mut Context) { let Some(ix) = self - .projects - .get(self.active) - .and_then(|project| project.view().step_visible(direction)) + .active_view() + .and_then(|view| view.step_visible(direction)) else { return; }; - let path = self.projects[self.active].view().files[ix].path().clone(); - self.projects[self.active].view_mut().select(path, cx); + let path = self.active_view().unwrap().files[ix].path().clone(); + self.active_view_mut().unwrap().select(path, cx); self.ensure_preview(cx); self.persist_navigation(cx); cx.notify(); @@ -666,9 +682,7 @@ impl Diffy { self.file_key_probe = probe; } pub fn selected_file(&self) -> Option<&SharedString> { - self.projects - .get(self.active) - .and_then(|p| p.view().selected.as_ref()) + self.active_view().and_then(|v| v.selected.as_ref()) } } diff --git a/src/app/github_review.rs b/src/app/github_review.rs new file mode 100644 index 0000000..b8cf93e --- /dev/null +++ b/src/app/github_review.rs @@ -0,0 +1,728 @@ +//! Repository-scoped remote review. Local checkout views and their watchers stay independent. +use super::{Diffy, review::ReviewView}; +use crate::{ + github::{self, Backend, PullRequest, Remote, Repository}, + repository::{ChangeGroup, ChangedFile, Preview, PreviewKey}, +}; +use gpui_kit::component::{ + Disableable as _, Selectable as _, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputEvent, InputState}, + popover::{Popover, PopoverState}, + tab::{Tab, TabBar}, + tooltip::Tooltip, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, StatefulInteractiveElement as _, Styled as _, Subscription, Task, + WeakEntity, Window, div, px, +}; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; + +pub(super) struct Runtime { + pub states: HashMap, + pub backend: Arc, +} +impl Default for Runtime { + fn default() -> Self { + Self { + states: HashMap::new(), + backend: Arc::new(github::OctocrabBackend), + } + } +} +pub(super) struct State { + pub remote_mode: bool, + pub remotes: Vec, + pub repository: Option, + pub resolving: bool, + pub prs: Vec, + pub selected: Option, + pub page: u32, + pub more: bool, + pub listing: bool, + pub loading: bool, + list_request: Option, + pub error: Option, + pub view: ReviewView, + pub cache_root: Option, + list_generation: u64, + diff_generation: u64, + resolve_task: Option>, + list_task: Option>, + diff_task: Option>, + menu_search: Entity, + subscriptions: Vec, +} +#[derive(Clone, Copy)] +enum ListRequest { + Initial, + Refresh, + More, +} +impl State { + pub(super) fn loading_message(&self) -> Option<&'static str> { + if !self.remote_mode { + return None; + } + match self.list_request { + Some(ListRequest::Initial) => Some("Loading pull requests…"), + Some(ListRequest::Refresh) => Some("Refreshing pull requests…"), + Some(ListRequest::More) => Some("Loading more…"), + None if self.loading => Some("Loading diff…"), + None => None, + } + } + fn loading_more(&self) -> bool { + matches!(self.list_request, Some(ListRequest::More)) + } + pub(super) fn add_subscription(&mut self, subscription: Subscription) { + self.subscriptions.push(subscription); + } + fn new(window: &mut Window, cx: &mut Context) -> Self { + let menu_search = + cx.new(|cx| InputState::new(window, cx).placeholder("Search pull requests")); + let subscription = cx.subscribe(&menu_search, |_, _, event, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }); + Self { + remote_mode: false, + remotes: Vec::new(), + repository: None, + resolving: true, + prs: Vec::new(), + selected: None, + page: 0, + more: false, + listing: false, + loading: false, + list_request: None, + error: None, + view: ReviewView::new(cx), + cache_root: None, + list_generation: 0, + diff_generation: 0, + resolve_task: None, + list_task: None, + diff_task: None, + menu_search, + subscriptions: vec![subscription], + } + } + pub(super) fn cancel(&mut self, cx: &mut Context) { + self.list_generation += 1; + self.diff_generation += 1; + self.list_task = None; + self.diff_task = None; + self.listing = false; + self.list_request = None; + self.loading = false; + self.view.suspend(cx); + } +} +impl Diffy { + pub(super) fn move_pr(&mut self, direction: isize, cx: &mut Context) { + let Some(s) = self.pr_state() else { + return; + }; + if s.prs.is_empty() { + return; + } + let index = s + .prs + .iter() + .position(|p| Some(p.number) == s.selected) + .unwrap_or(0); + let next = (index as isize + direction).rem_euclid(s.prs.len() as isize) as usize; + self.select_pr(s.prs[next].number, false, cx); + } + pub(super) fn github_key(&self) -> Option { + let p = self.projects.get(self.active)?; + p.repository.clone().or_else(|| p.path.clone()) + } + pub(super) fn pr_state(&self) -> Option<&State> { + self.github.states.get(&self.github_key()?) + } + pub(super) fn is_pr(&self) -> bool { + self.pr_state().is_some_and(|s| s.remote_mode) + } + pub(super) fn active_view(&self) -> Option<&ReviewView> { + if let Some(s) = self.pr_state().filter(|s| s.remote_mode) { + Some(&s.view) + } else { + self.projects.get(self.active).map(|p| p.view()) + } + } + pub(super) fn active_view_mut(&mut self) -> Option<&mut ReviewView> { + if self.is_pr() { + let key = self.github_key()?; + self.github.states.get_mut(&key).map(|s| &mut s.view) + } else { + self.projects.get_mut(self.active).map(|p| p.view_mut()) + } + } + pub(super) fn ensure_github_repository(&mut self, window: &mut Window, cx: &mut Context) { + let Some(key) = self.github_key() else { + return; + }; + if self.github.states.contains_key(&key) || self.projects[self.active].loading { + return; + } + let Some(path) = self.projects[self.active].path.clone() else { + return; + }; + let mut state = State::new(window, cx); + let request_key = key.clone(); + state.resolve_task = Some(cx.spawn(async move |owner, cx| { + let result = cx + .background_spawn(async move { github::remotes(&path) }) + .await; + let _ = owner.update(cx, |this, cx| { + let Some(s) = this.github.states.get_mut(&request_key) else { + return; + }; + s.resolving = false; + s.resolve_task = None; + match result { + Ok(remotes) => { + s.repository = remotes + .first() + .filter(|r| r.name == "origin" || remotes.len() == 1) + .map(|r| r.repository.clone()); + s.remotes = remotes; + } + Err(error) => s.error = Some(error), + } + cx.notify(); + }); + })); + self.github.states.insert(key, state); + } + pub(super) fn leave_github(&mut self, cx: &mut Context) { + if let Some(key) = self.github_key() + && let Some(s) = self.github.states.get_mut(&key) + { + s.cancel(cx); + } + } + pub(super) fn set_review_source( + &mut self, + remote: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.stop_file_repeat(); + self.reset_file_scroll(); + let Some(key) = self.github_key() else { + return; + }; + if remote + && self + .pr_state() + .is_none_or(|s| s.resolving || s.remotes.is_empty()) + { + return; + } + if remote { + if let Some(view) = self.active_view_mut() { + view.suspend(cx); + } + } else { + self.leave_github(cx); + } + if let Some(s) = self.github.states.get_mut(&key) { + s.remote_mode = remote; + } + if remote { + if self.pr_state().is_some_and(|s| s.page == 0) { + self.list_prs(false, cx); + } else { + self.ensure_pr_preview(cx); + } + } else { + self.ensure_preview(cx); + } + self.focus.focus(window, cx); + cx.notify(); + } + pub(super) fn list_prs(&mut self, more: bool, cx: &mut Context) { + let Some(key) = self.github_key() else { + return; + }; + let Some(s) = self.github.states.get_mut(&key) else { + return; + }; + let Some(repo) = s.repository.clone() else { + return; + }; + if s.listing { + return; + } + let page = if more { s.page + 1 } else { 1 }; + let last_page = if more { page } else { s.page.max(1) }; + s.list_generation += 1; + let generation = s.list_generation; + s.listing = true; + s.list_request = Some(if more { + ListRequest::More + } else if s.page == 0 { + ListRequest::Initial + } else { + ListRequest::Refresh + }); + s.error = None; + let backend = self.github.backend.clone(); + s.list_task = Some(cx.spawn(async move |owner, cx| { + let result = cx + .background_spawn(async move { + let mut combined = github::Page { + items: Vec::new(), + more: false, + }; + let mut loaded_page = page; + for current in page..=last_page { + let result = backend.list(&repo, current)?; + combined.items.extend(result.items); + combined.more = result.more; + loaded_page = current; + if !combined.more { + break; + } + } + Ok::<_, String>((combined, loaded_page)) + }) + .await; + let _ = owner.update(cx, |this, cx| { + if this.github_key().as_ref() != Some(&key) { + return; + } + let Some(s) = this.github.states.get_mut(&key) else { + return; + }; + if s.list_generation != generation || !s.remote_mode { + return; + } + s.listing = false; + s.list_request = None; + s.list_task = None; + match result { + Ok((result, loaded_page)) => { + if !more { + s.prs.clear(); + } + for pr in result.items { + if !s.prs.iter().any(|p| p.number == pr.number) { + s.prs.push(pr); + } + } + s.page = loaded_page; + s.more = result.more; + let selected = s + .selected + .filter(|n| s.prs.iter().any(|p| p.number == *n)) + .or_else(|| s.prs.first().map(|p| p.number)); + if let Some(number) = selected { + this.select_pr(number, !more, cx); + } else { + s.selected = None; + s.cache_root = None; + s.view.clear(cx); + } + } + Err(error) => s.error = Some(error), + } + cx.notify(); + }); + })); + cx.notify(); + } + pub(super) fn select_pr(&mut self, number: u64, force: bool, cx: &mut Context) { + self.stop_file_repeat(); + self.reset_file_scroll(); + let Some(key) = self.github_key() else { + return; + }; + let Some(s) = self.github.states.get_mut(&key) else { + return; + }; + let Some(repo) = s.repository.clone() else { + return; + }; + if !s.prs.iter().any(|p| p.number == number) { + return; + } + let changed = s.selected != Some(number); + if !changed && !force && s.cache_root.is_some() { + self.ensure_pr_preview(cx); + return; + } + if changed { + s.view.clear(cx); + s.cache_root = None; + } + s.selected = Some(number); + s.loading = true; + s.error = None; + s.diff_generation += 1; + let generation = s.diff_generation; + let backend = self.github.backend.clone(); + s.diff_task = Some(cx.spawn(async move |owner, cx| { + let request_repo = repo.clone(); + let result = cx + .background_spawn(async move { backend.diff(&request_repo, number) }) + .await; + let _ = owner.update(cx, |this, cx| { + if this.github_key().as_ref() != Some(&key) { + return; + } + let Some(s) = this.github.states.get_mut(&key) else { + return; + }; + if s.diff_generation != generation + || !s.remote_mode + || s.selected != Some(number) + || s.repository.as_ref() != Some(&repo) + { + return; + } + s.loading = false; + s.diff_task = None; + match result { + Ok(result) => { + let root = result.pr.cache_root(&repo); + if let Some(pr) = s.prs.iter_mut().find(|p| p.number == number) { + *pr = result.pr; + } + // Pin the selected file even if the PR exceeds the cache's slot count. + let preferred = s.view.selected.clone(); + let selected_preview = result + .snapshot + .files + .iter() + .find(|f| preferred.as_ref() == Some(f.path())) + .or_else(|| result.snapshot.files.first()) + .map(|file| { + let revision = result.snapshot.revisions[file.path()]; + ( + Preview { + file: file.clone(), + fingerprint: revision, + bytes: file.estimated_size_bytes(), + }, + revision, + ) + }); + let mut cache = + this.preview_cache.lock().unwrap_or_else(|e| e.into_inner()); + let entries = result + .snapshot + .files + .into_iter() + .map(|file| { + let revision = result.snapshot.revisions[file.path()]; + let entry = ChangedFile::from_diff(&file, revision); + cache.insert( + PreviewKey { + root: root.clone(), + group: ChangeGroup::Changes, + path: file.path().clone(), + revision, + }, + Preview { + bytes: file.estimated_size_bytes(), + fingerprint: revision, + file, + }, + ); + entry + }) + .collect(); + drop(cache); + s.cache_root = Some(root); + s.view.install_metadata(entries, number, cx); + if let Some((preview, revision)) = selected_preview { + s.view.install_preview(preview, revision, cx); + } + } + Err(error) => s.error = Some(error), + } + cx.notify(); + }); + })); + cx.notify(); + } + pub(super) fn ensure_pr_preview(&mut self, cx: &mut Context) { + let Some(key) = self.github_key() else { + return; + }; + let Some(s) = self.github.states.get_mut(&key) else { + return; + }; + if !s.remote_mode { + return; + } + if s.cache_root.is_none() + && !s.loading + && s.error.is_none() + && let Some(number) = s.selected + { + self.select_pr(number, true, cx); + return; + } + let Some(entry) = s.view.selected_entry().cloned() else { + return; + }; + if s.view.preview_revision == Some(entry.revision) { + return; + } + if let Some(root) = s.cache_root.clone() { + let cached = self + .preview_cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&PreviewKey { + root, + group: ChangeGroup::Changes, + path: entry.path.clone(), + revision: entry.revision, + }); + if let Some(preview) = cached { + s.view.install_preview(preview, entry.revision, cx); + } else if !s.loading + && let Some(number) = s.selected + { + self.select_pr(number, true, cx); + } + } + } + fn change_remote(&mut self, repo: Repository, cx: &mut Context) { + let Some(key) = self.github_key() else { + return; + }; + let Some(s) = self.github.states.get_mut(&key) else { + return; + }; + s.cancel(cx); + s.repository = Some(repo); + s.prs.clear(); + s.selected = None; + s.page = 0; + s.more = false; + s.cache_root = None; + s.view.clear(cx); + self.list_prs(false, cx); + cx.notify(); + } + pub(super) fn pr_empty_message(&self) -> &'static str { + let Some(s) = self.pr_state() else { + return "Pull requests unavailable"; + }; + if s.resolving { + "Reading GitHub remotes…" + } else if s.repository.is_none() { + "Choose a GitHub remote from the pull request menu" + } else if s.loading { + "Loading pull request diff…" + } else if s.listing { + "Loading pull requests…" + } else if s.error.is_some() { + "Pull requests unavailable · Retry or open GitHub" + } else if s.selected.is_some() { + "No changes in this pull request" + } else { + "No open pull requests" + } + } + pub(super) fn open_pr_browser(&self, cx: &mut Context) { + if let Some(s) = self.pr_state() + && let Some(repo) = &s.repository + { + cx.open_url(&s.selected.map_or_else(|| repo.url(), |n| repo.pr_url(n))); + } + } + pub(super) fn render_pr_tabs(&self, cx: &mut Context) -> AnyElement { + let Some(s) = self.pr_state() else { + return div().into_any_element(); + }; + let selected = s + .prs + .iter() + .position(|p| Some(p.number) == s.selected) + .unwrap_or(0); + let mut tabs = TabBar::new("pr-tabs") + .underline() + .small() + .max_width(px(180.)) + .selected_index(selected) + .min_w_0() + .flex_1() + .h(px(32.)); + for pr in &s.prs { + let title = pr.title.clone(); + let tooltip = pr.tooltip(); + tabs = tabs.child( + Tab::new() + .prefix( + div() + .flex_shrink_0() + .mr_1() + .child(format!("#{}", pr.number)), + ) + .label(title) + .tooltip(move |w, cx| { + Tooltip::new(tooltip.clone()) + .max_w(px(640.)) + .whitespace_normal() + .build(w, cx) + }), + ); + } + let numbers: Vec<_> = s.prs.iter().map(|p| p.number).collect(); + let owner = cx.entity().downgrade(); + h_flex() + .min_w_0() + .flex_1() + .child(tabs.on_click(cx.listener(move |this, ix: &usize, _, cx| { + if let Some(&n) = numbers.get(*ix) { + this.select_pr(n, false, cx); + } + }))) + .child( + Popover::new("pr-menu") + .trigger( + Button::new("pr-overflow") + .ghost() + .xsmall() + .label("…") + .accessibility_label("Find pull request or choose remote"), + ) + .content(move |_, window, cx| { + let popup = cx.entity().downgrade(); + owner + .update(cx, |this, cx| this.render_pr_menu(popup, window, cx)) + .unwrap_or_else(|_| div().into_any_element()) + }), + ) + .into_any_element() + } + fn render_pr_menu( + &self, + popup: WeakEntity, + _: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(s) = self.pr_state() else { + return div().into_any_element(); + }; + let query = s.menu_search.read(cx).value().to_lowercase(); + let items: Vec<_> = s + .prs + .iter() + .filter(|p| { + format!("#{} {}", p.number, p.title) + .to_lowercase() + .contains(query.as_str()) + }) + .cloned() + .collect(); + v_flex() + .w(px(420.)) + .max_w_full() + .gap_2() + .p_2() + .children(s.remotes.iter().map(|remote| { + let repo = remote.repository.clone(); + Button::new(format!("github-remote-{}", remote.name)) + .ghost() + .small() + .label(format!("{} · {}", remote.name, repo.label())) + .selected(s.repository.as_ref() == Some(&repo)) + .on_click( + cx.listener(move |this, _, _, cx| this.change_remote(repo.clone(), cx)), + ) + })) + .child(Input::new(&s.menu_search).id("pr-search").small()) + .child( + v_flex() + .id("pr-options") + .max_h(px(320.)) + .overflow_y_scroll() + .children(items.into_iter().map(|pr| { + let popup = popup.clone(); + Button::new(format!("choose-pr-{}", pr.number)) + .ghost() + .small() + .w_full() + .label(format!( + "#{} {}{}", + pr.number, + pr.title, + if pr.draft { " · Draft" } else { "" } + )) + .on_click(cx.listener(move |this, _, window, cx| { + this.select_pr(pr.number, false, cx); + let _ = popup.update(cx, |p, cx| p.dismiss(window, cx)); + })) + })), + ) + .when(s.more, |d| { + d.child( + Button::new("pr-load-more") + .ghost() + .small() + .label(if s.loading_more() { + "Loading more…" + } else { + "Load more" + }) + .loading(s.loading_more()) + .disabled(s.listing) + .on_click(cx.listener(|this, _, _, cx| this.list_prs(true, cx))), + ) + }) + .into_any_element() + } +} + +#[cfg(feature = "ui-tests")] +impl Diffy { + pub fn set_github_fixture( + &mut self, + remotes: Vec, + backend: Arc, + window: &mut Window, + cx: &mut Context, + ) { + let key = self + .github_key() + .unwrap_or_else(|| PathBuf::from("demo-github")); + let mut s = State::new(window, cx); + s.resolving = false; + s.repository = remotes.first().map(|r| r.repository.clone()); + s.remotes = remotes; + self.github.backend = backend; + self.github.states.insert(key, s); + } + pub fn probe_pr_writes(&mut self, cx: &mut Context) { + self.stage_file(cx); + self.stage_all(cx); + if let Some(p) = self.projects.get(self.active) { + let id = p.id; + if let Some(file) = p.view().selected_entry().cloned() { + self.stage_path(id, ChangeGroup::Changes, &file.path, cx); + self.load(Some((vec![file], true)), cx); + } + } + } + pub fn github_selected(&self) -> Option { + self.pr_state().and_then(|s| s.selected) + } + pub fn github_busy(&self) -> bool { + self.pr_state().is_some_and(|s| s.listing || s.loading) + } +} diff --git a/src/app/import.rs b/src/app/import.rs index 246dddc..d2819db 100644 --- a/src/app/import.rs +++ b/src/app/import.rs @@ -11,7 +11,6 @@ use gpui_kit::component::{VirtualListScrollHandle, scroll::ScrollableElement as use gpui_kit::component::{ button::{Button, ButtonVariants as _}, checkbox::Checkbox, - group_box::GroupBox, tooltip::Tooltip, }; use gpui_kit::prelude::FluentBuilder as _; @@ -27,14 +26,16 @@ use std::{ Arc, atomic::{AtomicBool, Ordering}, }, - time::{Duration, Instant}, }; pub(super) struct Runtime { pub preferences: discovery::Preferences, pub snapshot: Arc, pub picker: Option>, - pub requested: bool, + pub generation: u64, + scanner: Option, + #[cfg(feature = "ui-tests")] + fixture: Option, pub loading: bool, pub cancelled: Arc, pub task: Option>, @@ -45,7 +46,10 @@ impl Default for Runtime { preferences: Default::default(), snapshot: Arc::new(Snapshot::default()), picker: None, - requested: false, + generation: 0, + scanner: None, + #[cfg(feature = "ui-tests")] + fixture: None, loading: false, cancelled: Arc::new(AtomicBool::new(false)), task: None, @@ -58,12 +62,6 @@ impl Drop for Runtime { } } -#[derive(Clone)] -enum Row { - Header(Source), - Project(usize, Source), - Empty(Source), -} pub(super) struct Picker { owner: WeakEntity, focus: FocusHandle, @@ -71,10 +69,9 @@ pub(super) struct Picker { existing: BTreeSet, selected: BTreeSet, seen: BTreeSet, - automatic: [bool; 3], + excluded_repositories: BTreeSet, loading: bool, scroll: VirtualListScrollHandle, - rows: Arc>, sizes: Rc>>, } impl Picker { @@ -91,67 +88,31 @@ impl Picker { if self.seen.insert(candidate.path.clone()) && candidate.error.is_none() && !self.existing.contains(&candidate.path) + && !candidate + .repository + .as_ref() + .is_some_and(|r| self.excluded_repositories.contains(r)) { self.selected.insert(candidate.path.clone()); } } self.snapshot = snapshot; self.loading = loading; - let mut rows = Vec::new(); - let mut sizes = Vec::new(); - for source in Source::ALL { - rows.push(Row::Header(source)); - sizes.push(size( - px(600.), - px(if self.snapshot.errors[source.index()].is_some() { - 112. - } else { - 84. - }), - )); - let mut count = 0; - let mut candidates: Vec<_> = self - .snapshot - .candidates - .iter() - .enumerate() - .filter(|(_, candidate)| candidate.sources.contains(&source)) - .collect(); - candidates.sort_by_key(|(_, candidate)| { - ( - std::cmp::Reverse(candidate.created_at), - candidate.name().to_lowercase(), - &candidate.path, - ) - }); - for (ix, _) in candidates { - rows.push(Row::Project(ix, source)); - sizes.push(size(px(600.), px(60.))); - count += 1; - } - if count == 0 { - rows.push(Row::Empty(source)); - sizes.push(size(px(600.), px(36.))); - } - } - self.rows = Arc::new(rows); - self.sizes = Rc::new(sizes); + self.sizes = Rc::new(vec![ + size(px(600.), px(60.)); + self.snapshot.candidates.len() + ]); } fn finish(&mut self, apply: bool, window: &mut Window, cx: &mut Context) { let selected = self.selected.clone(); - let automatic = self.automatic; let snapshot = self.snapshot.clone(); let _ = self.owner.update(cx, |this, cx| { - this.finish_import(apply, selected, automatic, &snapshot, window, cx) + this.finish_import(apply, selected, &snapshot, window, cx) }); } - fn render_project(&self, ix: usize, source: Source, cx: &mut Context) -> AnyElement { + fn render_project(&self, ix: usize, cx: &mut Context) -> AnyElement { let candidate = &self.snapshot.candidates[ix]; - let row_id = if candidate.section() == source { - ix.to_string() - } else { - format!("{ix}-{}", source.index()) - }; + let row_id = candidate.path.to_string_lossy(); let path = candidate.path.clone(); let tooltip = path.display().to_string(); let row_path = path.clone(); @@ -209,24 +170,7 @@ impl Picker { .flex_1() .min_w_0() .gap_1() - .child( - h_flex() - .gap_2() - .child(div().font_medium().child(candidate.name())) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child( - candidate - .sources - .iter() - .map(|s| s.label()) - .collect::>() - .join(" · "), - ), - ), - ) + .child(div().font_medium().truncate().child(candidate.name())) .child( div() .truncate() @@ -256,7 +200,19 @@ impl Picker { } impl Render for Picker { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let rows = self.rows.clone(); + let status = if self.loading { + "Pulling latest projects from Herdr, Codex, and Claude Code…".into() + } else { + format!( + "{} project{} found · Most recently updated first", + self.snapshot.candidates.len(), + if self.snapshot.candidates.len() == 1 { + "" + } else { + "s" + } + ) + }; v_flex() .id("import-picker") .test_support() @@ -269,6 +225,26 @@ impl Render for Picker { .text_color(cx.theme().muted_foreground) .child("Choose local Git projects to add to Diffy."), ) + .child( + div() + .id("import-status") + .test_support() + .role(gpui_kit::Role::Status) + .aria_label(status.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(status), + ) + .children(Source::ALL.into_iter().filter_map(|source| { + self.snapshot.errors[source.index()].as_ref().map(|error| { + div() + .id(format!("import-error-{}", source.index())) + .test_support() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{}: {error}", source.label())) + }) + })) .child( h_flex() .gap_2() @@ -309,64 +285,69 @@ impl Render for Picker { .label("Refresh") .disabled(self.loading) .on_click(cx.listener(|this, _, _, cx| { - let _ = this.owner.update(cx, |owner, cx| { - owner.request_import_scan(cx); + if this.loading { + return; + } + this.loading = true; + cx.notify(); + let owner = this.owner.clone(); + let picker = cx.entity().downgrade(); + // The parent updates this picker when a scan starts. Wait + // until the click handler has released its entity lease. + cx.defer(move |cx| { + let _ = + owner.update(cx, |owner, cx| { + if owner.import.picker.as_ref().is_some_and(|p| { + p.entity_id() == picker.entity_id() + }) { + owner.request_import_scan(cx); + } + }); }); })), ), ) .child( - div().id("import-scroll").test_support().relative().flex_none().min_h_0() - .h(px((f32::from(window.viewport_size().height) * 0.52).min(460.))) - .child(v_virtual_list( - cx.entity(), - "import-results", - self.sizes.clone(), - move |this, range, _, cx| { - range - .map(|ix| match rows[ix] { - Row::Header(source) => { - let error = this.snapshot.errors[source.index()].clone(); - GroupBox::new() - .title(source.label()) - .w_full() - .mt_3() - .child( - div().id(format!("import-auto-{}",source.index())).test_support().child(Checkbox::new(("auto-import", source.index())) - .label("Automatically add new projects") - .accessibility_label(format!("Automatically add new {} projects",source.label())) - .checked(this.automatic[source.index()]) - .on_change(cx.listener( - move |this, checked: &bool, _, cx| { - this.automatic[source.index()] = *checked; - cx.notify(); - }, - ))), - ) - .children(error.map(|e| { - div().text_xs().text_color(cx.theme().muted_foreground).child(e) - })) - .into_any_element() - } - Row::Project(candidate, source) => this.render_project(candidate, source, cx), - Row::Empty(source) => div() - .pl_2() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(if this.loading { - "Looking for projects…" - } else if this.snapshot.errors[source.index()].is_some() { - "No readable projects found." - } else { - "No projects found." - }) - .into_any_element(), - }) - .collect::>() - }, - ) - .track_scroll(&self.scroll) - .size_full()).vertical_scrollbar(&self.scroll), + div() + .id("import-scroll") + .test_support() + .relative() + .flex_none() + .min_h_0() + .h(px( + (f32::from(window.viewport_size().height) * 0.52).min(460.) + )) + .when(self.snapshot.candidates.is_empty(), |d| { + d.child( + div() + .px_2() + .py_3() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(if self.loading { + "Looking for projects…" + } else { + "No projects found." + }), + ) + }) + .when(!self.snapshot.candidates.is_empty(), |d| { + d.child( + v_virtual_list( + cx.entity(), + "import-results", + self.sizes.clone(), + |this, range, _, cx| { + range + .map(|ix| this.render_project(ix, cx)) + .collect::>() + }, + ) + .track_scroll(&self.scroll) + .size_full(), + ) + .vertical_scrollbar(&self.scroll) + }), ) .child( h_flex() @@ -414,8 +395,8 @@ impl Diffy { let existing = self.project_paths(); let owner = cx.entity().downgrade(); let snapshot = self.import.snapshot.clone(); - let automatic = self.import.preferences.automatic; let excluded = self.import.preferences.excluded.clone(); + let excluded_repositories = self.import.preferences.excluded_repositories.clone(); let picker = cx.new(|cx| { let mut picker = Picker { owner, @@ -424,10 +405,9 @@ impl Diffy { existing: BTreeSet::new(), selected: BTreeSet::new(), seen: excluded, - automatic, + excluded_repositories, loading: true, scroll: VirtualListScrollHandle::new(), - rows: Arc::new(Vec::new()), sizes: Rc::new(Vec::new()), }; picker.install(snapshot, existing, true); @@ -447,6 +427,7 @@ impl Diffy { .content(move |content, _, _| content.child(picker.clone())) .on_close(move |_, window, cx| { let _ = owner.update(cx, |this, cx| { + this.cancel_import_scan(); this.import.picker = None; this.import.preferences.onboarding_completed = true; this.persist(cx); @@ -456,32 +437,72 @@ impl Diffy { }) }); self.request_import_scan(cx); - if self.import.task.is_none() { - self.start_discovery(window, cx); - } } - pub(super) fn request_import_scan(&mut self, cx: &mut Context) { - self.import.requested = true; + fn cancel_import_scan(&mut self) { + self.import.cancelled.store(true, Ordering::Relaxed); + self.import.generation += 1; + self.import.task = None; + self.import.loading = false; + } + fn request_import_scan(&mut self, cx: &mut Context) { + if self.import.loading || self.import.picker.is_none() { + return; + } self.import.loading = true; + self.import.generation += 1; + let generation = self.import.generation; + let cancelled = Arc::new(AtomicBool::new(false)); + self.import.cancelled = cancelled.clone(); if let Some(picker) = &self.import.picker { picker.update(cx, |p, cx| { p.loading = true; cx.notify(); }); } + let mut scanner = self.import.scanner.take().unwrap_or_default(); + let locations = discovery::Locations::from_env(); + #[cfg(feature = "ui-tests")] + let fixture = self.import.fixture.clone(); + self.import.task = Some(cx.spawn(async move |owner, cx| { + let (scanner, snapshot) = cx + .background_spawn(async move { + #[cfg(feature = "ui-tests")] + if let Some(snapshot) = fixture { + return (scanner, snapshot); + } + let snapshot = scanner.scan(&locations, [true; 3], &cancelled); + (scanner, snapshot) + }) + .await; + let _ = owner.update(cx, |this, cx| { + this.complete_import_scan(generation, scanner, snapshot, cx); + }); + })); cx.notify(); } + fn complete_import_scan( + &mut self, + generation: u64, + scanner: discovery::Scanner, + snapshot: Snapshot, + cx: &mut Context, + ) { + if generation != self.import.generation || self.import.picker.is_none() { + return; + } + self.import.scanner = Some(scanner); + self.import.task = None; + self.receive_discovery(snapshot, cx); + } pub(super) fn finish_import( &mut self, apply: bool, selected: BTreeSet, - automatic: [bool; 3], snapshot: &Snapshot, window: &mut Window, cx: &mut Context, ) { if apply { - self.import.preferences.automatic = automatic; for candidate in &snapshot.candidates { if selected.contains(&candidate.path) { self.import.preferences.excluded.remove(&candidate.path); @@ -497,6 +518,7 @@ impl Diffy { self.import_projects(selected, cx); } self.import.preferences.onboarding_completed = true; + self.cancel_import_scan(); self.import.picker = None; self.persist(cx); window.close_dialog(cx); @@ -522,29 +544,11 @@ impl Diffy { self.restoring = was_restoring; self.persist(cx); } - pub(super) fn receive_discovery(&mut self, snapshot: Snapshot, cx: &mut Context) { + fn receive_discovery(&mut self, mut snapshot: Snapshot, cx: &mut Context) { + snapshot.sort(); let snapshot = Arc::new(snapshot); self.import.loading = false; - if *self.import.snapshot == *snapshot { - if let Some(picker) = &self.import.picker { - picker.update(cx, |p, cx| { - p.loading = false; - cx.notify(); - }); - } - return; - } self.import.snapshot = snapshot.clone(); - // While choosing, defer auto-add so existing checkbox rows do not change underneath the user. - if self.import.picker.is_none() { - let paths = snapshot - .candidates - .iter() - .filter(|c| self.import.preferences.should_add(c)) - .map(|c| c.path.clone()) - .collect(); - self.import_projects(paths, cx); - } if let Some(picker) = &self.import.picker { let existing = self.project_paths(); picker.update(cx, |p, cx| { @@ -554,64 +558,6 @@ impl Diffy { } cx.notify(); } - pub(super) fn start_discovery(&mut self, window: &mut Window, cx: &mut Context) { - if self.import.task.is_some() { - return; - } - let cancelled = self.import.cancelled.clone(); - let locations = discovery::Locations::from_env(); - self.import.task = Some(cx.spawn_in(window, async move |owner, cx| { - let mut scanner = discovery::Scanner::default(); - let mut watch = discovery::MetadataWatch::default(); - let mut next = Instant::now(); - let mut dirty_since = None; - loop { - let Ok((enabled, requested, picker)) = owner.update(cx, |this, _| { - let requested = std::mem::take(&mut this.import.requested); - ( - this.import.preferences.automatic, - requested, - this.import.picker.is_some(), - ) - }) else { - break; - }; - if watch.dirty.swap(false, Ordering::Relaxed) && dirty_since.is_none() { - dirty_since = Some(Instant::now()); - } - let dirty = dirty_since - .is_some_and(|time: Instant| time.elapsed() >= Duration::from_secs(2)); - if requested || (enabled.iter().any(|v| *v) && (dirty || Instant::now() >= next)) { - dirty_since = None; - let scan_sources = if picker { [true; 3] } else { enabled }; - let locations = locations.clone(); - let cancelled = cancelled.clone(); - let result = cx - .background_executor() - .spawn(async move { - watch.configure(&locations, enabled); - let snapshot = scanner.scan(&locations, scan_sources, &cancelled); - (scanner, watch, snapshot) - }) - .await; - scanner = result.0; - watch = result.1; - let _ = owner.update(cx, |this, cx| this.receive_discovery(result.2, cx)); - let herdr_live = owner - .read_with(cx, |this, _| this.import.snapshot.herdr_live) - .unwrap_or(false); - next = Instant::now() - + Duration::from_secs(if enabled[0] && herdr_live { 5 } else { 30 }); - } else { - // Drop disabled watchers promptly, even when there is no scan to run. - watch.configure(&locations, enabled); - } - cx.background_executor() - .timer(Duration::from_millis(500)) - .await; - } - })); - } } #[cfg(feature = "ui-tests")] @@ -622,9 +568,9 @@ impl Diffy { window: &mut Window, cx: &mut Context, ) { + self.import.fixture = Some(snapshot.clone()); self.open_import(window, cx); - self.import.task = None; - self.import.requested = false; + self.cancel_import_scan(); self.receive_discovery(snapshot, cx); } pub fn import_selected_count(&self, cx: &gpui_kit::App) -> usize { @@ -633,11 +579,33 @@ impl Diffy { .as_ref() .map_or(0, |picker| picker.read(cx).selected.len()) } - pub fn import_automatic(&self, cx: &gpui_kit::App) -> [bool; 3] { + pub fn set_import_fixture(&mut self, snapshot: Snapshot) { + self.import.fixture = Some(snapshot); + } + pub fn import_loading(&self, cx: &gpui_kit::App) -> bool { self.import .picker .as_ref() - .map_or(self.import.preferences.automatic, |p| p.read(cx).automatic) + .is_some_and(|p| p.read(cx).loading) + } + pub fn import_paths(&self) -> Vec { + self.import + .snapshot + .candidates + .iter() + .map(|c| c.path.clone()) + .collect() + } + pub fn import_generation(&self) -> u64 { + self.import.generation + } + pub fn complete_import_fixture( + &mut self, + generation: u64, + snapshot: Snapshot, + cx: &mut Context, + ) { + self.complete_import_scan(generation, discovery::Scanner::default(), snapshot, cx); } pub fn import_is_open(&self) -> bool { self.import.picker.is_some() diff --git a/src/app/mod.rs b/src/app/mod.rs index bee6af7..10dbf88 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -32,6 +32,7 @@ use gpui_kit::{Action, AsKeystroke as _, KeyBinding, KeyContext, Pixels}; use std::path::PathBuf; mod files; +mod github_review; mod import; mod notice; mod palette; @@ -189,6 +190,7 @@ pub fn init(cx: &mut App) { pub struct Diffy { window_handle: gpui_kit::AnyWindowHandle, import: import::Runtime, + github: github_review::Runtime, projects: Vec, repositories: Vec, topology_sequence: u64, @@ -268,15 +270,19 @@ impl Render for Diffy { .child(hint(&[&PreviousProject, &NextProject], "Projects")) .child(hint(&[&PreviousFile, &NextFile], "Files")) .child(hint(&[&FocusFileSearch], "Find")) - .child(hint(&[&ToggleGroup], "View")) - .child(hint( - &[&StageFile], - if self.selected_group() == ChangeGroup::Staged { - "Unstage" - } else { - "Stage" - }, - )); + .when(!self.is_pr(), |row| { + row.child(hint(&[&ToggleGroup], "View")) + }) + .when(!self.is_pr(), |row| { + row.child(hint( + &[&StageFile], + if self.selected_group() == ChangeGroup::Staged { + "Unstage" + } else { + "Stage" + }, + )) + }); let breadcrumb = p.map(|p| { let path = p.path.as_ref().map_or_else( || p.name.to_string(), @@ -298,7 +304,18 @@ impl Render for Diffy { .text_color(cx.theme().muted_foreground) .child(div().min_w_0().truncate().child(path)) .child("/") - .child(div().flex_none().child(p.branch.clone())) + .child(div().flex_none().child(if self.is_pr() { + self.pr_state() + .and_then(|s| { + s.repository.as_ref().map(|r| { + s.selected + .map_or_else(|| r.label(), |n| format!("{} #{}", r.label(), n)) + }) + }) + .unwrap_or_else(|| "Pull requests".into()) + } else { + p.branch.to_string() + })) }); v_flex() .id("diffy") diff --git a/src/app/palette.rs b/src/app/palette.rs index 2ea6c55..3b4ff26 100644 --- a/src/app/palette.rs +++ b/src/app/palette.rs @@ -92,12 +92,13 @@ impl Diffy { } pub(super) fn render_palette(&self, cx: &mut Context) -> AnyElement { let p = self.projects.get(self.active); - let has_files = p.is_some_and(|p| !p.view().files.is_empty()); - let mutable = p.is_some_and(|p| { - p.path.is_some() - && !p.loading - && p.view().selected_entry().is_some_and(|e| e.stageable()) - }); + let has_files = self.active_view().is_some_and(|v| !v.files.is_empty()); + let mutable = !self.is_pr() + && p.is_some_and(|p| { + p.path.is_some() + && !p.loading + && p.view().selected_entry().is_some_and(|e| e.stageable()) + }); let projects = CommandGroup::new() .label("Projects") @@ -135,7 +136,11 @@ impl Diffy { ), item("Install terminal command", Box::new(InstallCli), false), item( - "Refresh repository", + if self.is_pr() { + "Refresh pull requests" + } else { + "Refresh repository" + }, Box::new(Refresh), !p.is_some_and(|p| p.path.is_some() && !p.loading), ), @@ -150,14 +155,30 @@ impl Diffy { self.project_count() < 2, ), item( - "Next worktree", + if self.is_pr() { + "Next pull request" + } else { + "Next worktree" + }, Box::new(NextWorktree), - self.worktree_indices(self.active).len() < 2, + if self.is_pr() { + self.pr_state().is_none_or(|s| s.prs.len() < 2) + } else { + self.worktree_indices(self.active).len() < 2 + }, ), item( - "Previous worktree", + if self.is_pr() { + "Previous pull request" + } else { + "Previous worktree" + }, Box::new(PreviousWorktree), - self.worktree_indices(self.active).len() < 2, + if self.is_pr() { + self.pr_state().is_none_or(|s| s.prs.len() < 2) + } else { + self.worktree_indices(self.active).len() < 2 + }, ), item("Next file", Box::new(NextFile), !has_files), item("Previous file", Box::new(PreviousFile), !has_files), @@ -166,7 +187,7 @@ impl Diffy { item( "Switch Staged / Changes", Box::new(ToggleGroup), - p.is_none(), + p.is_none() || self.is_pr(), ), item("Unified diff", Box::new(UnifiedMode), p.is_none()), item("Split diff", Box::new(SplitMode), p.is_none()), @@ -186,9 +207,12 @@ impl Diffy { "Unstage all files" }, Box::new(StageAll), - !p.is_some_and(|p| { - p.path.is_some() && !p.loading && p.view().files.iter().any(|e| e.stageable()) - }), + self.is_pr() + || !p.is_some_and(|p| { + p.path.is_some() + && !p.loading + && p.view().files.iter().any(|e| e.stageable()) + }), ), item("Focus diff", Box::new(FocusDiff), !has_files), item("Find file", Box::new(FocusFileSearch), p.is_none()), @@ -200,12 +224,14 @@ impl Diffy { item( "Copy absolute path", Box::new(super::CopyAbsolutePath), - !p.is_some_and(|p| p.path.is_some() && p.view().selected_entry().is_some()), + self.is_pr() + || !p.is_some_and(|p| p.path.is_some() && p.view().selected_entry().is_some()), ), item( crate::project_apps::reveal_label(), Box::new(super::RevealFile), - !p.is_some_and(|p| p.path.is_some() && p.view().selected_entry().is_some()), + self.is_pr() + || !p.is_some_and(|p| p.path.is_some() && p.view().selected_entry().is_some()), ), item( "Increase diff font size", diff --git a/src/app/reader.rs b/src/app/reader.rs index f7eefb0..df060f6 100644 --- a/src/app/reader.rs +++ b/src/app/reader.rs @@ -14,6 +14,9 @@ impl Diffy { absolute: bool, cx: &mut Context, ) { + if absolute && self.is_pr() { + return; + } let Some(p) = self.projects.iter().find(|p| p.id == id) else { return; }; @@ -28,6 +31,9 @@ impl Diffy { cx.write_to_clipboard(ClipboardItem::new_string(text)); } pub(super) fn reveal_file_path(&self, id: u64, path: &str, cx: &mut Context) { + if self.is_pr() { + return; + } if let Some(p) = self.projects.iter().find(|p| p.id == id) && let Some(root) = &p.path && let Some(path) = reveal_target(&root.join(path)) @@ -36,10 +42,13 @@ impl Diffy { } } pub(super) fn copy_path(&self, absolute: bool, cx: &mut Context) { + if absolute && self.is_pr() { + return; + } let Some(p) = self.projects.get(self.active) else { return; }; - let Some(file) = p.view().selected_entry() else { + let Some(file) = self.active_view().and_then(|v| v.selected_entry()) else { return; }; let text = if absolute { @@ -53,10 +62,13 @@ impl Diffy { cx.write_to_clipboard(ClipboardItem::new_string(text)); } pub(super) fn reveal_file(&self, cx: &mut Context) { + if self.is_pr() { + return; + } let Some(p) = self.projects.get(self.active) else { return; }; - let Some(file) = p.view().selected_entry() else { + let Some(file) = self.active_view().and_then(|v| v.selected_entry()) else { return; }; if let Some(root) = &p.path @@ -88,9 +100,8 @@ impl Diffy { self.preview_requests } pub fn selected_preview_path(&self) -> Option<&gpui_kit::SharedString> { - self.projects - .get(self.active) - .and_then(|p| p.view().selected_file()) + self.active_view() + .and_then(|v| v.selected_file()) .map(|f| f.path()) } pub fn preferences_flush(&self) -> Option { @@ -99,9 +110,7 @@ impl Diffy { .map(crate::state::Writer::flush_handle) } pub fn selected_top_line(&self, cx: &gpui_kit::App) -> Option { - self.projects - .get(self.active)? - .view() + self.active_view()? .diff .read(cx) .reading_position() diff --git a/src/app/review.rs b/src/app/review.rs index b58fcfb..193e637 100644 --- a/src/app/review.rs +++ b/src/app/review.rs @@ -142,6 +142,12 @@ impl ReviewView { } } + pub(super) fn clear(&mut self, cx: &mut Context) { + self.demos.clear(); + self.selected = None; + self.reset_selection(cx); + self.install_metadata(Vec::new(), 0, cx); + } pub(super) fn loaded(&self) -> bool { self.loaded } diff --git a/src/app/session.rs b/src/app/session.rs index 77f0976..7eae80d 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -133,6 +133,7 @@ impl Diffy { let mut this = Self { window_handle: window.window_handle(), import: Default::default(), + github: Default::default(), projects: Vec::new(), repositories: Vec::new(), topology_sequence: 0, @@ -324,7 +325,6 @@ impl Diffy { })); if !demo && this.state_path.is_some() && !this.state_restore_failed { cx.defer_in(window, |this, window, cx| { - this.start_discovery(window, cx); if !this.import.preferences.onboarding_completed { this.open_import(window, cx); } @@ -561,6 +561,8 @@ impl Diffy { if !self.projects[ix].available { return; } + self.stop_file_repeat(); + self.leave_github(cx); self.active = ix; self.remember_worktree(); self.reveal_worktree(); @@ -588,6 +590,7 @@ impl Diffy { .preferences .excluded_repositories .insert(common.clone()); + self.github.states.remove(&common); self.repositories.retain(|r| r.common_dir != common); } for p in &self.projects { @@ -629,6 +632,9 @@ impl Diffy { window: &mut Window, cx: &mut Context, ) { + if self.is_pr() { + return; + } let Some(p) = self.projects.get_mut(self.active) else { return; }; diff --git a/src/app/sync.rs b/src/app/sync.rs index e44c758..b34077b 100644 --- a/src/app/sync.rs +++ b/src/app/sync.rs @@ -14,7 +14,7 @@ impl Diffy { mutation: Option<(Vec, bool)>, cx: &mut Context, ) { - if mutation.is_some() && self.updates.blocks_mutations() { + if mutation.is_some() && (self.updates.blocks_mutations() || self.is_pr()) { return; } if mutation.is_some() @@ -170,6 +170,19 @@ impl Diffy { } } pub(super) fn refresh(&mut self, cx: &mut Context) { + if self.is_pr() { + self.list_prs(false, cx); + return; + } + if let Some(key) = self.github_key() + && self + .github + .states + .get(&key) + .is_some_and(|s| !s.resolving && s.remotes.is_empty()) + { + self.github.states.remove(&key); + } if let Some(id) = self.active_project_id() { self.discover_worktrees(id, true, cx); } @@ -256,7 +269,7 @@ impl Diffy { })); } pub(super) fn stage_file(&mut self, cx: &mut Context) { - if self.updates.blocks_mutations() { + if self.updates.blocks_mutations() || self.is_pr() { return; } let Some(p) = self.projects.get(self.active) else { @@ -270,7 +283,7 @@ impl Diffy { } } pub(super) fn stage_all(&mut self, cx: &mut Context) { - if self.updates.blocks_mutations() { + if self.updates.blocks_mutations() || self.is_pr() { return; } let Some(p) = self.projects.get(self.active) else { @@ -299,6 +312,9 @@ impl Diffy { path: &SharedString, cx: &mut Context, ) { + if self.is_pr() { + return; + } let Some(ix) = self.projects.iter().position(|p| p.id == id) else { return; }; @@ -347,6 +363,16 @@ impl Diffy { } } pub(super) fn ensure_preview(&mut self, cx: &mut Context) { + let active_key = self.github_key(); + for (key, state) in &mut self.github.states { + if Some(key) != active_key.as_ref() { + state.cancel(cx); + } + } + if self.is_pr() { + self.ensure_pr_preview(cx); + return; + } for (index, project) in self.projects.iter_mut().enumerate() { if project.path.is_none() { continue; @@ -448,9 +474,8 @@ impl Diffy { .is_some_and(|p| p.watcher.is_some()) } pub fn selected_additions(&self) -> usize { - self.projects - .get(self.active) - .and_then(|p| p.view().selected_file()) + self.active_view() + .and_then(|v| v.selected_file()) .map_or(0, |f| f.additions()) } } diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 8339a0f..7bc88c9 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -8,6 +8,7 @@ use gpui_kit::InteractiveElement as _; use gpui_kit::IntoElement; use gpui_kit::MouseButton; use gpui_kit::ParentElement as _; +use gpui_kit::StatefulInteractiveElement as _; use gpui_kit::Styled as _; use gpui_kit::Window; use gpui_kit::base::TestSupportExt as _; @@ -25,6 +26,7 @@ use gpui_kit::component::diff::DiffMode; use gpui_kit::component::h_flex; use gpui_kit::component::kbd::Kbd; use gpui_kit::component::menu::ContextMenuExt as _; +use gpui_kit::component::spinner::Spinner; use gpui_kit::component::toolbar::Toolbar; use gpui_kit::component::v_flex; use gpui_kit::div; @@ -65,7 +67,8 @@ impl Diffy { let Some(project) = self.projects.get(self.active) else { return false; }; - project.view().files.is_empty() + !self.is_pr() + && project.view().files.is_empty() && !project.loading && self.settings.is_none() && !self.palette_open @@ -129,7 +132,9 @@ impl Diffy { ) .into_any_element(); }; - let view = p.view(); + let remote = self.is_pr(); + let pr_loading = self.pr_state().and_then(|s| s.loading_message()); + let view = self.active_view().unwrap(); let mode = view.diff.read(cx).mode(); let empty = view.files.is_empty(); let scope_buttons = [ChangeGroup::Staged, ChangeGroup::Changes].map(|group| { @@ -165,7 +170,62 @@ impl Diffy { .flex_none() .px_3() .gap_1() - .contents(scope_buttons.map(IntoElement::into_any_element)) + .contents(if remote { + Vec::new() + } else { + scope_buttons + .into_iter() + .map(IntoElement::into_any_element) + .collect::>() + }) + .when(remote, |toolbar| { + toolbar + .content(if let Some(message) = pr_loading { + h_flex() + .id("pr-loading-status") + .test_support() + .role(gpui_kit::Role::Status) + .aria_label(message) + .min_w_0() + .max_w(px(320.)) + .gap_2() + .text_size(px(12.)) + .child(Spinner::new().xsmall()) + .child(div().truncate().child(message)) + .into_any_element() + } else { + div() + .max_w(px(320.)) + .truncate() + .text_size(px(12.)) + .child( + self.pr_state() + .and_then(|s| { + s.prs.iter().find(|p| Some(p.number) == s.selected) + }) + .map(|p| { + format!( + "#{} · {} → {}{}", + p.number, + p.head, + p.base, + if p.draft { " · Draft" } else { "" } + ) + }) + .unwrap_or_else(|| "Pull requests".into()), + ) + .into_any_element() + }) + .content( + Button::new("open-pr-github") + .ghost() + .small() + .label("Open in GitHub") + .on_click( + cx.listener(|this, _, _, cx| this.open_pr_browser(cx)), + ), + ) + }) .content( div() .id("toolbar-drag-area") @@ -227,7 +287,14 @@ impl Diffy { .ghost() .small() .icon(IconName::RotateCw) - .disabled(p.path.is_none() || p.loading) + .loading(pr_loading.is_some()) + .disabled(if remote { + self.pr_state().is_none_or(|s| { + s.listing || s.loading || s.repository.is_none() + }) + } else { + p.path.is_none() || p.loading + }) .tooltip(super::shortcut_label(&super::Refresh, window, "Refresh")) .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))), ), @@ -251,7 +318,7 @@ impl Diffy { .context_menu({ let id = p.id; let path = view.selected.clone().unwrap_or_default(); - let local = p.path.is_some(); + let local = p.path.is_some() && !remote; move |menu, _, _| { super::reader::file_menu(menu, id, path.as_str(), local) } @@ -272,71 +339,80 @@ impl Diffy { .child(format!("−{}", f.deletions())), ) })) - .child( - Button::new("stage-file") - .ghost() - .xsmall() - .icon(if p.group == ChangeGroup::Changes { - IconName::Plus - } else { - IconName::Minus - }) - .accessibility_label(if p.group == ChangeGroup::Changes { - "Stage file" - } else { - "Unstage file" - }) - .when( - !view - .diff - .read(cx) - .focus_handle(cx) - .contains_focused(window, cx), - |button| { - button - .invisible() - .group_hover("preview-header", |button| button.visible()) - }, - ) - .disabled( - empty - || p.path.is_none() - || p.loading - || view.selected_entry().is_some_and(|e| !e.stageable()), - ) - .tooltip(if p.path.is_none() { - "Open a repository to stage files".to_owned() - } else { - super::shortcut_label( - &super::StageFile, - window, - "Stage / unstage file", + .when(!remote, |row| { + row.child( + Button::new("stage-file") + .ghost() + .xsmall() + .icon(if p.group == ChangeGroup::Changes { + IconName::Plus + } else { + IconName::Minus + }) + .accessibility_label(if p.group == ChangeGroup::Changes { + "Stage file" + } else { + "Unstage file" + }) + .when( + !view + .diff + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx), + |button| { + button.invisible().group_hover("preview-header", |button| { + button.visible() + }) + }, ) - }) - .on_click(cx.listener(|this, _, _, cx| this.stage_file(cx))), - ), + .disabled( + empty + || p.path.is_none() + || p.loading + || view.selected_entry().is_some_and(|e| !e.stageable()), + ) + .tooltip(if p.path.is_none() { + "Open a repository to stage files".to_owned() + } else { + super::shortcut_label( + &super::StageFile, + window, + "Stage / unstage file", + ) + }) + .on_click(cx.listener(|this, _, _, cx| this.stage_file(cx))), + ) + }), + ) + .children( + (if remote { + self.pr_state().and_then(|s| s.error.as_ref()) + } else { + p.error.as_ref() + }) + .map(|error| { + h_flex() + .px_4() + .py_2() + .gap_2() + .child( + div() + .flex_1() + .text_size(px(13.)) + .text_color(cx.theme().danger) + .child(error.clone()), + ) + .child( + Button::new("retry") + .ghost() + .small() + .label("Retry") + .disabled(p.loading) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))), + ) + }), ) - .children(p.error.as_ref().map(|error| { - h_flex() - .px_4() - .py_2() - .gap_2() - .child( - div() - .flex_1() - .text_size(px(13.)) - .text_color(cx.theme().danger) - .child(error.clone()), - ) - .child( - Button::new("retry") - .ghost() - .small() - .label("Retry") - .disabled(p.loading) - .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))), - ) - })) .children(view.preview_error.as_ref().map(|error| { h_flex() .px_3() @@ -360,16 +436,22 @@ impl Diffy { .child("Unmerged file · resolve and stage outside Diffy") })) .child(if !empty && view.preview.is_none() { - div() + v_flex() .flex_1() - .flex() .items_center() .justify_center() .text_color(cx.theme().muted_foreground) + .gap_2() + .children(pr_loading.map(|_| { + div() + .id("pr-loading-empty") + .test_support() + .child(Spinner::new().small()) + })) .child(if view.preview_error.is_some() && !view.preview_loading { "Preview unavailable" } else { - "Loading preview…" + pr_loading.unwrap_or("Loading preview…") }) .into_any_element() } else if empty { @@ -393,7 +475,16 @@ impl Diffy { .items_center() .justify_center() .text_color(cx.theme().muted_foreground) - .child(if p.loading { + .gap_2() + .children(pr_loading.map(|_| { + div() + .id("pr-loading-empty") + .test_support() + .child(Spinner::new().small()) + })) + .child(if remote { + pr_loading.unwrap_or_else(|| self.pr_empty_message()) + } else if p.loading { "Reading changes…" } else if p.group == ChangeGroup::Staged { "No staged files" @@ -425,6 +516,13 @@ impl Diffy { .into_any_element() } pub(super) fn set_mode(&mut self, mode: DiffMode, cx: &mut Context) { + if self.is_pr() { + if let Some(view) = self.active_view() { + view.diff.update(cx, |d, cx| d.set_mode(mode, cx)); + } + cx.notify(); + return; + } let Some(p) = self.projects.get(self.active) else { return; }; @@ -434,10 +532,10 @@ impl Diffy { self.persist(cx); } pub(super) fn change(&mut self, next: bool, cx: &mut Context) { - let Some(p) = self.projects.get(self.active) else { + let Some(view) = self.active_view() else { return; }; - p.view().diff.update(cx, |d, cx| { + view.diff.update(cx, |d, cx| { if next { d.next_change(cx) } else { @@ -446,13 +544,12 @@ impl Diffy { }); } pub(super) fn focus_diff(&self, window: &mut Window, cx: &mut App) { - if let Some(p) = self.projects.get(self.active) { - p.view().diff.read(cx).focus_handle(cx).focus(window, cx); + if let Some(view) = self.active_view() { + view.diff.read(cx).focus_handle(cx).focus(window, cx); } } pub fn selected_mode(&self, cx: &App) -> DiffMode { - self.projects - .get(self.active) - .map_or(DiffMode::Unified, |p| p.view().diff.read(cx).mode()) + self.active_view() + .map_or(DiffMode::Unified, |view| view.diff.read(cx).mode()) } } diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index 1e3b663..bcbadf3 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -2,7 +2,9 @@ use super::{Diffy, session::Project}; use crate::{repository, state, watch}; use gpui_kit::component::{ - InteractiveElementExt as _, Sizable as _, + Disableable as _, InteractiveElementExt as _, Selectable as _, Sizable as _, StyledExt as _, + button::{Button, ButtonVariants as _}, + h_flex, tab::{Tab, TabBar}, tooltip::Tooltip, }; @@ -193,6 +195,10 @@ impl Diffy { window: &mut Window, cx: &mut Context, ) { + if self.is_pr() { + self.move_pr(direction, cx); + return; + } let indices = self .worktree_indices(self.active) .into_iter() @@ -234,8 +240,8 @@ impl Diffy { } for (position, &ix) in indices.iter().enumerate() { let p = &self.projects[ix]; - let label = if position == 0 { - name.to_string() + let label = if position == 0 && p.branch.as_ref() != "…" { + p.branch.to_string() } else { self.worktree_label(ix) }; @@ -249,51 +255,85 @@ impl Diffy { if p.locked_worktree { " · locked" } else { "" }, if p.available { "" } else { " · unavailable" } ); - let mut tab = - Tab::new() - .label(label) - .disabled(!p.available) - .tooltip(move |window, cx| { - Tooltip::new(tooltip.clone()) - .max_w(px(640.)) - .whitespace_normal() - .build(window, cx) - }); - if position == 0 { - let content = match icon { - Some(state::ProjectIcon::Image(path)) => img(path.clone()) - .size_full() - .object_fit(ObjectFit::Cover) - .into_any_element(), - Some(state::ProjectIcon::Emoji(emoji)) => { - div().child(emoji.clone()).into_any_element() - } - None => div() - .child( - name.chars() - .next() - .unwrap_or('D') - .to_uppercase() - .to_string(), - ) - .into_any_element(), - }; - tab = tab.prefix( - div() - .size(px(18.)) - .rounded(px(5.)) - .overflow_hidden() - .bg(rgb(color)) - .text_color(rgb(0x141618)) - .text_size(px(11.)) - .flex() - .items_center() - .justify_center() - .child(content), - ); - } + let tab = Tab::new() + .label(label) + .disabled(!p.available) + .tooltip(move |window, cx| { + Tooltip::new(tooltip.clone()) + .max_w(px(640.)) + .whitespace_normal() + .build(window, cx) + }); bar = bar.child(tab); } + let content = match icon { + Some(state::ProjectIcon::Image(path)) => img(path.clone()) + .size_full() + .object_fit(ObjectFit::Cover) + .into_any_element(), + Some(state::ProjectIcon::Emoji(emoji)) => div().child(emoji.clone()).into_any_element(), + None => div() + .child( + name.chars() + .next() + .unwrap_or('D') + .to_uppercase() + .to_string(), + ) + .into_any_element(), + }; + let identity = h_flex() + .gap_2() + .px_2() + .max_w(px(180.)) + .min_w_0() + .flex_shrink_0() + .child( + div() + .size(px(18.)) + .flex_shrink_0() + .rounded(px(5.)) + .overflow_hidden() + .bg(rgb(color)) + .text_color(rgb(0x141618)) + .text_size(px(11.)) + .flex() + .items_center() + .justify_center() + .child(content), + ) + .child(div().truncate().font_medium().child(name.clone())); + let remote = self.is_pr(); + let ready = self + .pr_state() + .is_some_and(|s| !s.resolving && !s.remotes.is_empty()); + let source = h_flex() + .flex_shrink_0() + .gap_1() + .child( + Button::new("source-worktrees") + .ghost() + .xsmall() + .label("Worktrees") + .selected(!remote) + .toggled(!remote) + .on_click(cx.listener(|this, _, w, cx| this.set_review_source(false, w, cx))), + ) + .child( + Button::new("source-prs") + .ghost() + .xsmall() + .label("Pull requests") + .selected(remote) + .toggled(remote) + .disabled(!ready) + .tooltip(if ready { + "Review open GitHub pull requests" + } else { + "No GitHub remote available · Refresh after adding one" + }) + .on_click(cx.listener(|this, _, w, cx| this.set_review_source(true, w, cx))), + ); // Wait for pointer movement before handing the gesture to macOS, so // ordinary clicks still reach the tabs and their overflow menu. let dragging = Rc::new(Cell::new(false)); @@ -316,11 +356,25 @@ impl Diffy { }) .on_double_click(|_, window, _| window.titlebar_double_click()) .child( - bar.on_click(cx.listener(move |this, selected: &usize, window, cx| { - if let Some(&ix) = indices.get(*selected) { - this.switch_project(ix, window, cx); - } - })), + h_flex() + .w_full() + .min_w_0() + .h_full() + .gap_2() + .child(identity) + .child(source) + .child(if remote { + self.render_pr_tabs(cx) + } else { + bar.min_w_0() + .flex_1() + .on_click(cx.listener(move |this, selected: &usize, window, cx| { + if let Some(&ix) = indices.get(*selected) { + this.switch_project(ix, window, cx); + } + })) + .into_any_element() + }), ) .into_any_element(), ) diff --git a/src/discovery.rs b/src/discovery.rs index 3c2ef2f..7fcc998 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -6,10 +6,7 @@ use std::{ fs::{self, File}, io::{self, BufReader, Read, Write}, path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, + sync::atomic::{AtomicBool, Ordering}, time::{Duration, Instant, SystemTime}, }; @@ -38,33 +35,18 @@ impl Source { #[serde(default)] pub struct Preferences { pub onboarding_completed: bool, - pub automatic: [bool; 3], pub excluded: BTreeSet, pub excluded_repositories: BTreeSet, } -impl Preferences { - pub fn should_add(&self, candidate: &Candidate) -> bool { - candidate.error.is_none() - && !self.excluded.contains(&candidate.path) - && !candidate - .repository - .as_ref() - .is_some_and(|r| self.excluded_repositories.contains(r)) - && candidate.sources.iter().any(|s| self.automatic[s.index()]) - } -} #[derive(Debug, Clone, PartialEq)] pub struct Candidate { pub path: PathBuf, pub repository: Option, pub sources: BTreeSet, pub error: Option, - pub created_at: Option, + pub updated_at: Option, } impl Candidate { - pub fn section(&self) -> Source { - *self.sources.first().expect("candidate has a source") - } pub fn name(&self) -> String { self.path .file_name() @@ -77,7 +59,17 @@ impl Candidate { pub struct Snapshot { pub candidates: Vec, pub errors: [Option; 3], - pub herdr_live: bool, +} +impl Snapshot { + pub(crate) fn sort(&mut self) { + self.candidates.sort_by_cached_key(|candidate| { + ( + std::cmp::Reverse(candidate.updated_at), + candidate.name().to_lowercase(), + candidate.path.clone(), + ) + }); + } } #[derive(Clone)] pub struct Locations { @@ -132,33 +124,44 @@ impl Locations { errors, } } - pub fn watch_paths(&self, enabled: [bool; 3]) -> Vec { - let mut paths = Vec::new(); - if enabled[0] { - paths.extend(self.herdr.iter().cloned()); - } - if enabled[1] - && let Some(p) = &self.codex - { - paths.extend([p.join("sessions"), p.join("archived_sessions")]); - } - if enabled[2] - && let Some(p) = &self.claude - { - paths.push(p.join("projects")); +} + +type Stamp = (u64, Option); + +struct TemporaryRoots(Vec); +impl Default for TemporaryRoots { + fn default() -> Self { + let roots = std::iter::once(std::env::temp_dir()); + #[cfg(unix)] + let roots = roots.chain(["/tmp", "/var/tmp"].map(PathBuf::from)); + Self::new(roots) + } +} +impl TemporaryRoots { + fn new(roots: impl IntoIterator) -> Self { + let mut paths = BTreeSet::new(); + for root in roots { + if root.is_absolute() { + if let Ok(canonical) = fs::canonicalize(&root) { + paths.insert(canonical); + } + paths.insert(root); + } } - paths + Self(paths.into_iter().collect()) + } + fn contains(&self, checkout: &Path) -> bool { + self.0.iter().any(|root| checkout.starts_with(root)) } } -type Stamp = (u64, Option); #[derive(Default)] pub struct Scanner { metadata: HashMap, ()>)>, roots: HashMap)>, pub metadata_reads: usize, - created: HashMap, - herdr_live: bool, + updated: HashMap, + temporary_roots: TemporaryRoots, } impl Scanner { fn cached( @@ -171,26 +174,31 @@ impl Scanner { return Err(()); } let stamp = (meta.len(), meta.modified().ok()); - if let Some((old, result)) = self.metadata.get(path) + let result = if let Some((old, result)) = self.metadata.get(path) && *old == stamp { - return result.clone(); + result.clone() + } else { + self.metadata_reads += 1; + let result = read(); + self.metadata + .insert(path.to_owned(), (stamp, result.clone())); + result + }; + if let Ok(paths) = &result { + self.record_activity(paths, stamp.1); } - self.metadata_reads += 1; - let result = read(); - if let Ok(paths) = &result - && let Some(created) = meta.created().ok().or_else(|| meta.modified().ok()) - { + result + } + fn record_activity(&mut self, paths: &[PathBuf], updated: Option) { + if let Some(updated) = updated { for path in paths { - self.created + self.updated .entry(path.clone()) - .and_modify(|time| *time = (*time).max(created)) - .or_insert(created); + .and_modify(|time| *time = (*time).max(updated)) + .or_insert(updated); } } - self.metadata - .insert(path.to_owned(), (stamp, result.clone())); - result } pub fn scan( &mut self, @@ -199,6 +207,9 @@ impl Scanner { cancelled: &AtomicBool, ) -> Snapshot { let _span = tracing::info_span!("project_discovery").entered(); + // Rebuild from the files visited this scan, including cache hits. Removed + // history must not leave an old maximum timestamp attached to a project. + self.updated.clear(); let mut raw = Vec::new(); let mut snapshot = Snapshot::default(); for source in Source::ALL { @@ -231,7 +242,7 @@ impl Scanner { if !path.is_absolute() { continue; } - let created_at = self.created.get(&path).copied(); + let updated_at = self.updated.get(&path).copied(); let path = fs::canonicalize(&path).unwrap_or(path); // Old agent history frequently points at deleted checkouts. Do not clutter the picker. if !path.is_dir() { @@ -261,42 +272,26 @@ impl Scanner { Ok(root) => (root, None), Err(_) => continue, }; - let created_at = created_at.or_else(|| { - fs::metadata(&path) - .ok() - .and_then(|m| m.created().ok().or_else(|| m.modified().ok())) - }); + // Filter the resolved checkout, including cache hits: agent histories + // also contain disposable evaluation repos and temporary worktrees. + if self.temporary_roots.contains(&path) { + continue; + } let candidate = merged.entry(path.clone()).or_insert_with(|| Candidate { repository: crate::repository::common_directory(&path).ok(), path, sources: BTreeSet::new(), error, - created_at, + updated_at, }); - candidate.created_at = candidate.created_at.max(created_at); + candidate.updated_at = candidate.updated_at.max(updated_at); candidate.sources.insert(source); } - snapshot.herdr_live = enabled[0] && self.herdr_live; snapshot.candidates = merged.into_values().collect(); - snapshot.candidates.sort_by_cached_key(|candidate| { - ( - candidate.section(), - std::cmp::Reverse(candidate.created_at), - candidate.name().to_lowercase(), - candidate.path.clone(), - ) - }); + snapshot.sort(); if !cancelled.load(Ordering::Relaxed) { self.metadata.retain(|path, _| path.is_file()); self.roots.retain(|path, _| path.is_dir()); - let referenced: BTreeSet<_> = self - .metadata - .values() - .filter_map(|(_, result)| result.as_ref().ok()) - .flatten() - .cloned() - .collect(); - self.created.retain(|path, _| referenced.contains(path)); } snapshot } @@ -360,7 +355,6 @@ impl Scanner { (paths.into_iter().collect(), failures) } fn herdr(&mut self, locations: &Locations, cancelled: &AtomicBool) -> (Vec, usize) { - self.herdr_live = false; let mut sessions: BTreeSet = locations.herdr.iter().cloned().collect(); for root in &locations.herdr { if let Ok(dirs) = fs::read_dir(root.join("sessions")) { @@ -404,7 +398,13 @@ impl Scanner { None }; if let Some(items) = result { - self.herdr_live = true; + // A successful poll is not new project activity. Use the saved + // session's timestamp when available, never the scan time. + let updated = saved + .as_ref() + .and_then(|p| fs::metadata(p).ok()) + .and_then(|m| m.modified().ok()); + self.record_activity(&items, updated); paths.extend(items); continue; } @@ -691,56 +691,6 @@ fn read_herdr_socket( } } -/// Watch only enabled source metadata. Missing roots are checked by periodic reconciliation. -pub struct MetadataWatch { - pub dirty: Arc, - watcher: Option, - paths: Vec, -} -impl Default for MetadataWatch { - fn default() -> Self { - Self { - dirty: Arc::new(AtomicBool::new(false)), - watcher: None, - paths: Vec::new(), - } - } -} -impl MetadataWatch { - pub fn configure(&mut self, locations: &Locations, enabled: [bool; 3]) { - use notify::Watcher; - let paths: Vec<_> = locations - .watch_paths(enabled) - .into_iter() - .filter(|p| p.is_dir()) - .collect(); - if self.paths == paths { - return; - } - self.watcher = None; - self.paths = paths.clone(); - let dirty = self.dirty.clone(); - if paths.is_empty() { - return; - } - if let Ok(mut watcher) = - notify::recommended_watcher(move |event: notify::Result| { - if event - .as_ref() - .map_or(true, |e| !matches!(e.kind, notify::EventKind::Access(_))) - { - dirty.store(true, Ordering::Relaxed); - } - }) - { - for path in &paths { - let _ = watcher.watch(path, notify::RecursiveMode::Recursive); - } - self.watcher = Some(watcher); - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -785,6 +735,14 @@ mod tests { errors: [None, None, None], } } + fn scanner(&self) -> Scanner { + // Fixtures live in the host's temp directory. Give them their own + // temporary root so ordinary fixture projects remain discoverable. + Scanner { + temporary_roots: TemporaryRoots::new([self.0.join("system-temp")]), + ..Scanner::default() + } + } } impl Drop for Fixture { fn drop(&mut self) { @@ -795,6 +753,95 @@ mod tests { serde_json::json!({"type":"session_meta","payload":{"id":"fixture","cwd":path,"source":"cli"}}) } #[test] + fn default_temporary_roots_hide_platform_temp_and_unix_aliases() { + let f = Fixture::new(); + let root = f.repo("openloop-eval/runs/app"); + f.write("codex/sessions/temp.jsonl", &codex(&root)); + assert!( + Scanner::default() + .scan(&f.locations(), [true; 3], &AtomicBool::new(false)) + .candidates + .is_empty() + ); + #[cfg(unix)] + for alias in ["/tmp", "/var/tmp"] { + let roots = TemporaryRoots::default(); + assert!(roots.contains(&Path::new(alias).join("evaluation/app"))); + if let Ok(canonical) = fs::canonicalize(alias) { + assert!(roots.contains(&canonical.join("evaluation/app"))); + } + } + } + #[test] + fn temporary_checkouts_are_hidden_across_sources_and_cached_scans() { + let f = Fixture::new(); + let temporary = f.repo("system-temp/openloop-eval/runs/app"); + let nested = temporary.join("src"); + fs::create_dir(&nested).unwrap(); + let ordinary = f.repo("projects/app"); + let similar = f.repo("system-temp-project/app"); + let named_tmp = f.repo("projects/tmp/app"); + f.write("herdr/session.json", &serde_json::json!({"version":3,"workspaces":[{"identity_cwd":nested,"tabs":[]},{"identity_cwd":ordinary,"tabs":[]}]})); + for (name, path) in [ + ("temporary", &nested), + ("ordinary", &ordinary), + ("similar", &similar), + ("named-tmp", &named_tmp), + ] { + f.write(&format!("codex/sessions/{name}.jsonl"), &codex(path)); + f.write(&format!("claude/projects/example/{name}.jsonl"), &serde_json::json!({"type":"user","cwd":path,"sessionId":name,"message":{"content":"ignored"}})); + } + let mut scanner = f.scanner(); + let cancel = AtomicBool::new(false); + let first = scanner.scan(&f.locations(), [true; 3], &cancel); + assert_eq!( + first + .candidates + .iter() + .map(|c| c.path.clone()) + .collect::>(), + BTreeSet::from([ordinary.clone(), similar, named_tmp]) + ); + assert_eq!( + first + .candidates + .iter() + .find(|c| c.path == ordinary) + .unwrap() + .sources, + BTreeSet::from(Source::ALL) + ); + assert!( + scanner.roots.contains_key(&nested), + "The excluded checkout's resolved root is cached too" + ); + let reads = scanner.metadata_reads; + assert_eq!(scanner.scan(&f.locations(), [true; 3], &cancel), first); + assert_eq!(scanner.metadata_reads, reads); + } + #[cfg(unix)] + #[test] + fn temporary_filter_follows_checkout_and_temp_root_symlinks() { + let f = Fixture::new(); + let temporary = f.repo("system-temp/app"); + let ordinary = f.repo("projects/app"); + let temp_alias = f.0.join("temp-alias"); + let checkout_alias = f.0.join("project-alias"); + let ordinary_alias = f.0.join("system-temp/ordinary-alias"); + std::os::unix::fs::symlink(f.0.join("system-temp"), &temp_alias).unwrap(); + std::os::unix::fs::symlink(&temporary, &checkout_alias).unwrap(); + std::os::unix::fs::symlink(&ordinary, &ordinary_alias).unwrap(); + f.write("codex/sessions/temp.jsonl", &codex(&checkout_alias)); + f.write("codex/sessions/ordinary.jsonl", &codex(&ordinary_alias)); + let mut scanner = Scanner { + temporary_roots: TemporaryRoots::new([temp_alias]), + ..Scanner::default() + }; + let snapshot = scanner.scan(&f.locations(), [true; 3], &AtomicBool::new(false)); + assert_eq!(snapshot.candidates.len(), 1); + assert_eq!(snapshot.candidates[0].path, ordinary); + } + #[test] fn discovery_merges_sources_and_nested_paths_and_reuses_metadata() { let f = Fixture::new(); let root = f.repo("项目 with spaces"); @@ -809,7 +856,7 @@ mod tests { "claude/projects/project/sessions-index.json", &serde_json::json!({"entries":[{"projectPath":root,"firstPrompt":"ignored"}]}), ); - let mut scanner = Scanner::default(); + let mut scanner = f.scanner(); let cancel = AtomicBool::new(false); let snapshot = scanner.scan(&f.locations(), [true; 3], &cancel); assert_eq!(snapshot.candidates.len(), 1); @@ -854,49 +901,30 @@ mod tests { f.write("codex/archived_sessions/good.jsonl", &codex(&root)); let bad = f.0.join("codex/archived_sessions/bad.jsonl"); fs::write(bad, b"{broken\n").unwrap(); - let snapshot = Scanner::default().scan(&f.locations(), [true; 3], &AtomicBool::new(false)); + let snapshot = f + .scanner() + .scan(&f.locations(), [true; 3], &AtomicBool::new(false)); assert_eq!(snapshot.candidates.len(), 1); assert!(snapshot.errors[1].is_some()); assert!(snapshot.errors[0].is_none()); assert!(snapshot.errors[2].is_none()); } #[test] - fn unavailable_paths_disabled_and_auto_add_respects_sources_and_exclusions() { + fn unavailable_and_non_git_paths_are_hidden() { let f = Fixture::new(); let root = f.repo("repo"); f.write("codex/sessions/a.jsonl", &codex(&root)); fs::create_dir(f.0.join("not-git")).unwrap(); f.write("codex/sessions/b.jsonl", &codex(&f.0.join("not-git"))); f.write("codex/sessions/gone.jsonl", &codex(&f.0.join("gone"))); - let snapshot = Scanner::default().scan( + let snapshot = f.scanner().scan( &f.locations(), [false, true, false], &AtomicBool::new(false), ); assert_eq!(snapshot.candidates.len(), 1); - let valid = snapshot - .candidates - .iter() - .find(|c| c.error.is_none()) - .unwrap(); - let mut invalid = valid.clone(); - invalid.error = Some("Unavailable".into()); - let mut preferences = Preferences::default(); - assert!(!preferences.should_add(valid)); - preferences.automatic[1] = true; - assert!(preferences.should_add(valid)); - assert!(!preferences.should_add(&invalid)); - preferences.excluded.insert(root.clone()); - assert!(!preferences.should_add(valid)); - preferences.excluded.remove(&root); - assert!(preferences.should_add(valid)); - preferences - .excluded_repositories - .insert(valid.repository.clone().unwrap()); - assert!( - !preferences.should_add(valid), - "Repository removal also excludes newly discovered sibling checkouts" - ); + assert_eq!(snapshot.candidates[0].path, root); + assert!(snapshot.candidates[0].error.is_none()); } #[test] fn linked_worktrees_remain_distinct() { @@ -933,7 +961,7 @@ mod tests { ); f.write("codex/sessions/a.jsonl", &codex(&root)); f.write("codex/sessions/b.jsonl", &codex(&checkout)); - let snapshot = Scanner::default().scan( + let snapshot = f.scanner().scan( &f.locations(), [false, true, false], &AtomicBool::new(false), @@ -1009,7 +1037,7 @@ mod tests { for n in 0..1000 { f.write(&format!("codex/sessions/{n}.jsonl"), &codex(&root)); } - let mut scanner = Scanner::default(); + let mut scanner = f.scanner(); let cancel = AtomicBool::new(false); let first = scanner.scan(&f.locations(), [false, true, false], &cancel); assert_eq!(first.candidates.len(), 1); @@ -1022,33 +1050,147 @@ mod tests { assert_eq!(scanner.metadata_reads, reads); eprintln!("Cached 1000-session discovery: {:?}", start.elapsed()); } + fn modified(path: &Path, seconds: u64) -> SystemTime { + let time = SystemTime::UNIX_EPOCH + Duration::from_secs(seconds); + File::options() + .write(true) + .open(path) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(time)) + .unwrap(); + time + } #[test] - fn newest_metadata_first_and_unavailable_directories_are_hidden() { + fn activity_orders_across_sources_and_updates_cached_and_removed_metadata() { let f = Fixture::new(); let old = f.repo("a-old"); let new = f.repo("z-new"); - f.write("codex/sessions/old.jsonl", &codex(&old)); - f.write("codex/sessions/new.jsonl", &codex(&new)); - f.write("codex/sessions/missing.jsonl", &codex(&f.0.join("removed"))); - let mut scanner = Scanner::default(); + let old_session = f.write("codex/sessions/old.jsonl", &codex(&old)); + let new_session = f.write( + "herdr/session.json", + &serde_json::json!({"version":3,"workspaces":[{"identity_cwd":new,"tabs":[]}]}), + ); + let newest_session = f.write("claude/projects/old/session.jsonl", &serde_json::json!({"type":"user","cwd":old,"sessionId":"fixture","message":{"content":"ignored"}})); + modified(&old_session, 10); + modified(&new_session, 20); + let latest = modified(&newest_session, 30); + let mut scanner = f.scanner(); let cancel = AtomicBool::new(false); - scanner.scan(&f.locations(), [false, true, false], &cancel); - scanner - .created - .insert(old.clone(), SystemTime::UNIX_EPOCH + Duration::from_secs(1)); - scanner - .created - .insert(new.clone(), SystemTime::UNIX_EPOCH + Duration::from_secs(2)); - let snapshot = scanner.scan(&f.locations(), [false, true, false], &cancel); + let scan = scanner.scan(&f.locations(), [true; 3], &cancel); + assert_eq!( + scan.candidates.iter().map(|c| &c.path).collect::>(), + vec![&old, &new] + ); + assert_eq!(scan.candidates[0].updated_at, Some(latest)); + assert_eq!( + scan.candidates[0].sources, + BTreeSet::from([Source::Codex, Source::Claude]) + ); + let reads = scanner.metadata_reads; + assert_eq!(scanner.scan(&f.locations(), [true; 3], &cancel), scan); + assert_eq!(scanner.metadata_reads, reads); + + // Touch an existing session without changing its creation time or cwd. + modified(&new_session, 40); + let scan = scanner.scan(&f.locations(), [true; 3], &cancel); + assert_eq!(scan.candidates[0].path, new); + assert_eq!(scanner.metadata_reads, reads + 1); + + fs::remove_file(newest_session).unwrap(); + let scan = scanner.scan(&f.locations(), [true; 3], &cancel); + assert_eq!(scan.candidates[1].path, old); + assert_eq!( + scan.candidates[1].updated_at, + Some(SystemTime::UNIX_EPOCH + Duration::from_secs(10)) + ); + assert_eq!(scan.candidates[1].sources, BTreeSet::from([Source::Codex])); + assert_eq!( + scanner.metadata_reads, + reads + 1, + "Remaining metadata stays cached" + ); + } + #[test] + fn equal_and_unknown_activity_use_name_then_path() { + let candidate = |path: &str, updated_at| Candidate { + path: path.into(), + repository: None, + error: None, + sources: BTreeSet::new(), + updated_at, + }; + let known = Some(SystemTime::UNIX_EPOCH); + let mut snapshot = Snapshot { + candidates: vec![ + candidate("/a/unknown", None), + candidate("/z/same", known), + candidate("/a/same", known), + candidate("/a/Alpha", known), + ], + errors: Default::default(), + }; + snapshot.sort(); assert_eq!( snapshot .candidates .iter() - .map(|c| c.path.clone()) + .map(|c| c.path.to_str().unwrap()) .collect::>(), - vec![new, old] + vec!["/a/Alpha", "/a/same", "/z/same", "/a/unknown"] ); } + #[cfg(unix)] + #[test] + fn symlink_and_nested_paths_merge_into_one_checkout() { + let f = Fixture::new(); + let root = f.repo("repo"); + let nested = root.join("src"); + fs::create_dir(&nested).unwrap(); + let link = f.0.join("alias"); + std::os::unix::fs::symlink(&root, &link).unwrap(); + f.write("codex/sessions/nested.jsonl", &codex(&nested)); + f.write("codex/sessions/alias.jsonl", &codex(&link)); + let snapshot = f + .scanner() + .scan(&f.locations(), [true; 3], &AtomicBool::new(false)); + assert_eq!(snapshot.candidates.len(), 1); + assert_eq!(snapshot.candidates[0].path, root); + } + #[cfg(unix)] + #[test] + fn live_herdr_activity_uses_saved_metadata_and_never_poll_time() { + use std::io::BufRead; + use std::os::unix::net::UnixListener; + let f = Fixture::new(); + let root = f.repo("repo"); + let saved = f.write( + "herdr/session.json", + &serde_json::json!({"version":3,"workspaces":[]}), + ); + let time = modified(&saved, 10); + let listener = UnixListener::bind(f.0.join("herdr/herdr.sock")).unwrap(); + let checkout = root.clone(); + let server = std::thread::spawn(move || { + for _ in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut request) + .unwrap(); + writeln!(stream, "{}", serde_json::json!({"id":"diffy-discover","result":{"snapshot":{"workspaces":[{"worktree":{"checkout_path":checkout}}]}}})).unwrap(); + } + }); + let mut scanner = f.scanner(); + let cancel = AtomicBool::new(false); + let snapshot = scanner.scan(&f.locations(), [true; 3], &cancel); + assert_eq!(snapshot.candidates[0].path, root); + assert_eq!(snapshot.candidates[0].updated_at, Some(time)); + assert_eq!(scanner.scan(&f.locations(), [true; 3], &cancel), snapshot); + fs::remove_file(saved).unwrap(); + let snapshot = scanner.scan(&f.locations(), [true; 3], &cancel); + assert_eq!(snapshot.candidates[0].updated_at, None); + server.join().unwrap(); + } #[test] fn legacy_codex_headers_without_cwd_are_normal_skips() { let f = Fixture::new(); diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 0000000..1d48a1c --- /dev/null +++ b/src/github.rs @@ -0,0 +1,353 @@ +//! Read-only GitHub API access. Credentials stay inside the backend, never in saved state. +use crate::repository::{self, GroupSnapshot}; +use serde::{Deserialize, Serialize}; +use std::{ + io::Read, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::OnceLock, + time::{Duration, Instant}, +}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Repository { + pub owner: String, + pub name: String, +} +impl Repository { + pub fn parse(remote: &str) -> Option { + let path = if let Some(path) = remote.strip_prefix("git@github.com:") { + path.to_owned() + } else { + let url = url::Url::parse(remote).ok()?; + if url.host_str()? != "github.com" + || !matches!(url.scheme(), "https" | "ssh") + || url.query().is_some() + || url.fragment().is_some() + { + return None; + } + url.path().trim_start_matches('/').to_owned() + }; + let path = path.trim_end_matches('/').trim_end_matches(".git"); + let (owner, name) = path.split_once('/')?; + if [owner, name].iter().any(|p| { + p.is_empty() + || *p == "." + || *p == ".." + || !p + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + }) { + return None; + } + Some(Self { + owner: owner.into(), + name: name.into(), + }) + } + pub fn label(&self) -> String { + format!("{}/{}", self.owner, self.name) + } + pub fn url(&self) -> String { + format!("https://github.com/{}", self.label()) + } + pub fn pr_url(&self, number: u64) -> String { + format!("{}/pull/{number}", self.url()) + } +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Remote { + pub name: String, + pub repository: Repository, +} +pub fn remotes(path: &Path) -> Result, String> { + let bytes = repository::git(path, &["remote", "-v"])?; + let text = String::from_utf8(bytes).map_err(|_| "Couldn't read repository remotes.")?; + let mut result = Vec::new(); + for line in text.lines() { + let mut parts = line.split_whitespace(); + if let (Some(name), Some(url), Some("(fetch)")) = (parts.next(), parts.next(), parts.next()) + && let Some(repository) = Repository::parse(url) + { + result.push(Remote { + name: name.into(), + repository, + }); + } + } + result.sort_by_key(|r| (r.name != "origin", r.name.clone())); + Ok(result) +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PullRequest { + pub number: u64, + pub title: String, + pub author: String, + pub draft: bool, + pub base: String, + pub head: String, + pub base_sha: String, + pub head_sha: String, +} +impl PullRequest { + pub fn tooltip(&self) -> String { + format!( + "#{} {}{}\n{} · {} → {}", + self.number, + self.title, + if self.draft { " · Draft" } else { "" }, + self.author, + self.head, + self.base + ) + } + pub fn cache_root(&self, repo: &Repository) -> PathBuf { + // A cache namespace only; never passed to filesystem or Git operations. + PathBuf::from(format!( + "github://{}/{}/{}/{}/{}", + repo.owner, repo.name, self.number, self.base_sha, self.head_sha + )) + } +} +pub struct Page { + pub items: Vec, + pub more: bool, +} +pub struct PullDiff { + pub pr: PullRequest, + pub snapshot: GroupSnapshot, +} +pub trait Backend: Send + Sync { + fn list(&self, repo: &Repository, page: u32) -> Result; + fn diff(&self, repo: &Repository, number: u64) -> Result; +} +pub struct OctocrabBackend; +fn runtime() -> Result<&'static tokio::runtime::Runtime, String> { + static RUNTIME: OnceLock> = OnceLock::new(); + RUNTIME + .get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("diffy-github") + .enable_all() + .build() + .map_err(|_| "Couldn't start GitHub networking.".into()) + }) + .as_ref() + .map_err(Clone::clone) +} +fn gh_program() -> Option { + which::which("gh").ok().or_else(|| { + let mut candidates = vec![ + PathBuf::from("/opt/homebrew/bin/gh"), + PathBuf::from("/usr/local/bin/gh"), + PathBuf::from("/usr/bin/gh"), + ]; + if let Some(home) = std::env::home_dir() { + candidates.push(home.join(".local/bin/gh")); + } + #[cfg(windows)] + if let Some(root) = std::env::var_os("ProgramFiles") { + candidates.push(PathBuf::from(root).join("GitHub CLI/gh.exe")); + } + candidates.into_iter().find(|p| p.is_file()) + }) +} +fn credential() -> Result { + let program = + gh_program().ok_or("Install GitHub CLI, then run gh auth login to view pull requests.")?; + let mut command = Command::new(program); + command + .args(["auth", "token", "--hostname", "github.com"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(0x08000000); + } + let mut child = command + .spawn() + .map_err(|_| "Couldn't start GitHub CLI. Run gh auth login, then retry.")?; + let stdout = child.stdout.take().unwrap(); + let reader = std::thread::spawn(move || { + let mut bytes = Vec::new(); + stdout + .take(64 * 1024 + 1) + .read_to_end(&mut bytes) + .map(|_| bytes) + }); + let deadline = Instant::now() + Duration::from_secs(15); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + } + }; + let bytes = reader + .join() + .ok() + .and_then(Result::ok) + .ok_or("Couldn't read GitHub credentials. Run gh auth login, then retry.")?; + if !status.is_some_and(|s| s.success()) || bytes.len() > 64 * 1024 { + return Err( + "GitHub login unavailable. Run gh auth login --hostname github.com, then retry.".into(), + ); + } + String::from_utf8(bytes) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "Run gh auth login --hostname github.com, then retry.".into()) +} +fn client(token: String) -> Result { + octocrab::Octocrab::builder() + .personal_token(token) + .build() + .map_err(|_| "Couldn't initialize GitHub access.".into()) +} +fn error(error: octocrab::Error) -> String { + match error { + octocrab::Error::GitHub { source, .. } => match source.status_code.as_u16() { + 401 => "GitHub login expired. Run gh auth login --hostname github.com, then retry.".into(), + 403 => "GitHub denied access or reached a rate limit. Check gh auth status and your repository permissions, then retry.".into(), + 429 => "GitHub's rate limit was reached. Wait for it to reset, then retry.".into(), + 404 => "Repository or pull request unavailable. Check the selected remote and your GitHub access.".into(), + 406 | 422 => "GitHub couldn't provide this diff. Open the pull request in GitHub to review it.".into(), + _ => "GitHub couldn't complete the request. Retry or open the pull request in GitHub.".into(), + }, + _ => "Couldn't reach GitHub. Check your connection and retry.".into(), + } +} +fn pull(pr: octocrab::models::pulls::PullRequest) -> PullRequest { + PullRequest { + number: pr.number, + title: pr.title.unwrap_or_default(), + author: pr.user.map(|u| u.login).unwrap_or_default(), + draft: pr.draft.unwrap_or(false), + base: pr.base.ref_field, + head: pr.head.ref_field, + base_sha: pr.base.sha, + head_sha: pr.head.sha, + } +} +impl Backend for OctocrabBackend { + fn list(&self, repo: &Repository, page: u32) -> Result { + let runtime = runtime()?; + let _entered = runtime.enter(); + let client = client(credential()?)?; + runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(30), async { + let page = client + .pulls(&repo.owner, &repo.name) + .list() + .state(octocrab::params::State::Open) + .sort(octocrab::params::pulls::Sort::Updated) + .direction(octocrab::params::Direction::Descending) + .per_page(100) + .page(page) + .send() + .await + .map_err(error)?; + Ok(Page { + more: page.next.is_some(), + items: page.items.into_iter().map(pull).collect(), + }) + }) + .await + .map_err(|_| { + "GitHub request timed out. Retry when your connection is available.".to_owned() + })? + }) + } + fn diff(&self, repo: &Repository, number: u64) -> Result { + let runtime = runtime()?; + let _entered = runtime.enter(); + let client = client(credential()?)?; + runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(30), async { + let handler = client.pulls(&repo.owner, &repo.name); + let pr = handler.get(number).await.map_err(error)?; + let patch = handler.get_diff(number).await.map_err(error)?; + if patch.len() > 16 * 1024 * 1024 { + return Err( + "This PR is too large for the desktop preview. Open it in GitHub.".into(), + ); + } + // Metadata and the diff are separate requests. Don't cache a newer patch + // under old commit IDs if a force-push or base update happened in between. + let latest = handler.get(number).await.map_err(error)?; + if latest.base.sha != pr.base.sha || latest.head.sha != pr.head.sha { + return Err( + "This pull request changed while loading. Refresh to get the latest diff." + .into(), + ); + } + let snapshot = GroupSnapshot::parse(patch)?; + if snapshot + .files + .iter() + .map(|f| f.estimated_size_bytes()) + .sum::() + > 32 * 1024 * 1024 + { + return Err( + "This PR is too large for the desktop preview. Open it in GitHub.".into(), + ); + } + Ok(PullDiff { + pr: pull(pr), + snapshot, + }) + }) + .await + .map_err(|_| { + "GitHub request timed out. Retry when your connection is available.".to_owned() + })? + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn api_client_initializes_on_a_background_thread() { + std::thread::spawn(|| { + let runtime = runtime().unwrap(); + let _entered = runtime.enter(); + // Building Octocrab starts a Tokio task even before the first request. + assert!(client("fixture-token".into()).is_ok()); + }) + .join() + .unwrap(); + } + #[test] + fn github_remotes_are_unambiguous_and_host_scoped() { + for url in [ + "git@github.com:hitSlop/diffy.git", + "https://github.com/hitSlop/diffy.git", + "ssh://git@github.com/hitSlop/diffy", + "https://github.com/hitSlop/diffy/", + ] { + assert_eq!(Repository::parse(url).unwrap().label(), "hitSlop/diffy"); + } + for url in [ + "https://example.com/hitSlop/diffy", + "https://github.com.evil.test/a/b", + "git@other:org/repo.git", + "https://github.com/a/b/more", + "https://github.com/a/..", + "https://github.com/a/b?token=secret", + ] { + assert!(Repository::parse(url).is_none(), "{url}"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index d112c74..b5e7dad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod cli; pub mod demo; pub mod discovery; pub mod fonts; +pub mod github; pub mod instance; mod keyboard; pub mod profiling; diff --git a/src/state.rs b/src/state.rs index 84707cf..dd555b6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -321,6 +321,36 @@ mod tests { assert_eq!(saved.projects[0].icon, None); } #[test] + fn retired_auto_import_flags_preserve_projects_and_exclusions() { + let saved: SavedState = serde_json::from_value(serde_json::json!({ + "version": 1, + "projects": [{"path":"/tmp/example","color":1,"staged":false,"split":true,"selected":[null,null]}], + "active": "/tmp/example", + "discovery": { + "automatic": [true, true, true], + "onboarding_completed": true, + "excluded": ["/tmp/skipped"], + "excluded_repositories": ["/tmp/removed/.git"] + } + })).unwrap(); + assert_eq!(saved.projects.len(), 1); + assert_eq!(saved.active, Some(PathBuf::from("/tmp/example"))); + assert!(saved.discovery.onboarding_completed); + assert!(saved.discovery.excluded.contains(Path::new("/tmp/skipped"))); + assert!( + saved + .discovery + .excluded_repositories + .contains(Path::new("/tmp/removed/.git")) + ); + let serialized = serde_json::to_value(&saved).unwrap(); + assert!(serialized["discovery"].get("automatic").is_none()); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + saved + ); + } + #[test] fn optional_future_fields_do_not_hide_saved_projects() { let saved: SavedState = serde_json::from_str(r#"{"version":1,"projects":[{"path":"/tmp/example","color":1,"staged":false,"split":true,"selected":[null,null],"future_option":true}],"active":null,"future_reader_option":{"enabled":true}}"#).unwrap(); assert_eq!(saved.projects.len(), 1); diff --git a/tests/github_ui.rs b/tests/github_ui.rs new file mode 100644 index 0000000..3f703a2 --- /dev/null +++ b/tests/github_ui.rs @@ -0,0 +1,444 @@ +#[path = "../benches/support/fixture.rs"] +mod fixture; +use diffy::{ + app, + github::{Backend, Page, PullDiff, PullRequest, Remote, Repository}, + repository::GroupSnapshot, +}; +use gpui_kit::{ + AppContext as _, Bounds, HeadlessAppContext, WindowBounds, WindowOptions, assets::Assets, px, + size, test::TestWindowExt as _, +}; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::Duration, +}; + +#[derive(Default)] +struct FakeGitHub { + fail: AtomicBool, + delayed: AtomicBool, + release: AtomicBool, + revision: AtomicUsize, + calls: AtomicUsize, + large: AtomicBool, + list_calls: AtomicUsize, +} +fn pr(number: u64, revision: usize) -> PullRequest { + PullRequest { + number, + title: format!( + "Fix refresh and keep this deliberately long pull request title readable {number}" + ), + author: "fixture".into(), + draft: number == 2, + base: "main".into(), + head: format!("fix-{number}"), + base_sha: "base".into(), + head_sha: format!("head-{number}-{revision}"), + } +} +impl Backend for FakeGitHub { + fn list(&self, repo: &Repository, page: u32) -> Result { + self.list_calls.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(Duration::from_millis(30)); + if self.fail.load(Ordering::Relaxed) { + return Err("Fixture authentication failed · Retry".into()); + } + if repo.name == "empty" { + return Ok(Page { + items: Vec::new(), + more: false, + }); + } + Ok(Page { + items: if page == 1 { + vec![pr(1, 0), pr(2, 0)] + } else { + vec![pr(3, 0)] + }, + more: page == 1, + }) + } + fn diff(&self, _: &Repository, number: u64) -> Result { + self.calls.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(Duration::from_millis(30)); + if self.delayed.load(Ordering::Relaxed) && number == 2 { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !self.release.load(Ordering::Relaxed) && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(2)); + } + } + if self.fail.load(Ordering::Relaxed) { + return Err("Fixture diff unavailable".into()); + } + let rev = self.revision.load(Ordering::Relaxed); + let mut patch = format!( + "diff --git a/src/review.rs b/src/review.rs\n--- a/src/review.rs\n+++ b/src/review.rs\n@@ -1 +1 @@\n-local\n+PR {number} revision {rev}\ndiff --git a/z.txt b/z.txt\n--- a/z.txt\n+++ b/z.txt\n@@ -1 +1 @@\n-before\n+after\n" + ); + if self.large.load(Ordering::Relaxed) { + for i in 0..40 { + patch.push_str(&format!("diff --git a/extra-{i:02}.txt b/extra-{i:02}.txt\n--- a/extra-{i:02}.txt\n+++ b/extra-{i:02}.txt\n@@ -1 +1 @@\n-before\n+after\n")); + } + } + Ok(PullDiff { + pr: pr(number, rev), + snapshot: GroupSnapshot::parse(patch)?, + }) + } +} +fn main() { + let repo = fixture::Fixture::new("github-ui"); + repo.write("local.txt", "base\n"); + repo.git(&["add", "."]); + repo.git(&["commit", "-m", "base"]); + repo.write("local.txt", "local change\n"); + let index = std::fs::read(repo.0.join(".git/index")).unwrap(); + let backend = Arc::new(FakeGitHub::default()); + std::fs::create_dir_all("target/previews").unwrap(); + let mut cx = HeadlessAppContext::with_platform( + gpui_kit::platform::current_platform(true).text_system(), + Arc::new(Assets), + gpui_kit::platform::current_headless_renderer, + ); + cx.update(|cx| { + gpui_kit::init(cx); + app::init(cx); + cx.set_reduce_motion(true); + }); + let (handle, view) = cx + .update(|cx| { + gpui_kit::open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Default::default(), + size: size(px(1000.), px(760.)), + })), + show: false, + focus: false, + ..Default::default() + }, + cx, + |window, cx| { + cx.new(|cx| { + app::Diffy::with_options(vec![repo.0.clone()], false, None, window, cx) + }) + }, + ) + }) + .unwrap(); + macro_rules! wait { + () => {{ + for _ in 0..500 { + cx.run_until_parked(); + let done = cx + .update_window(handle, |_, w, cx| { + w.render_frame(cx); + !view.read(cx).is_busy() + && view.read(cx).worktrees_ready() + && !view.read(cx).github_busy() + }) + .unwrap(); + if done { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + }}; + } + wait!(); + let local_selected = cx.update(|cx| view.read(cx).selected_file().cloned()); + let remote = Remote { + name: "origin".into(), + repository: Repository { + owner: "fixture".into(), + name: "project".into(), + }, + }; + cx.update_window(handle, |_, w, cx| { + view.update(cx, |v, cx| { + v.set_github_fixture( + vec![ + remote, + Remote { + name: "upstream".into(), + repository: Repository { + owner: "fixture".into(), + name: "empty".into(), + }, + }, + ], + backend.clone(), + w, + cx, + ) + }); + w.render_frame(cx); + w.click("source-prs", cx); + w.render_frame(cx); + assert_eq!( + w.find("pr-loading-status").label(), + Some("Loading pull requests…") + ); + assert!(w.try_find("pr-loading-empty").is_some()); + w.click("refresh", cx); + w.click("refresh", cx); + }) + .unwrap(); + cx.capture_screenshot(handle) + .unwrap() + .save("target/previews/diffy-github-loading.png") + .unwrap(); + wait!(); + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + assert_eq!(view.read(cx).github_selected(), Some(1)); + assert_eq!( + backend.list_calls.load(Ordering::Relaxed), + 1, + "Repeated refresh clicks must not duplicate the initial list request" + ); + assert!(w.try_find("pr-loading-status").is_none()); + assert_eq!( + view.read(cx).selected_file().unwrap().as_ref(), + "src/review.rs" + ); + assert!(w.try_find("stage-file").is_none()); + assert!(w.try_find("stage-all").is_none()); + assert!(w.try_find(("file-action", 0usize)).is_none()); + assert!(w.try_find("staged").is_none()); + w.press("secondary-s", cx); + w.dispatch_action(Box::new(app::StageAll), cx); + view.update(cx, |v, cx| v.probe_pr_writes(cx)); + w.render_frame(cx); + }) + .unwrap(); + assert_eq!( + std::fs::read(repo.0.join(".git/index")).unwrap(), + index, + "Every PR staging entry point preserves the local index" + ); + std::fs::create_dir_all("target/previews").unwrap(); + cx.capture_screenshot(handle) + .unwrap() + .save("target/previews/diffy-github-pr.png") + .unwrap(); + cx.update_window(handle, |_, w, cx| { + w.click("pr-overflow", cx); + w.render_frame(cx); + }) + .unwrap(); + for _ in 0..12 { + cx.advance_clock(Duration::from_millis(16)); + cx.run_until_parked(); + } + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + w.click("pr-search", cx); + w.input("#2", cx); + w.render_frame(cx); + assert!(w.try_find("choose-pr-1").is_none()); + w.click("choose-pr-2", cx); + w.render_frame(cx); + assert_eq!(w.find("pr-loading-status").label(), Some("Loading diff…")); + assert!(w.try_find("pr-loading-empty").is_some()); + }) + .unwrap(); + wait!(); + cx.update_window(handle, |_, w, cx| { + assert_eq!(view.read(cx).github_selected(), Some(2)); + w.click("source-worktrees", cx); + w.render_frame(cx); + assert_eq!(view.read(cx).selected_file().cloned(), local_selected); + assert!(w.try_find("stage-all").is_some()); + // Editing a local file while PR mode is active must not replace the PR snapshot. + w.click("source-prs", cx); + w.render_frame(cx); + }) + .unwrap(); + wait!(); + repo.write("local.txt", "another local edit\n"); + for _ in 0..8 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + std::thread::sleep(Duration::from_millis(5)); + } + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + assert_eq!(view.read(cx).github_selected(), Some(2)); + assert_eq!( + view.read(cx).selected_file().unwrap().as_ref(), + "src/review.rs" + ); + // Load the next page through the overflow menu. + w.click("pr-overflow", cx); + w.render_frame(cx); + }) + .unwrap(); + for _ in 0..12 { + cx.advance_clock(Duration::from_millis(16)); + cx.run_until_parked(); + } + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + w.click("pr-search", cx); + w.press("secondary-a", cx); + w.press("backspace", cx); + w.render_frame(cx); + }) + .unwrap(); + cx.capture_screenshot(handle) + .unwrap() + .save("target/previews/diffy-github-menu.png") + .unwrap(); + cx.update_window(handle, |_, w, cx| { + w.click("pr-load-more", cx); + w.render_frame(cx); + assert_eq!(w.find("pr-load-more").label(), Some("Loading more…")); + assert_eq!(w.find("pr-loading-status").label(), Some("Loading more…")); + assert_eq!( + view.read(cx).selected_preview_path().unwrap().as_ref(), + "src/review.rs" + ); + w.click("pr-load-more", cx); + }) + .unwrap(); + wait!(); + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + assert!(w.try_find("choose-pr-3").is_some()); + w.click("choose-pr-3", cx); + w.render_frame(cx); + }) + .unwrap(); + wait!(); + backend.revision.store(1, Ordering::Relaxed); + cx.update_window(handle, |_, w, cx| { + w.click("refresh", cx); + w.render_frame(cx); + assert_eq!( + w.find("pr-loading-status").label(), + Some("Refreshing pull requests…") + ); + assert_eq!(view.read(cx).github_selected(), Some(3)); + assert_eq!( + view.read(cx).selected_preview_path().unwrap().as_ref(), + "src/review.rs" + ); + assert!(w.try_find("pr-loading-empty").is_none()); + w.click("refresh", cx); + w.dispatch_action(Box::new(app::Refresh), cx); + }) + .unwrap(); + wait!(); + cx.update_window(handle, |_, w, cx| { + assert_eq!(view.read(cx).github_selected(), Some(3)); + w.render_frame(cx); + }) + .unwrap(); + // Start a delayed response, then leave PR mode before it completes. + backend.delayed.store(true, Ordering::Relaxed); + cx.update_window(handle, |_, w, cx| { + w.dispatch_action(Box::new(app::NextWorktree), cx); + w.dispatch_action(Box::new(app::NextWorktree), cx); + w.render_frame(cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle, |_, w, cx| { + w.click("source-worktrees", cx); + w.render_frame(cx); + assert!( + w.try_find("pr-loading-status").is_none(), + "Cancelled PR requests must not leave a loading indicator in Worktrees" + ); + }) + .unwrap(); + backend.release.store(true, Ordering::Relaxed); + wait!(); + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + assert_eq!(view.read(cx).selected_file().cloned(), local_selected); + backend.fail.store(true, Ordering::Relaxed); + w.click("source-prs", cx); + w.render_frame(cx); + w.click("refresh", cx); + w.render_frame(cx); + }) + .unwrap(); + wait!(); + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + assert!(w.try_find("retry").is_some()); + assert!( + w.try_find("pr-loading-status").is_none(), + "Errors must clear loading feedback" + ); + backend.fail.store(false, Ordering::Relaxed); + w.click("retry", cx); + w.render_frame(cx); + }) + .unwrap(); + wait!(); + // Remote changes clear the old PR; a large PR must keep the selected preview + // even when inserting its other files evicts it from the shared cache. + cx.update_window(handle, |_, w, cx| { + w.click("pr-overflow", cx); + w.render_frame(cx); + }) + .unwrap(); + for _ in 0..12 { + cx.advance_clock(Duration::from_millis(16)); + cx.run_until_parked(); + } + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + w.click("github-remote-upstream", cx); + }) + .unwrap(); + wait!(); + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + assert_eq!(view.read(cx).github_selected(), None); + assert_eq!(view.read(cx).selected_file(), None); + backend.large.store(true, Ordering::Relaxed); + w.click("github-remote-origin", cx); + }) + .unwrap(); + wait!(); + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + assert_eq!( + view.read(cx).selected_preview_path().unwrap().as_ref(), + "extra-00.txt" + ); + w.press("escape", cx); + w.dispatch_action(Box::new(app::NextFile), cx); + }) + .unwrap(); + wait!(); + let calls = backend.calls.load(Ordering::Relaxed); + for _ in 0..8 { + cx.run_until_parked(); + cx.update_window(handle, |_, w, cx| { + w.render_frame(cx); + assert_eq!( + view.read(cx).selected_preview_path().unwrap().as_ref(), + "extra-01.txt" + ); + }) + .unwrap(); + } + assert_eq!( + backend.calls.load(Ordering::Relaxed), + calls, + "Evicted previews must not create a refetch loop" + ); + assert_eq!(std::fs::read(repo.0.join(".git/index")).unwrap(), index); + println!( + "GitHub PR header, search, pagination, read-only actions, local restoration, stale responses, and retry passed." + ); +} diff --git a/tests/import_ui.rs b/tests/import_ui.rs index df7adac..3360594 100644 --- a/tests/import_ui.rs +++ b/tests/import_ui.rs @@ -1,3 +1,5 @@ +#[path = "../benches/support/fixture.rs"] +mod fixture; use diffy::{ app, discovery::{Candidate, Snapshot, Source}, @@ -6,7 +8,23 @@ use gpui_kit::{ AppContext as _, Bounds, HeadlessAppContext, ScrollDelta, WindowBounds, WindowOptions, assets::Assets, point, px, size, test::TestWindowExt as _, }; -use std::{collections::BTreeSet, path::PathBuf, sync::Arc}; +use std::{ + collections::BTreeSet, + path::PathBuf, + sync::Arc, + time::{Duration, SystemTime}, +}; + +fn check(ix: usize) -> String { + format!("import-check-/tmp/import-fixture/project-{ix:02}") +} + +fn settle(cx: &mut HeadlessAppContext) { + for _ in 0..20 { + cx.advance_clock(Duration::from_millis(16)); + cx.run_until_parked(); + } +} fn main() { let mut cx = HeadlessAppContext::with_platform( @@ -47,11 +65,10 @@ fn main() { BTreeSet::from([Source::Herdr]) }, error: None, - created_at: None, + updated_at: None, }) .collect(), errors: Default::default(), - herdr_live: false, }; cx.update_window(handle, |_, window, cx| { view.update(cx, |v, cx| { @@ -75,21 +92,19 @@ fn main() { .unwrap(); cx.update_window(handle, |_, window, cx| { assert_eq!(view.read(cx).import_selected_count(cx), 40); - window.click("import-check-0", cx); + window.click(check(0), cx); window.render_frame(cx); assert_eq!( view.read(cx).import_selected_count(cx), 39, "Checkbox pointer clicks must change the selection" ); - window.click("import-check-0", cx); + window.click(check(0), cx); window.render_frame(cx); assert_eq!(view.read(cx).import_selected_count(cx), 40); - window.click("import-auto-0", cx); - window.render_frame(cx); assert!( - view.read(cx).import_automatic(cx)[0], - "Auto-add checkbox must toggle" + window.try_find("import-auto-0").is_none(), + "Source controls are retired" ); for _ in 0..8 { window.scroll( @@ -100,41 +115,69 @@ fn main() { window.render_frame(cx); } assert!( - window.find("import-check-39").bounds().size.height > px(0.), + window.find(check(39)).bounds().size.height > px(0.), "Scroll must reach the last candidate" ); - window.click("import-check-39", cx); + window.click(check(39), cx); window.render_frame(cx); assert_eq!(view.read(cx).import_selected_count(cx), 39); assert!( - window.find("import-check-39").bounds().size.height > px(0.), + window.find(check(39)).bounds().size.height > px(0.), "Toggling must preserve scroll position" ); assert!( - window.find("import-check-39-2").bounds().size.height > px(0.), - "Shared projects must appear under Claude as well" + window.try_find("import-check-39-2").is_none(), + "Shared projects appear only once" ); - window.click("import-check-39-2", cx); + let count = view.read(cx).project_count(); + window.click("import-refresh", cx); + window.render_frame(cx); + assert!(view.read(cx).import_loading(cx)); + assert_eq!( + window.find("import-status").label(), + Some("Pulling latest projects from Herdr, Codex, and Claude Code…") + ); + // A second native pointer click while loading must not start another scan. + window.click("import-refresh", cx); + assert_eq!( + view.read(cx).project_count(), + count, + "Scanning must not add projects" + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle, |_, window, cx| { window.render_frame(cx); + assert!(!view.read(cx).import_loading(cx)); + assert_eq!( + window.find("import-status").label(), + Some("40 projects found · Most recently updated first") + ); assert_eq!( view.read(cx).import_selected_count(cx), - 40, - "Shared project rows must use the same selection" + 39, + "Unchanged refresh preserves deselection" + ); + assert!( + window.find(check(39)).bounds().size.height > px(0.), + "Refresh preserves scrolling" ); window.click("import-skip", cx); window.render_frame(cx); assert!(!view.read(cx).import_is_open()); - assert_eq!( - view.read(cx).discovery_preferences().automatic, - [false; 3], - "Cancel must not commit auto-add changes" + assert!( + view.read(cx).discovery_preferences().excluded.is_empty(), + "Cancel must not save selection changes" ); assert!(view.read(cx).discovery_preferences().onboarding_completed); }) .unwrap(); std::fs::create_dir_all("target/previews").unwrap(); cx.update_window(handle, |_, window, cx| { - view.update(cx, |v, cx| v.open_import_fixture(snapshot, window, cx)); + view.update(cx, |v, cx| { + v.open_import_fixture(snapshot.clone(), window, cx) + }); window.render_frame(cx); }) .unwrap(); @@ -159,5 +202,202 @@ fn main() { assert!(!view.read(cx).import_is_open()); }) .unwrap(); - println!("Native import checkbox, scrolling, cancellation, and selection checks passed."); + // A refresh may reorder rows or shrink the list without losing path identity. + cx.update_window(handle, |_, window, cx| { + view.update(cx, |v, cx| { + v.open_import_fixture(snapshot.clone(), window, cx) + }); + window.render_frame(cx); + }) + .unwrap(); + settle(&mut cx); + let mut reordered = snapshot.clone(); + reordered.candidates[39].updated_at = Some(SystemTime::UNIX_EPOCH); + reordered.errors[1] = Some("One metadata source could not be read.".into()); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + window.click("import-select-all", cx); + window.render_frame(cx); + window.click(check(0), cx); + view.update(cx, |v, _| v.set_import_fixture(reordered.clone())); + window.click("import-refresh", cx); + window.render_frame(cx); + assert!(view.read(cx).import_loading(cx)); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + assert_eq!(view.read(cx).import_paths().len(), 40); + assert_eq!( + view.read(cx).import_paths()[0], + reordered.candidates[39].path + ); + assert_eq!(view.read(cx).import_selected_count(cx), 39); + window.click(check(0), cx); + window.render_frame(cx); + assert_eq!( + view.read(cx).import_selected_count(cx), + 40, + "Deselection followed the path after reordering" + ); + assert!(window.try_find("import-error-1").is_some()); + let mut small = reordered.clone(); + small.candidates.retain(|c| { + c.path == snapshot.candidates[0].path || c.path == snapshot.candidates[39].path + }); + view.update(cx, |v, _| v.set_import_fixture(small)); + for _ in 0..8 { + window.scroll( + "import-scroll", + ScrollDelta::Pixels(point(px(0.), px(-400.))), + cx, + ); + window.render_frame(cx); + } + window.click("import-refresh", cx); + window.render_frame(cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + assert_eq!(view.read(cx).import_paths().len(), 2); + assert_eq!(view.read(cx).import_selected_count(cx), 2); + assert!( + window.find(check(39)).visible(), + "Shrinking results clamps the old scroll offset" + ); + window.click("import-refresh", cx); + window.render_frame(cx); + assert!(view.read(cx).import_loading(cx)); + }) + .unwrap(); + let old_generation = cx.update(|cx| view.read(cx).import_generation()); + cx.update_window(handle, |_, window, cx| { + window.click("import-skip", cx); + window.render_frame(cx); + assert!(!view.read(cx).import_is_open()); + view.update(cx, |v, cx| { + v.open_import_fixture(Snapshot::default(), window, cx) + }); + // Simulate completion of the cancelled generation after a new picker opens. + view.update(cx, |v, cx| { + v.complete_import_fixture(old_generation, snapshot.clone(), cx) + }); + window.render_frame(cx); + assert!( + view.read(cx).import_paths().is_empty(), + "Stale completion cannot replace a reopened picker" + ); + }) + .unwrap(); + settle(&mut cx); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + assert!(view.read(cx).import_paths().is_empty()); + window.click("import-refresh", cx); + window.render_frame(cx); + window.click("import-skip", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| assert!(!view.read(cx).import_is_open())); + + // Real repositories enter the sidebar only when explicitly imported. + let first = fixture::Fixture::new("import-first"); + let second = fixture::Fixture::new("import-second"); + let paths = [&first.0, &second.0].map(|p| std::fs::canonicalize(p).unwrap()); + let real = Snapshot { + candidates: paths + .iter() + .map(|path| Candidate { + path: path.clone(), + repository: Some(path.join(".git")), + sources: BTreeSet::from([Source::Codex]), + error: None, + updated_at: None, + }) + .collect(), + errors: Default::default(), + }; + let (count, active) = cx.update(|cx| { + ( + view.read(cx).project_count(), + view.read(cx).active_project_id(), + ) + }); + cx.update_window(handle, |_, window, cx| { + view.update(cx, |v, cx| v.open_import_fixture(real.clone(), window, cx)); + window.render_frame(cx); + }) + .unwrap(); + settle(&mut cx); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + assert_eq!(view.read(cx).project_count(), count); + assert_eq!(view.read(cx).import_selected_count(cx), 2); + window.click(format!("import-check-{}", paths[0].display()), cx); + window.render_frame(cx); + window.click("import-apply", cx); + window.render_frame(cx); + assert_eq!(view.read(cx).project_count(), count + 1); + assert_eq!(view.read(cx).active_project_id(), active); + assert!( + view.read(cx) + .discovery_preferences() + .excluded + .contains(&paths[0]) + ); + view.update(cx, |v, cx| v.open_import_fixture(real.clone(), window, cx)); + window.render_frame(cx); + assert_eq!( + view.read(cx).import_selected_count(cx), + 0, + "Skipped and existing projects start unselected" + ); + }) + .unwrap(); + settle(&mut cx); + cx.update_window(handle, |_, window, cx| { + window.render_frame(cx); + assert!( + window + .try_find(format!("import-check-{}", paths[1].display())) + .is_none(), + "Existing projects cannot be selected again" + ); + window.click("import-skip", cx); + }) + .unwrap(); + // Discovery filtering must not prevent manually opening a temporary checkout. + first.write("manual.txt", "manually opened temporary project\n"); + cx.update_window(handle, |_, window, cx| { + view.update(cx, |v, cx| v.open_paths(vec![paths[0].clone()], window, cx)); + window.render_frame(cx); + assert_eq!(view.read(cx).project_count(), count + 2); + }) + .unwrap(); + for _ in 0..500 { + cx.run_until_parked(); + let ready = cx + .update_window(handle, |_, window, cx| { + window.render_frame(cx); + !view.read(cx).is_busy() && view.read(cx).selected_preview_path().is_some() + }) + .unwrap(); + if ready { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + cx.update(|cx| { + assert_eq!( + view.read(cx).selected_preview_path().unwrap().as_ref(), + "manual.txt" + ) + }); + println!( + "Import refresh, unified ordering, selection, cancellation, and explicit import checks passed." + ); } diff --git a/tests/worktrees_ui.rs b/tests/worktrees_ui.rs index 23323fa..c431818 100644 --- a/tests/worktrees_ui.rs +++ b/tests/worktrees_ui.rs @@ -118,7 +118,7 @@ fn main() { .filter(|s| s.role() == Some(Role::Tab)) .filter_map(|s| s.label().map(str::to_owned)) .collect::>(); - assert!(tabs.contains(&"Project".to_owned()), "{tabs:?}"); + assert!(tabs.contains(&"main".to_owned()), "{tabs:?}"); assert!( tabs.contains(&"Legacy task".to_owned()), "Legacy labels survive migration: {tabs:?}" @@ -363,7 +363,7 @@ fn main() { .collect::>(); assert!( tabs.iter() - .any(|s| s.label() == Some("Project") && s.selected() == Some(true)), + .any(|s| s.label() == Some("main") && s.selected() == Some(true)), "Main project selection survives restart" ); window.press("secondary-right", cx);