From cb47f8a60219e60f970defb39037de0b8e11ae4c Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Mon, 14 Sep 2026 03:30:00 +0200 Subject: [PATCH] feat: show GitHub pull requests in spaces --- docs/next/api/herdr-api.schema.json | 2 +- .../src/content/docs/configuration.mdx | 9 + .../website/src/data/config-reference.json | 11 + src/app/actions.rs | 19 +- src/app/api.rs | 33 + src/app/git_refresh.rs | 32 +- src/app/mod.rs | 55 ++ src/app/pull_requests.rs | 575 ++++++++++++++++++ src/app/runtime.rs | 3 + src/client/shell/config.rs | 2 + src/client/shell/endpoint_sidebar.rs | 2 + src/client/shell/mouse.rs | 1 + src/client/shell/settings.rs | 23 + src/client/shell/settings_overlay.rs | 40 +- src/client/shell/sidebar.rs | 58 +- src/client/shell/state.rs | 6 +- .../tests/agents_worktrees_notifications.rs | 65 ++ src/client/shell/tests/mobile.rs | 3 + src/client/shell/tests/mod.rs | 1 + src/client/shell/tests/startup_overlays.rs | 22 +- src/config.rs | 6 +- src/config/model.rs | 22 + src/config/write.rs | 8 + src/events.rs | 15 +- src/main.rs | 3 + src/persist/restore.rs | 2 + src/protocol/wire.rs | 5 +- src/remote/attach.rs | 6 +- src/server/client_shell.rs | 1 + src/server/headless.rs | 1 + src/ui/sidebar.rs | 14 + src/ui/sidebar/tokens.rs | 14 +- src/workspace.rs | 33 + tests/api_ping.rs | 2 +- tests/cli/sessions.rs | 12 +- tests/support/mod.rs | 2 +- 36 files changed, 1056 insertions(+), 52 deletions(-) create mode 100644 src/app/pull_requests.rs diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 26299e79..db4d9414 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "protocol": 22, + "protocol": 23, "schema_version": 1, "schemas": { "error_response": { diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 1975a43a..4159d49c 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -348,6 +348,15 @@ status_indicators = "symbols" The symbols are static, so this option does not enable spinner animation. +Herdr can use the authenticated GitHub CLI (`gh`) to show the pull request for each current branch in the Spaces sidebar. The default uses portable symbols; Nerd Font users can select GitHub's Octicon glyphs, or turn the indicators off: + +```toml +[ui] +pull_request_indicators = "symbols" # "off", "symbols", or "nerd_font" +``` + +Linked worktrees get a conditional pull-request row. A repository's main space shows the pull request beside its branch. The cached indicator is cleared immediately when the branch changes. + ### Sidebar row layouts The expanded desktop sidebar renders each inner array in `rows` as one line. These are the complete default layouts: diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 36afe337..db72d6f4 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -1048,6 +1048,17 @@ "symbols" ] }, + { + "key": "ui.pull_request_indicators", + "type": "enum", + "default": "\"symbols\"", + "description": "Show current-branch pull requests in Spaces using portable symbols, GitHub Nerd Font icons, or not at all.", + "values": [ + "off", + "symbols", + "nerd_font" + ] + }, { "key": "ui.sidebar.agents.row_gap", "type": "integer", diff --git a/src/app/actions.rs b/src/app/actions.rs index 85314331..98077823 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1414,6 +1414,14 @@ impl AppState { } let ws = &mut self.workspaces[ws_idx]; + let pull_request_identity_changed = ws.cached_identity_cwd + != result.resolved_identity_cwd + || ws.cached_git_status_key != result.status_cache_key + || result.demand.branch && ws.cached_git_branch != result.branch; + if pull_request_identity_changed { + changed |= ws.cached_pull_request.take().is_some(); + ws.cached_pull_request_repository = None; + } if ws.cached_identity_cwd != result.resolved_identity_cwd { ws.cached_identity_cwd = result.resolved_identity_cwd; } @@ -1674,7 +1682,9 @@ impl AppState { AppEvent::WorktreeAddFinished(_) => Vec::new(), AppEvent::WorktreeRemoveFinished(_) => Vec::new(), AppEvent::TabBarCommandFinished { .. } => Vec::new(), - AppEvent::PluginCommandFinished { .. } => Vec::new(), + AppEvent::PluginCommandFinished { .. } | AppEvent::PullRequestsRefreshed(_) => { + Vec::new() + } } } @@ -2554,6 +2564,11 @@ mod tests { fn apply_workspace_git_statuses_updates_matching_workspace() { let mut state = app_with_workspaces(&["one", "two"]); let first_id = state.workspaces[0].id.clone(); + state.workspaces[0].cached_pull_request = Some(crate::workspace::PullRequestInfo { + number: 42, + state: crate::workspace::PullRequestState::Open, + }); + state.workspaces[0].cached_pull_request_repository = Some("upstream/herdr".into()); let first_cwd = state.workspaces[0].resolved_identity_cwd().unwrap(); let second_id = state.workspaces[1].id.clone(); @@ -2574,6 +2589,8 @@ mod tests { assert!(changed); assert_eq!(state.workspaces[0].branch().as_deref(), Some("main")); + assert_eq!(state.workspaces[0].cached_pull_request, None); + assert_eq!(state.workspaces[0].cached_pull_request_repository, None); assert_eq!(state.workspaces[0].git_ahead_behind(), Some((2, 1))); assert_eq!(state.workspaces[1].id, second_id); assert_eq!(state.workspaces[1].git_ahead_behind(), None); diff --git a/src/app/api.rs b/src/app/api.rs index 1270feff..25b6599e 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -34,6 +34,14 @@ impl App { results, cache_updates, } => self.handle_git_status_refreshed(results, cache_updates), + AppEvent::PullRequestsRefreshed(results) => { + let changed = self.handle_pull_requests_refreshed(results); + if changed { + self.render_dirty.request_generic(); + self.render_notify.notify_one(); + } + changed + } AppEvent::TabBarCommandFinished { generation, segment_index, @@ -65,9 +73,26 @@ impl App { } else { self.last_git_remote_status_refresh = Instant::now(); } + let pull_request_identity_changed = results.iter().any(|result| { + self.state.workspaces.iter().any(|workspace| { + workspace.id == result.workspace_id + && (workspace.cached_identity_cwd != result.resolved_identity_cwd + || workspace.cached_git_status_key != result.status_cache_key + || result.demand.branch && workspace.cached_git_branch != result.branch) + }) + }); let changed = self .state .apply_workspace_git_statuses(&self.terminal_runtimes, results); + if pull_request_identity_changed { + if self.pull_request_refresh_in_flight { + self.pull_request_refresh_due_after_in_flight = true; + } else { + self.last_pull_request_refresh = Instant::now() + .checked_sub(super::PULL_REQUEST_REFRESH_INTERVAL) + .unwrap_or_else(Instant::now); + } + } if changed { self.render_dirty.request_generic(); self.render_notify.notify_one(); @@ -116,6 +141,14 @@ impl App { return Vec::new(); } + if let AppEvent::PullRequestsRefreshed(results) = ev { + if self.handle_pull_requests_refreshed(results) { + self.render_dirty.request_generic(); + self.render_notify.notify_one(); + } + return Vec::new(); + } + if let AppEvent::TabBarCommandFinished { generation, segment_index, diff --git a/src/app/git_refresh.rs b/src/app/git_refresh.rs index fd512d55..75f804b8 100644 --- a/src/app/git_refresh.rs +++ b/src/app/git_refresh.rs @@ -100,7 +100,10 @@ impl App { } fn git_refresh_demand(&self) -> GitStatusRefreshDemand { - let mut demand = GitStatusRefreshDemand::default(); + let mut demand = GitStatusRefreshDemand { + branch: true, + ..GitStatusRefreshDemand::default() + }; for token in self.state.sidebar_spaces.rows.iter().flatten() { match token.parts().0 { crate::config::SpaceSidebarToken::Branch => demand.branch = true, @@ -376,7 +379,7 @@ mod tests { } #[test] - fn due_git_refresh_does_not_start_without_sidebar_consumer() { + fn due_git_refresh_starts_for_pull_request_collection() { let mut config = crate::config::Config::default(); config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; let mut app = test_app(&config); @@ -386,8 +389,7 @@ mod tests { app.start_git_status_refresh_if_due(now); - assert!(!app.git_refresh_in_flight); - assert!(app.event_rx.try_recv().is_err()); + assert!(app.git_refresh_in_flight); } #[test] @@ -395,7 +397,10 @@ mod tests { let cases = [ ( crate::config::SpaceSidebarToken::Workspace, - GitStatusRefreshDemand::default(), + GitStatusRefreshDemand { + branch: true, + ahead_behind: false, + }, ), ( crate::config::SpaceSidebarToken::Branch, @@ -407,7 +412,7 @@ mod tests { ( crate::config::SpaceSidebarToken::GitStatus, GitStatusRefreshDemand { - branch: false, + branch: true, ahead_behind: true, }, ), @@ -429,7 +434,7 @@ mod tests { } #[test] - fn unnamed_linked_worktree_does_not_force_periodic_branch_refresh() { + fn unnamed_linked_worktree_keeps_branch_fresh_for_pull_requests() { let mut config = crate::config::Config::default(); config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; let mut app = test_app(&config); @@ -444,11 +449,11 @@ mod tests { }); app.state.workspaces.push(child); - assert_eq!(app.git_refresh_deadline(), None); + assert!(app.git_refresh_deadline().is_some()); } #[test] - fn custom_named_linked_worktree_does_not_require_branch_refresh() { + fn custom_named_linked_worktree_keeps_branch_fresh_for_pull_requests() { let mut config = crate::config::Config::default(); config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; let mut app = test_app(&config); @@ -462,7 +467,7 @@ mod tests { }); app.state.workspaces.push(child); - assert_eq!(app.git_refresh_deadline(), None); + assert!(app.git_refresh_deadline().is_some()); } #[test] @@ -476,10 +481,9 @@ mod tests { app.next_headless_loop_deadline_with_git_refresh(now, false, false), None ); - assert_eq!( - app.next_headless_loop_deadline_with_git_refresh(now, false, true), - Some(now) - ); + assert!(app + .next_headless_loop_deadline_with_git_refresh(now, false, true) + .is_some_and(|deadline| deadline <= now)); } #[test] diff --git a/src/app/mod.rs b/src/app/mod.rs index 4ac109c7..8328c370 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -19,6 +19,7 @@ mod git_refresh; mod ids; pub(crate) mod pane_graphics; mod popup; +pub(crate) mod pull_requests; mod runtime; mod session; pub mod state; @@ -37,6 +38,7 @@ use std::time::{Duration, Instant}; const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); +const PULL_REQUEST_REFRESH_INTERVAL: Duration = Duration::from_secs(60); const GIT_REPO_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750); @@ -120,6 +122,9 @@ pub struct App { pub(crate) last_git_remote_status_refresh: Instant, pub(crate) last_git_repo_discovery_refresh: Instant, pub(crate) git_refresh_in_flight: bool, + pub(crate) last_pull_request_refresh: Instant, + pub(crate) pull_request_refresh_in_flight: bool, + pub(crate) pull_request_refresh_due_after_in_flight: bool, pub(crate) git_refresh_due_after_in_flight: bool, pub(crate) git_identity_refresh_requested: bool, pub(crate) git_status_cache: HashMap, @@ -579,6 +584,9 @@ impl App { last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL, last_git_repo_discovery_refresh: Instant::now(), git_refresh_in_flight: false, + last_pull_request_refresh: Instant::now() - PULL_REQUEST_REFRESH_INTERVAL, + pull_request_refresh_in_flight: false, + pull_request_refresh_due_after_in_flight: false, git_refresh_due_after_in_flight: false, git_identity_refresh_requested: false, git_status_cache: HashMap::new(), @@ -1039,6 +1047,53 @@ mod tests { assert_eq!(app.git_refresh_deadline(), None); } + #[test] + fn pull_request_refresh_preserves_due_request_while_in_flight() { + let mut app = test_app(); + app.pull_request_refresh_in_flight = true; + app.pull_request_refresh_due_after_in_flight = true; + + app.handle_pull_requests_refreshed(Vec::new()); + + assert!(!app.pull_request_refresh_in_flight); + assert!(!app.pull_request_refresh_due_after_in_flight); + assert!(app.last_pull_request_refresh + PULL_REQUEST_REFRESH_INTERVAL <= Instant::now()); + } + + #[test] + fn failed_pull_request_refresh_preserves_cached_indicator() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("one"); + workspace.cached_git_branch = Some("feature".into()); + workspace.cached_pull_request = Some(crate::workspace::PullRequestInfo { + number: 42, + state: crate::workspace::PullRequestState::Open, + }); + workspace.cached_pull_request_repository = Some("upstream/herdr".into()); + let result = pull_requests::WorkspacePullRequest { + workspace_id: workspace.id.clone(), + cwd: workspace.cached_identity_cwd.clone(), + branch: "feature".into(), + pull_request: Err(()), + }; + app.state.workspaces.push(workspace); + + assert!(!app.handle_pull_requests_refreshed(vec![result])); + assert_eq!( + app.state.workspaces[0] + .cached_pull_request + .as_ref() + .map(|pull_request| pull_request.number), + Some(42) + ); + assert_eq!( + app.state.workspaces[0] + .cached_pull_request_repository + .as_deref(), + Some("upstream/herdr") + ); + } + #[test] fn unchanged_git_status_event_has_no_render_impact() { let mut app = test_app(); diff --git a/src/app/pull_requests.rs b/src/app/pull_requests.rs new file mode 100644 index 00000000..fd3499d7 --- /dev/null +++ b/src/app/pull_requests.rs @@ -0,0 +1,575 @@ +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +use super::{App, PULL_REQUEST_REFRESH_INTERVAL}; +use crate::events::AppEvent; +use crate::workspace::{PullRequestInfo, PullRequestState}; + +#[derive(Debug, Clone)] +pub(crate) struct WorkspacePullRequest { + pub workspace_id: String, + pub cwd: PathBuf, + pub branch: String, + pub pull_request: Result, +} + +#[derive(Debug, Clone)] +pub(crate) struct PullRequestLookup { + pub pull_request: Option, + pub repository: Option, +} + +#[derive(Deserialize)] +struct GhPullRequest { + number: u64, + state: String, + #[serde(rename = "draft")] + is_draft: bool, + #[serde(default)] + merged_at: Option, + head: GhPullRequestHead, +} + +#[derive(Deserialize)] +struct GhPullRequestHead { + repo: Option, +} + +#[derive(Deserialize)] +struct GhRepositoryName { + #[serde(rename = "full_name")] + name_with_owner: String, +} + +#[derive(Deserialize)] +struct GhRepository { + #[serde(rename = "nameWithOwner")] + name_with_owner: String, + url: String, + parent: Option, +} + +#[derive(Deserialize)] +struct GhRepositoryParent { + name: String, + owner: GhRepositoryOwner, +} + +#[derive(Deserialize)] +struct GhRepositoryOwner { + login: String, +} + +impl App { + pub(crate) fn pull_request_refresh_deadline(&self) -> Option { + (!self.pull_request_refresh_in_flight + && self + .state + .workspaces + .iter() + .any(|workspace| workspace.cached_git_branch.is_some())) + .then_some(self.last_pull_request_refresh + PULL_REQUEST_REFRESH_INTERVAL) + } + + pub(crate) fn start_pull_request_refresh_if_due(&mut self, now: Instant) { + let Some(deadline) = self.pull_request_refresh_deadline() else { + return; + }; + if now < deadline { + return; + } + let targets = self + .state + .workspaces + .iter() + .filter_map(|workspace| { + Some(( + workspace.id.clone(), + workspace.cached_identity_cwd.clone(), + workspace.cached_git_branch.clone()?, + workspace.cached_pull_request_repository.clone(), + )) + }) + .collect::>(); + self.pull_request_refresh_in_flight = true; + let event_tx = self.event_tx.clone(); + tokio::spawn(async move { + let target_count = targets.len(); + let concurrency = Arc::new(tokio::sync::Semaphore::new(4)); + let mut tasks = tokio::task::JoinSet::new(); + for (workspace_id, cwd, branch, cached_repository) in targets { + let concurrency = Arc::clone(&concurrency); + tasks.spawn(async move { + let _permit = concurrency.acquire_owned().await.ok()?; + Some(WorkspacePullRequest { + workspace_id, + pull_request: query_pull_request( + &cwd, + &branch, + cached_repository.as_deref(), + ) + .await, + cwd, + branch, + }) + }); + } + let mut results = Vec::with_capacity(target_count); + while let Some(result) = tasks.join_next().await { + if let Ok(Some(result)) = result { + results.push(result); + } + } + let _ = event_tx + .send(AppEvent::PullRequestsRefreshed(results)) + .await; + }); + } + + pub(crate) fn handle_pull_requests_refreshed( + &mut self, + results: Vec, + ) -> bool { + self.pull_request_refresh_in_flight = false; + let now = Instant::now(); + if self.pull_request_refresh_due_after_in_flight { + self.last_pull_request_refresh = now + .checked_sub(PULL_REQUEST_REFRESH_INTERVAL) + .unwrap_or(now); + self.pull_request_refresh_due_after_in_flight = false; + } else { + self.last_pull_request_refresh = now; + } + let mut changed = false; + for result in results { + let Some(workspace) = self.state.workspaces.iter_mut().find(|workspace| { + workspace.id == result.workspace_id + && workspace.cached_identity_cwd == result.cwd + && workspace.cached_git_branch.as_deref() == Some(result.branch.as_str()) + }) else { + continue; + }; + let Ok(lookup) = result.pull_request else { + continue; + }; + workspace.cached_pull_request_repository = lookup.repository; + if workspace.cached_pull_request != lookup.pull_request { + workspace.cached_pull_request = lookup.pull_request; + changed = true; + } + } + changed + } +} + +async fn query_pull_request( + cwd: &std::path::Path, + branch: &str, + cached_repository: Option<&str>, +) -> Result { + let target = resolve_published_target(cwd, branch).await?; + let (_, published_branch, repository_locator) = ⌖ + let repository: GhRepository = serde_json::from_slice( + &gh_output( + cwd, + &[ + "repo", + "view", + repository_locator, + "--json", + "nameWithOwner,parent,url", + ], + ) + .await?, + ) + .map_err(|_| ())?; + let host = github_host(&repository.url).ok_or(())?; + let head_owner = repository.name_with_owner.split_once('/').ok_or(())?.0; + let remote_config = command_output( + cwd, + "git", + &["config", "--get-regexp", r"^remote\..*\.(pushurl|url)$"], + ) + .await + .ok() + .and_then(|output| String::from_utf8(output).ok()); + let repositories = + pull_request_repositories(&repository, remote_config.as_deref(), cached_repository); + let head = format!("{head_owner}:{published_branch}"); + let mut found = None; + 'states: for state in ["open", "closed"] { + let mut failed = false; + for (base_repository, required) in &repositories { + let endpoint = format!("repos/{base_repository}/pulls"); + let output = match gh_output( + cwd, + &[ + "api", + "--method", + "GET", + "--hostname", + host, + &endpoint, + "-f", + &format!("state={state}"), + "-f", + &format!("head={head}"), + "-f", + "per_page=100", + "--paginate", + "--slurp", + ], + ) + .await + { + Ok(output) => output, + Err(()) => { + failed |= *required; + continue; + } + }; + if let Some(pull_request) = pull_request_for_repository( + serde_json::from_slice::>>(&output) + .map_err(|_| ())? + .into_iter() + .flatten(), + &repository.name_with_owner, + ) { + found = Some((pull_request, base_repository.clone())); + break 'states; + } + } + if failed { + return Err(()); + } + } + let current_remote_config = command_output( + cwd, + "git", + &["config", "--get-regexp", r"^remote\..*\.(pushurl|url)$"], + ) + .await + .ok() + .and_then(|output| String::from_utf8(output).ok()); + if resolve_published_target(cwd, branch).await? != target + || pull_request_repositories( + &repository, + current_remote_config.as_deref(), + cached_repository, + ) != repositories + { + return Err(()); + } + let (pull_request, repository) = found.map_or((None, None), |(pull_request, repository)| { + (Some(pull_request), Some(repository)) + }); + Ok(PullRequestLookup { + pull_request, + repository, + }) +} + +async fn resolve_published_target( + cwd: &std::path::Path, + branch: &str, +) -> Result<(String, String, String), ()> { + let local_ref = format!("refs/heads/{branch}"); + let published = String::from_utf8(command_output( + cwd, + "git", + &[ + "for-each-ref", + "--format=%(push:remotename)%09%(push:remoteref)%09%(upstream:remotename)%09%(upstream:remoteref)%09%(push)", + &local_ref, + ], + ) + .await?) + .map_err(|_| ())?; + let (remote, published_branch) = published_target(Some(&published), branch); + let remote_url = String::from_utf8( + command_output(cwd, "git", &["remote", "get-url", "--push", &remote]).await?, + ) + .map_err(|_| ())?; + let repository = repository_locator(&remote_url).ok_or(())?; + Ok((remote, published_branch, repository)) +} + +fn github_host(url: &str) -> Option<&str> { + url.split_once("://") + .and_then(|(_, rest)| rest.split('/').next()) + .filter(|host| !host.is_empty()) +} + +fn repository_locator(remote_url: &str) -> Option { + let remote_url = remote_url.trim(); + let (authority, path) = if let Some((_, rest)) = remote_url.split_once("://") { + rest.split_once('/')? + } else { + remote_url.split_once(':')? + }; + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + let path = path.trim_matches('/'); + let path = path.strip_suffix(".git").unwrap_or(path); + (!host.is_empty() && path.split_once('/').is_some()).then(|| format!("{host}/{path}")) +} + +fn pull_request_repositories( + repository: &GhRepository, + remote_config: Option<&str>, + cached_repository: Option<&str>, +) -> Vec<(String, bool)> { + let host = github_host(&repository.url).unwrap_or_default(); + let mut repositories = Vec::new(); + let mut push = |repository: String, required: bool| { + if !repositories + .iter() + .any(|(existing, _): &(String, bool)| existing.eq_ignore_ascii_case(&repository)) + { + repositories.push((repository, required)); + } + }; + if let Some(parent) = &repository.parent { + push(format!("{}/{}", parent.owner.login, parent.name), true); + } + push(repository.name_with_owner.clone(), true); + for locator in remote_config + .into_iter() + .flat_map(str::lines) + .filter_map(|line| line.split_once(' ').map(|(_, url)| url)) + .filter_map(repository_locator) + { + let Some((candidate_host, repository)) = locator.split_once('/') else { + continue; + }; + if candidate_host.eq_ignore_ascii_case(host) { + push(repository.to_owned(), false); + } + } + if let Some(index) = cached_repository.and_then(|cached| { + repositories + .iter() + .position(|(repository, _)| repository.eq_ignore_ascii_case(cached)) + }) { + let (repository, _) = repositories.remove(index); + repositories.insert(0, (repository, true)); + } + repositories +} + +async fn gh_output(cwd: &std::path::Path, args: &[&str]) -> Result, ()> { + command_output(cwd, "gh", args).await +} + +fn published_target(metadata: Option<&str>, local_branch: &str) -> (String, String) { + let mut fields = metadata.unwrap_or_default().trim_end().split('\t'); + let push_remote = fields.next().unwrap_or_default(); + let push_branch = fields + .next() + .unwrap_or_default() + .strip_prefix("refs/heads/"); + let upstream_remote = fields.next().unwrap_or_default(); + let upstream_branch = fields + .next() + .unwrap_or_default() + .strip_prefix("refs/heads/"); + let push_tracking_branch = fields + .next() + .unwrap_or_default() + .strip_prefix(&format!("refs/remotes/{push_remote}/")); + + if !push_remote.is_empty() { + let branch = push_branch + .or(push_tracking_branch) + .or_else(|| { + (upstream_remote == push_remote) + .then_some(upstream_branch) + .flatten() + }) + .unwrap_or(local_branch); + return (push_remote.to_owned(), branch.to_owned()); + } + if !upstream_remote.is_empty() { + return ( + upstream_remote.to_owned(), + upstream_branch.unwrap_or(local_branch).to_owned(), + ); + } + ("origin".to_owned(), local_branch.to_owned()) +} + +async fn command_output( + cwd: &std::path::Path, + program: &str, + args: &[&str], +) -> Result, ()> { + let mut command = std::process::Command::new(program); + command + .args(args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + crate::platform::configure_background_command(&mut command); + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(5), command.output()) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + if !output.status.success() { + return Err(()); + } + Ok(output.stdout) +} + +#[cfg(test)] +fn parse_pull_request(json: &[u8]) -> Option { + let value: GhPullRequest = serde_json::from_slice(json).ok()?; + pull_request_info(value) +} + +fn pull_request_info(value: GhPullRequest) -> Option { + let state = match value.state.as_str() { + "open" if value.is_draft => PullRequestState::Draft, + "open" => PullRequestState::Open, + "closed" if value.merged_at.is_some() => PullRequestState::Merged, + "closed" => PullRequestState::Closed, + _ => return None, + }; + Some(PullRequestInfo { + number: value.number, + state, + }) +} + +fn pull_request_for_repository( + values: impl IntoIterator, + repository: &str, +) -> Option { + values + .into_iter() + .find(|pull_request| { + pull_request + .head + .repo + .as_ref() + .is_some_and(|head| head.name_with_owner.eq_ignore_ascii_case(repository)) + }) + .and_then(pull_request_info) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_all_github_pull_request_states() { + for (number, github_state, draft, state) in [ + (1, "open", false, PullRequestState::Open), + (2, "open", true, PullRequestState::Draft), + (3, "closed", false, PullRequestState::Closed), + (4, "merged", false, PullRequestState::Merged), + (5, "closed", true, PullRequestState::Closed), + ] { + let merged_at = (github_state == "merged").then_some(r#""2026-09-14T00:00:00Z""#); + let json = format!( + r#"{{"number":{number},"state":"{}","draft":{draft},"merged_at":{},"head":{{"repo":{{"full_name":"me/repo"}}}}}}"#, + if github_state == "merged" { + "closed" + } else { + github_state + }, + merged_at.unwrap_or("null"), + ); + assert_eq!( + parse_pull_request(json.as_bytes()).map(|value| value.state), + Some(state) + ); + } + } + + #[test] + fn published_target_uses_push_remote_and_remote_branch() { + assert_eq!( + published_target( + Some("fork\trefs/heads/published-name\torigin\trefs/heads/upstream-name\trefs/remotes/fork/published-name\n"), + "local-name" + ), + ("fork".into(), "published-name".into()) + ); + assert_eq!( + published_target( + Some("fork\t\tfork\trefs/heads/upstream-name\trefs/remotes/fork/published-name\n"), + "local-name" + ), + ("fork".into(), "published-name".into()) + ); + } + + #[test] + fn repository_locator_removes_credentials() { + assert_eq!( + repository_locator("https://user:secret@github.example.com/me/herdr.git\n"), + Some("github.example.com/me/herdr".into()) + ); + assert_eq!( + repository_locator("git@github.com:me/herdr.git"), + Some("github.com/me/herdr".into()) + ); + } + + #[test] + fn ignores_same_owner_branch_from_another_repository() { + let pages = serde_json::from_slice::>>(br#"[[ + {"number":1,"state":"open","draft":false,"merged_at":null,"head":{"repo":{"full_name":"me/tools"}}} + ],[ + {"number":2,"state":"open","draft":false,"merged_at":null,"head":{"repo":{"full_name":"me/app"}}} + ]]"#).unwrap(); + + assert_eq!( + pull_request_for_repository(pages.into_iter().flatten(), "me/app") + .map(|pull_request| pull_request.number), + Some(2) + ); + } + + #[test] + fn configured_repositories_cover_fork_network_bases() { + let repository = serde_json::from_slice::( + br#" + { + "nameWithOwner":"me/herdr", + "url":"https://github.com/me/herdr", + "parent":{"name":"herdr","owner":{"login":"upstream"}} + } + "#, + ) + .unwrap(); + let remotes = "remote.origin.url https://user:secret@github.com/me/herdr.git +remote.upstream.url git@github.com:top/herdr.git +remote.other.url https://git.example.com/other/herdr.git +"; + assert_eq!( + pull_request_repositories(&repository, Some(remotes), None), + vec![ + ("upstream/herdr".into(), true), + ("me/herdr".into(), true), + ("top/herdr".into(), false), + ] + ); + assert_eq!( + pull_request_repositories(&repository, Some(remotes), Some("top/herdr"))[0], + ("top/herdr".into(), true) + ); + assert_eq!( + pull_request_repositories(&repository, Some(remotes), Some("removed/herdr")), + pull_request_repositories(&repository, Some(remotes), None) + ); + } +} diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 99769e7b..10698c31 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -154,6 +154,9 @@ impl App { include_git_refresh .then(|| self.git_refresh_deadline()) .flatten(), + include_git_refresh + .then(|| self.pull_request_refresh_deadline()) + .flatten(), self.next_auto_update_check, self.next_agent_manifest_update_check, self.agent_metadata_deadline, diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index 2d16b00f..bcd579ea 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -125,6 +125,7 @@ impl ClientShellConfig { agents: config.ui.sidebar.agents.clone(), agent_panel_sort: config.ui.agent_panel_sort, status_indicators: config.ui.status_indicators, + pull_request_indicators: config.ui.pull_request_indicators, sound_enabled: config.ui.sound.enabled, toast_delivery: config.ui.toast.delivery, toast_delay_seconds: config.ui.toast.delay_seconds, @@ -327,6 +328,7 @@ impl ClientShellConfig { self.agents = ui.sidebar.agents.clone(); self.agent_panel_sort = ui.agent_panel_sort; self.status_indicators = ui.status_indicators; + self.pull_request_indicators = ui.pull_request_indicators; self.sound_enabled = ui.sound.enabled; self.toast_delivery = ui.toast.delivery; self.toast_delay_seconds = ui.toast.delay_seconds; diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs index bce484a3..0f960fff 100644 --- a/src/client/shell/endpoint_sidebar.rs +++ b/src/client/shell/endpoint_sidebar.rs @@ -304,6 +304,7 @@ pub(super) fn render_expanded( ), entry.indented, &config.spaces, + config.pull_request_indicators, ) .len() .max(1) @@ -420,6 +421,7 @@ pub(super) fn render_expanded( status, entry.indented, &config.spaces, + config.pull_request_indicators, ); let height = (tokens.len().max(1).min(u16::MAX as usize) as u16).min(body.height); if y.saturating_add(height) > body.bottom() { diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 1ce14feb..710b2aa5 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -1494,6 +1494,7 @@ impl ClientShellState { self.overlay, Some(ClientShellOverlay::Settings(ClientSettingsOverlay { section: ClientSettingsSection::Indicators + | ClientSettingsSection::PullRequests | ClientSettingsSection::Sound | ClientSettingsSection::Toast, .. diff --git a/src/client/shell/settings.rs b/src/client/shell/settings.rs index b4474bc7..e2705d7e 100644 --- a/src/client/shell/settings.rs +++ b/src/client/shell/settings.rs @@ -17,6 +17,14 @@ fn indicator_index(style: crate::config::StatusIndicatorStyle) -> usize { usize::from(style == crate::config::StatusIndicatorStyle::Symbols) } +fn pull_request_index(style: crate::config::PullRequestIndicatorStyle) -> usize { + match style { + crate::config::PullRequestIndicatorStyle::Off => 0, + crate::config::PullRequestIndicatorStyle::Symbols => 1, + crate::config::PullRequestIndicatorStyle::NerdFont => 2, + } +} + fn toast_index(delivery: crate::config::ToastDelivery) -> usize { match delivery { crate::config::ToastDelivery::Off => 0, @@ -49,6 +57,9 @@ impl ClientShellState { match section { ClientSettingsSection::Theme => theme_index(&self.config.theme_name), ClientSettingsSection::Indicators => indicator_index(self.config.status_indicators), + ClientSettingsSection::PullRequests => { + pull_request_index(self.config.pull_request_indicators) + } ClientSettingsSection::Sound => usize::from(!self.config.sound_enabled), ClientSettingsSection::Toast => toast_index(self.config.toast_delivery), ClientSettingsSection::Integrations => 0, @@ -98,6 +109,7 @@ impl ClientShellState { Some(ClientShellOverlay::Settings(settings)) => match settings.section { ClientSettingsSection::Theme => crate::config::THEME_NAMES.len(), ClientSettingsSection::Indicators | ClientSettingsSection::Sound => 2, + ClientSettingsSection::PullRequests => 3, ClientSettingsSection::Toast => 4, ClientSettingsSection::Integrations => settings.integrations.len(), }, @@ -207,6 +219,17 @@ impl ClientShellState { outcome, ); } + ClientSettingsSection::PullRequests => { + let style = match selected { + 0 => crate::config::PullRequestIndicatorStyle::Off, + 1 => crate::config::PullRequestIndicatorStyle::Symbols, + _ => crate::config::PullRequestIndicatorStyle::NerdFont, + }; + self.save_settings_edit( + crate::config::ConfigEdit::PullRequestIndicators(style), + outcome, + ); + } ClientSettingsSection::Sound => { self.save_settings_edit(crate::config::ConfigEdit::Sound(selected == 0), outcome); } diff --git a/src/client/shell/settings_overlay.rs b/src/client/shell/settings_overlay.rs index 25f37b2a..1d82332d 100644 --- a/src/client/shell/settings_overlay.rs +++ b/src/client/shell/settings_overlay.rs @@ -71,6 +71,7 @@ pub(super) fn render_settings_overlay( .iter() .any(|integration| integration.state == crate::api::schema::IntegrationState::Outdated); let mut tab_x = inner.x; + let mut tab_y = inner.y + 1; let mut tab_hits = Vec::new(); for section in ClientSettingsSection::ALL { let badge = *section == ClientSettingsSection::Integrations && integration_badge; @@ -79,8 +80,13 @@ pub(super) fn render_settings_overlay( } else { format!(" {} ", section.label()) }; - let width = display_width(&label).min(inner.right().saturating_sub(tab_x)); - let rect = Rect::new(tab_x, inner.y + 1, width, 1); + let label_width = display_width(&label); + if tab_x > inner.x && tab_x.saturating_add(label_width) > inner.right() { + tab_x = inner.x; + tab_y = tab_y.saturating_add(1); + } + let width = label_width.min(inner.right().saturating_sub(tab_x)); + let rect = Rect::new(tab_x, tab_y, width, 1); let active = *section == settings.section; let style = if active { Style::default() @@ -107,24 +113,23 @@ pub(super) fn render_settings_overlay( } tab_hits.push((rect, *section)); tab_x = tab_x.saturating_add(width.saturating_add(1)); - if tab_x >= inner.right() { - break; - } } + let tabs_bottom = tab_y.saturating_add(1); put_text( buffer, inner.x, - inner.y + 2, + tabs_bottom, inner.width, &"─".repeat(inner.width as usize), Style::default().fg(palette.surface0).bg(palette.panel_bg), ); + let content_y = tabs_bottom.saturating_add(2); let content = Rect::new( inner.x, - inner.y + 4, + content_y, inner.width, - inner.height.saturating_sub(7), + inner.bottom().saturating_sub(3).saturating_sub(content_y), ); let mut choice_hits = Vec::new(); match settings.section { @@ -170,6 +175,22 @@ pub(super) fn render_settings_overlay( &mut choice_hits, ); } + ClientSettingsSection::PullRequests => { + render_choice_section( + buffer, + content, + "pull request indicators", + "show the current branch pull request in spaces", + &[ + "off", + "portable symbols ○ ◇ × ◆", + "GitHub icons (Nerd Font)    ", + ], + settings.selected, + palette, + &mut choice_hits, + ); + } ClientSettingsSection::Sound => { render_choice_section( buffer, @@ -283,7 +304,8 @@ fn render_choice_section( description, Style::default().fg(palette.overlay1).bg(palette.panel_bg), ); - let row_gap = u16::from(choices.len() > 2); + let row_gap = + u16::from(choices.len() > 2 && area.height >= (choices.len() as u16).saturating_mul(2) + 2); for (index, choice) in choices.iter().enumerate() { let y = area.y + 3 + index as u16 * (1 + row_gap); if y >= area.bottom() { diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index 161aecd5..c84f4d6b 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -232,6 +232,7 @@ pub(crate) fn render_sidebar( displayed_workspace_status(snapshot, workspace, state.collapsed_groups), entry.indented, &config.spaces, + config.pull_request_indicators, ) .len() .max(1) @@ -288,7 +289,13 @@ pub(crate) fn render_sidebar( continue; }; let status = displayed_workspace_status(snapshot, workspace, state.collapsed_groups); - let rows = workspace_rows(workspace, status, entry.indented, &config.spaces); + let rows = workspace_rows( + workspace, + status, + entry.indented, + &config.spaces, + config.pull_request_indicators, + ); let row_height = (rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); if y.saturating_add(row_height) > body.bottom() { break; @@ -612,6 +619,7 @@ pub(in crate::client::shell) fn workspace_rows( status: crate::api::schema::AgentStatus, indented: bool, config: &SpacesSidebarConfig, + pull_request_style: crate::config::PullRequestIndicatorStyle, ) -> Vec> { let label = if indented && !workspace.custom_label { workspace @@ -623,7 +631,7 @@ pub(in crate::client::shell) fn workspace_rows( &workspace.label }; let token_values = workspace.tokens.iter().cloned().collect::>(); - crate::ui::sidebar_space_rows( + let mut rows = crate::ui::sidebar_space_rows( config, crate::ui::SpaceTokenContext { workspace: label, @@ -633,7 +641,51 @@ pub(in crate::client::shell) fn workspace_rows( tokens: &token_values, suppress_git_details: indented, }, - ) + ); + if pull_request_style == crate::config::PullRequestIndicatorStyle::Off { + return rows; + } + let Some(pull_request) = workspace.pull_request.as_ref() else { + return rows; + }; + let icon = match (pull_request_style, pull_request.state) { + ( + crate::config::PullRequestIndicatorStyle::NerdFont, + crate::workspace::PullRequestState::Open, + ) => "", + ( + crate::config::PullRequestIndicatorStyle::NerdFont, + crate::workspace::PullRequestState::Draft, + ) => "", + ( + crate::config::PullRequestIndicatorStyle::NerdFont, + crate::workspace::PullRequestState::Closed, + ) => "", + ( + crate::config::PullRequestIndicatorStyle::NerdFont, + crate::workspace::PullRequestState::Merged, + ) => "", + (_, crate::workspace::PullRequestState::Open) => "○", + (_, crate::workspace::PullRequestState::Draft) => "◇", + (_, crate::workspace::PullRequestState::Closed) => "×", + (_, crate::workspace::PullRequestState::Merged) => "◆", + (_, crate::workspace::PullRequestState::Unknown) => return rows, + }; + let token = crate::ui::ResolvedToken::unstyled(crate::ui::ResolvedTokenKind::PullRequest { + text: format!("{icon} #{}", pull_request.number), + state: pull_request.state, + }); + if indented { + rows.push(vec![token]); + } else if let Some(row) = rows.iter_mut().find(|row| { + row.iter() + .any(|token| matches!(token.kind, crate::ui::ResolvedTokenKind::Branch(_))) + }) { + row.push(token); + } else { + rows.push(vec![token]); + } + rows } pub(in crate::client::shell) fn render_workspace_rows( diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 8f2aec43..d591b740 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -82,6 +82,7 @@ pub(crate) struct ClientShellConfig { pub(super) agents: crate::config::AgentsSidebarConfig, pub(super) agent_panel_sort: crate::config::AgentPanelSortConfig, pub(super) status_indicators: crate::config::StatusIndicatorStyle, + pub(super) pull_request_indicators: crate::config::PullRequestIndicatorStyle, pub(super) sound_enabled: bool, pub(super) toast_delivery: crate::config::ToastDelivery, pub(super) toast_delay_seconds: u64, @@ -441,6 +442,7 @@ pub(super) struct ClientGlobalMenuOverlay { pub(super) enum ClientSettingsSection { Theme, Indicators, + PullRequests, Sound, Toast, Integrations, @@ -450,6 +452,7 @@ impl ClientSettingsSection { pub(super) const ALL: &[Self] = &[ Self::Theme, Self::Indicators, + Self::PullRequests, Self::Sound, Self::Toast, Self::Integrations, @@ -458,7 +461,8 @@ impl ClientSettingsSection { pub(super) fn label(self) -> &'static str { match self { Self::Theme => "theme", - Self::Indicators => "indicators", + Self::Indicators => "status", + Self::PullRequests => "PRs", Self::Sound => "sound", Self::Toast => "toasts", Self::Integrations => "integrations", diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index c89991b1..40fd2765 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -1,5 +1,69 @@ use super::*; +#[test] +fn pull_request_uses_main_branch_row_and_linked_worktree_row() { + let mut workspace = snapshot().workspaces.remove(0); + workspace.pull_request = Some(crate::workspace::PullRequestInfo { + number: 123, + state: crate::workspace::PullRequestState::Open, + }); + let config = crate::config::SpacesSidebarConfig::default(); + + let main = super::super::sidebar::workspace_rows( + &workspace, + AgentStatus::Idle, + false, + &config, + crate::config::PullRequestIndicatorStyle::Symbols, + ); + assert!(matches!( + main[1][1].kind, + crate::ui::ResolvedTokenKind::PullRequest { .. } + )); + + let linked = super::super::sidebar::workspace_rows( + &workspace, + AgentStatus::Idle, + true, + &config, + crate::config::PullRequestIndicatorStyle::Symbols, + ); + assert_eq!(linked.len(), 2); + assert!(matches!( + linked[1][0].kind, + crate::ui::ResolvedTokenKind::PullRequest { .. } + )); + + let config = crate::config::SpacesSidebarConfig { + rows: vec![vec![crate::config::SpaceSidebarToken::Workspace]], + ..Default::default() + }; + let branchless = super::super::sidebar::workspace_rows( + &workspace, + AgentStatus::Idle, + false, + &config, + crate::config::PullRequestIndicatorStyle::Symbols, + ); + assert!(matches!( + branchless[1][0].kind, + crate::ui::ResolvedTokenKind::PullRequest { .. } + )); + + workspace.pull_request.as_mut().unwrap().state = crate::workspace::PullRequestState::Unknown; + let unknown = super::super::sidebar::workspace_rows( + &workspace, + AgentStatus::Idle, + false, + &config, + crate::config::PullRequestIndicatorStyle::Symbols, + ); + assert!(unknown + .iter() + .flatten() + .all(|token| !matches!(token.kind, crate::ui::ResolvedTokenKind::PullRequest { .. }))); +} + #[test] fn mouse_hits_use_stable_workspace_tab_and_pane_ids() { let config = ClientShellConfig::from_config(&Config::default()); @@ -100,6 +164,7 @@ fn grouped_worktrees_render_parent_branch_and_indented_child() { is_linked_worktree: false, }); snapshot.workspaces.push(ClientShellWorkspace { + pull_request: None, workspace_id: "ws_2".into(), active_tab_id: "tab_ws2".into(), new_workspace_cwd: "/repo/feature".into(), diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs index 0c6f1908..b0dca077 100644 --- a/src/client/shell/tests/mobile.rs +++ b/src/client/shell/tests/mobile.rs @@ -335,6 +335,7 @@ fn mobile_background_workspace_uses_its_own_active_tab_status() { agent_status: AgentStatus::Idle, }); projected.workspaces.push(ClientShellWorkspace { + pull_request: None, workspace_id: "ws_2".into(), active_tab_id: "tab_3".into(), new_workspace_cwd: "/feature".into(), @@ -534,6 +535,7 @@ fn mobile_previous_workspace_action_wraps_across_expanded_entries() { let mut projected = snapshot(); for index in 2..=3 { projected.workspaces.push(ClientShellWorkspace { + pull_request: None, workspace_id: format!("ws_{index}"), active_tab_id: format!("tab_{index}"), new_workspace_cwd: "/tmp".into(), @@ -574,6 +576,7 @@ fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() { let mut projected = snapshot(); for index in 2..=8 { projected.workspaces.push(ClientShellWorkspace { + pull_request: None, workspace_id: format!("ws_{index}"), active_tab_id: format!("tab_{index}"), new_workspace_cwd: "/tmp".into(), diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index b6b600bc..6d3911fe 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -27,6 +27,7 @@ pub(super) fn snapshot() -> ClientShellSnapshot { agent_view_label: None, agent_order: Vec::new(), workspaces: vec![ClientShellWorkspace { + pull_request: None, workspace_id: "ws_1".into(), active_tab_id: "tab_1".into(), new_workspace_cwd: "/repo".into(), diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs index 7107aa71..7f04ba46 100644 --- a/src/client/shell/tests/startup_overlays.rs +++ b/src/client/shell/tests/startup_overlays.rs @@ -1176,7 +1176,7 @@ fn client_settings_preview_restore_and_endpoint_integrations_are_owned_by_overla state.open_settings_overlay(); state.compose(106, 30).expect("settings overlay"); - for _ in 0..3 { + for _ in 0..4 { let next = state.handle_input_bytes(b"\t"); assert!(next.actions.is_empty()); } @@ -1294,3 +1294,23 @@ fn client_settings_preview_restore_and_endpoint_integrations_are_owned_by_overla })) if integration_messages == &["installed codex"] )); } +#[test] +fn narrow_settings_keep_every_section_mouse_accessible() { + let mut config = Config::default(); + config.ui.mobile_width_threshold = 0; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.open_settings_overlay(); + state.select_settings_section( + ClientSettingsSection::Toast, + &mut ClientShellInput::default(), + ); + state.compose(46, 30).expect("narrow settings overlay"); + + assert_eq!( + state.hits.settings_tabs.len(), + ClientSettingsSection::ALL.len() + ); + assert_eq!(state.hits.settings_choices.len(), 4); +} diff --git a/src/config.rs b/src/config.rs index 60a22a1e..f1b00289 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,9 +24,9 @@ pub use self::{ model::{ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, PaneBordersConfig, - ShellModeConfig, SidebarCollapsedModeConfig, StatusIndicatorStyle, TabBarPositionConfig, - ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition, - UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, + PullRequestIndicatorStyle, ShellModeConfig, SidebarCollapsedModeConfig, + StatusIndicatorStyle, TabBarPositionConfig, ToastClipboardPosition, ToastConfig, + ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, }, sidebar::{ AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SidebarTokenStyle, diff --git a/src/config/model.rs b/src/config/model.rs index 6ef6274d..e79f5818 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -116,6 +116,25 @@ pub enum StatusIndicatorStyle { Symbols, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PullRequestIndicatorStyle { + Off, + #[default] + Symbols, + NerdFont, +} + +impl PullRequestIndicatorStyle { + pub fn as_str(self) -> &'static str { + match self { + Self::Off => "off", + Self::Symbols => "symbols", + Self::NerdFont => "nerd_font", + } + } +} + impl StatusIndicatorStyle { pub fn as_str(self) -> &'static str { match self { @@ -962,6 +981,8 @@ pub struct UiConfig { _legacy_agent_panel_scope: Option, /// Agent status indicator style. Saved values are "dots" or "symbols". Default: "dots". pub status_indicators: StatusIndicatorStyle, + /// Pull request indicator style. Saved values are "off", "symbols", or "nerd_font". + pub pull_request_indicators: PullRequestIndicatorStyle, /// Expanded sidebar row composition. pub sidebar: SidebarConfig, /// Accent color for highlights, borders, and navigation UI. @@ -1186,6 +1207,7 @@ impl Default for UiConfig { agent_panel_sort: AgentPanelSortConfig::Spaces, _legacy_agent_panel_scope: None, status_indicators: StatusIndicatorStyle::Dots, + pull_request_indicators: PullRequestIndicatorStyle::Symbols, sidebar: SidebarConfig::default(), accent: "cyan".into(), toast: ToastConfig::default(), diff --git a/src/config/write.rs b/src/config/write.rs index f34ec2f6..fc8bbbe8 100644 --- a/src/config/write.rs +++ b/src/config/write.rs @@ -2,6 +2,7 @@ pub(crate) enum ConfigEdit<'a> { Theme(&'a str), StatusIndicators(super::StatusIndicatorStyle), + PullRequestIndicators(super::PullRequestIndicatorStyle), Sound(bool), ToastDelivery(super::ToastDelivery), } @@ -11,6 +12,7 @@ impl ConfigEdit<'_> { match self { Self::Theme(_) => "theme", Self::StatusIndicators(_) => "status indicators", + Self::PullRequestIndicators(_) => "pull request indicators", Self::Sound(_) => "sound setting", Self::ToastDelivery(_) => "toast setting", } @@ -29,6 +31,12 @@ impl ConfigEdit<'_> { "status_indicators", &format!("\"{}\"", style.as_str()), ), + Self::PullRequestIndicators(style) => super::upsert_section_value( + content, + "ui", + "pull_request_indicators", + &format!("\"{}\"", style.as_str()), + ), Self::Sound(enabled) => { super::upsert_section_bool(content, "ui.sound", "enabled", enabled) } diff --git a/src/events.rs b/src/events.rs index 95638991..d4087851 100644 --- a/src/events.rs +++ b/src/events.rs @@ -61,7 +61,10 @@ pub enum AppEvent { exit_reason: crate::platform::ChildExitReason, }, /// A worktree-removal runtime could not be restored normally. - WorktreeRuntimeRestoreFailed { pane_id: PaneId, operation_id: u64 }, + WorktreeRuntimeRestoreFailed { + pane_id: PaneId, + operation_id: u64, + }, /// Process detection identified an agent before its screen state was confirmed. AgentProcessDetected { pane_id: PaneId, @@ -139,10 +142,15 @@ pub enum AppEvent { }, /// A pane child emitted one or more executable BEL characters. /// The host-facing process forwards them to its outer terminal. - TerminalBell { pane_id: PaneId, count: u16 }, + TerminalBell { + pane_id: PaneId, + count: u16, + }, /// A pane child emitted a valid OSC 52 clipboard write. The main loop /// re-emits it through herdr's own clipboard writer. - ClipboardWrite { content: Vec }, + ClipboardWrite { + content: Vec, + }, /// A pane child reported its shell current directory through terminal /// metadata such as OSC 7. TerminalCwdReported { @@ -154,6 +162,7 @@ pub enum AppEvent { results: Vec, cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>, }, + PullRequestsRefreshed(Vec), /// A configured tab bar status command finished. TabBarCommandFinished { generation: u64, diff --git a/src/main.rs b/src/main.rs index 8cfe9b0b..53d2b10c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -334,6 +334,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # distinct static glyphs for blocked, working, done, idle, and unknown states. # status_indicators = "dots" +# Current-branch pull requests via gh: "off", "symbols", or "nerd_font". +# pull_request_indicators = "symbols" + # Accent color for highlights, borders, and navigation UI. # Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b) # accent = "cyan" diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 3c5775c9..3db884a8 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -415,6 +415,8 @@ fn restore_workspace( cached_auto_label, cached_git_status_key, cached_git_branch: crate::workspace::git_branch(&snap.identity_cwd), + cached_pull_request: None, + cached_pull_request_repository: None, cached_git_ahead_behind: None, cached_git_space, worktree_space, diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 052c73a3..53c8efb4 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 22; +pub const PROTOCOL_VERSION: u32 = 23; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -1018,6 +1018,8 @@ pub struct ClientShellWorkspace { pub custom_label: bool, pub branch: Option, pub git_ahead_behind: Option<(usize, usize)>, + #[serde(default)] + pub pull_request: Option, pub tokens: Vec<(String, String)>, pub worktree: Option, pub focused: bool, @@ -2691,6 +2693,7 @@ mod tests { agent_view_label: None, agent_order: Vec::new(), workspaces: vec![ClientShellWorkspace { + pull_request: None, workspace_id: "w1".into(), active_tab_id: "w1:t1".into(), new_workspace_cwd: "/tmp".into(), diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 6a1ee6f9..d14e96c2 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -4572,11 +4572,11 @@ mod tests { }; // Captured from Rohan after installing a new binary while the old daemon stayed alive. let installed = parse_client_status_json( - r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","presentation_effects_fence","health_check"]}"#, + r#"{"version":"0.8.2","protocol":23,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","presentation_effects_fence","health_check"]}"#, ) .unwrap(); let running_binary = parse_client_status_json( - r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","health_check"]}"#, + r#"{"version":"0.8.2","protocol":23,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","health_check"]}"#, ) .unwrap(); assert!(installed.supports_endpoint_requirement(&linux, true)); @@ -4603,7 +4603,7 @@ mod tests { detached_server_daemon, .. } = parse_remote_server_status_json( - r#"{"status":"running","running":true,"version":"0.8.2","protocol":22,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"#, + r#"{"status":"running","running":true,"version":"0.8.2","protocol":23,"capabilities":{"live_handoff":true,"detached_server_daemon":true,"endpoint_protocol_generation":1,"surface_interest":true,"health_check":true}}"#, ) .unwrap() .with_endpoint_negotiation(&live_negotiation) else { diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index 37470693..0f506279 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -63,6 +63,7 @@ pub(super) fn snapshot( custom_label: state.custom_name.is_some(), branch: state.branch(), git_ahead_behind: state.git_ahead_behind(), + pull_request: state.cached_pull_request.clone(), tokens, worktree: workspace .worktree diff --git a/src/server/headless.rs b/src/server/headless.rs index beda84b5..f3ca2552 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -3351,6 +3351,7 @@ impl HeadlessServer { if self.has_app_client() { self.app.start_git_status_refresh_if_due(now); + self.app.start_pull_request_refresh_if_due(now); } if self diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index cbf6f1a9..97b3f1be 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -122,6 +122,7 @@ pub(crate) fn resolved_token_spans( + usize::from(*behind > 0) * display_width(&format!("↓{behind}")) + usize::from(*ahead > 0 && *behind > 0) } + ResolvedTokenKind::PullRequest { text, .. } => display_width(text), _ => 0, }) .collect::>(); @@ -263,6 +264,19 @@ pub(crate) fn resolved_token_spans( )); } } + ResolvedTokenKind::PullRequest { text, state } => { + let color = match state { + crate::workspace::PullRequestState::Open => palette.green, + crate::workspace::PullRequestState::Draft => palette.overlay1, + crate::workspace::PullRequestState::Closed => palette.red, + crate::workspace::PullRequestState::Merged => palette.mauve, + crate::workspace::PullRequestState::Unknown => continue, + }; + spans.push(Span::styled( + text.clone(), + apply_token_style(Style::default().fg(color), token.style), + )); + } ResolvedTokenKind::TerminalTitle(text) | ResolvedTokenKind::Custom(text) => { spans.push(Span::styled( truncate_end(text, budgets[index]), diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index c1e38aa9..5300b185 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -20,7 +20,14 @@ pub(crate) enum ResolvedTokenKind { Agent(String), TerminalTitle(String), Branch(String), - GitStatus { ahead: usize, behind: usize }, + GitStatus { + ahead: usize, + behind: usize, + }, + PullRequest { + text: String, + state: crate::workspace::PullRequestState, + }, Custom(String), } @@ -36,7 +43,7 @@ impl ResolvedTokenKind { | Self::TerminalTitle(value) | Self::Branch(value) | Self::Custom(value) => Some(value), - Self::StateIcon | Self::GitStatus { .. } => None, + Self::StateIcon | Self::GitStatus { .. } | Self::PullRequest { .. } => None, } } } @@ -46,8 +53,7 @@ impl ResolvedToken { Self { kind, style } } - #[cfg(test)] - pub(super) fn unstyled(kind: ResolvedTokenKind) -> Self { + pub(crate) fn unstyled(kind: ResolvedTokenKind) -> Self { Self::new(kind, SidebarTokenStyle::default()) } } diff --git a/src/workspace.rs b/src/workspace.rs index b4bba45d..8666d400 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -38,6 +38,23 @@ pub struct WorktreeSpaceMembership { pub is_linked_worktree: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PullRequestState { + Open, + Draft, + Closed, + Merged, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PullRequestInfo { + pub number: u64, + pub state: PullRequestState, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceGitStatus { pub workspace_id: String, @@ -188,6 +205,8 @@ pub struct Workspace { pub(crate) cached_git_status_key: PathBuf, /// Cached current git branch for the workspace repo. pub(crate) cached_git_branch: Option, + pub(crate) cached_pull_request: Option, + pub(crate) cached_pull_request_repository: Option, /// Cached ahead/behind counts for the workspace repo's current branch upstream. pub(crate) cached_git_ahead_behind: Option<(usize, usize)>, /// Cached derived Git repo metadata for worktree actions and status display. @@ -257,6 +276,8 @@ impl Workspace { cached_auto_label, cached_git_status_key, cached_git_branch: git_branch(&identity_cwd), + cached_pull_request: None, + cached_pull_request_repository: None, cached_git_ahead_behind: None, cached_git_space, worktree_space: None, @@ -409,6 +430,8 @@ impl Workspace { cached_auto_label, cached_git_status_key, cached_git_branch: git_branch(&initial_cwd), + cached_pull_request: None, + cached_pull_request_repository: None, cached_git_ahead_behind: None, cached_git_space, worktree_space: None, @@ -1202,6 +1225,8 @@ impl Workspace { cached_auto_label: fallback_label_from_cwd(&identity_cwd), cached_git_status_key: identity_cwd.clone(), cached_git_branch: git_branch(&identity_cwd), + cached_pull_request: None, + cached_pull_request_repository: None, cached_git_ahead_behind: None, cached_git_space: None, worktree_space: None, @@ -1432,6 +1457,14 @@ impl Workspace { mod tests { use super::*; + #[test] + fn unknown_pull_request_state_is_tolerated() { + let pull_request: PullRequestInfo = + serde_json::from_str(r#"{"number":42,"state":"future_state"}"#).unwrap(); + + assert_eq!(pull_request.state, PullRequestState::Unknown); + } + #[test] fn generated_workspace_ids_are_short_base32_handles() { let first = generate_workspace_id(); diff --git a/tests/api_ping.rs b/tests/api_ping.rs index b86d0378..fa45b21e 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -306,7 +306,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION")); // Intentionally hardcoded so wire protocol bumps require updating this test. // Changing this value means old clients/servers are no longer compatible. - assert_eq!(value["result"]["protocol"], 22); + assert_eq!(value["result"]["protocol"], 23); cleanup_spawned_herdr(child, base); } diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index 5a887698..dbecd8f8 100644 --- a/tests/cli/sessions.rs +++ b/tests/cli/sessions.rs @@ -389,7 +389,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {full_stdout}" ); assert!( - full_stdout.contains(" protocol: 22"), + full_stdout.contains(" protocol: 23"), "stdout: {full_stdout}" ); assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); @@ -430,7 +430,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {server_stdout}" ); assert!( - server_stdout.contains("private_protocol: 22"), + server_stdout.contains("private_protocol: 23"), "stdout: {server_stdout}" ); @@ -442,7 +442,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 22"), + client_stdout.contains("protocol: 23"), "stdout: {client_stdout}" ); assert!( @@ -456,7 +456,7 @@ fn status_commands_report_client_and_server_versions() { let full_json = run_cli_json(&socket_path, &["status", "--json"]); assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(full_json["client"]["protocol"], 22); + assert_eq!(full_json["client"]["protocol"], 23); assert_eq!(full_json["client"]["endpoint_protocol_generation"], 1); assert_eq!(full_json["client"]["remote_host_bridge"], true); assert_eq!(full_json["server"]["status"], "running"); @@ -475,13 +475,13 @@ fn status_commands_report_client_and_server_versions() { let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); assert_eq!(server_json["status"], "running"); assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(server_json["protocol"], 22); + assert_eq!(server_json["protocol"], 23); assert_eq!(server_json["compatible"], true); assert_eq!(server_json["endpoint_compatible"], true); let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(client_json["protocol"], 22); + assert_eq!(client_json["protocol"], 23); assert_eq!(client_json["endpoint_protocol_generation"], 1); assert_eq!(client_json["remote_host_bridge"], true); assert!(client_json["binary"] diff --git a/tests/support/mod.rs b/tests/support/mod.rs index cc29afe9..2629e9f0 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -13,7 +13,7 @@ static INIT: Once = Once::new(); static CLEANUP_GUARD: OnceLock = OnceLock::new(); const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1); const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid"; -pub const CURRENT_PROTOCOL: u32 = 22; +pub const CURRENT_PROTOCOL: u32 = 23; pub const CURRENT_ENDPOINT_PROTOCOL_GENERATION: u32 = 1; pub const SERVER_MESSAGE_SERVER_SHUTDOWN: u32 = 3; pub const SERVER_MESSAGE_ENDPOINT_CONTROL: u32 = 20;