feat: show GitHub pull requests in spaces

This commit is contained in:
Jonathan Liebig
2026-09-14 08:20:08 +02:00
parent c77af1892f
commit cb47f8a602
36 changed files with 1056 additions and 52 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"protocol": 22,
"protocol": 23,
"schema_version": 1,
"schemas": {
"error_response": {
@@ -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:
@@ -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",
+18 -1
View File
@@ -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);
+33
View File
@@ -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,
+18 -14
View File
@@ -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]
+55
View File
@@ -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<std::path::PathBuf, crate::workspace::GitStatusCacheEntry>,
@@ -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();
+575
View File
@@ -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<PullRequestLookup, ()>,
}
#[derive(Debug, Clone)]
pub(crate) struct PullRequestLookup {
pub pull_request: Option<PullRequestInfo>,
pub repository: Option<String>,
}
#[derive(Deserialize)]
struct GhPullRequest {
number: u64,
state: String,
#[serde(rename = "draft")]
is_draft: bool,
#[serde(default)]
merged_at: Option<String>,
head: GhPullRequestHead,
}
#[derive(Deserialize)]
struct GhPullRequestHead {
repo: Option<GhRepositoryName>,
}
#[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<GhRepositoryParent>,
}
#[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<Instant> {
(!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::<Vec<_>>();
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<WorkspacePullRequest>,
) -> 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<PullRequestLookup, ()> {
let target = resolve_published_target(cwd, branch).await?;
let (_, published_branch, repository_locator) = &target;
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::<Vec<Vec<GhPullRequest>>>(&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<String> {
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<Vec<u8>, ()> {
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<Vec<u8>, ()> {
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<PullRequestInfo> {
let value: GhPullRequest = serde_json::from_slice(json).ok()?;
pull_request_info(value)
}
fn pull_request_info(value: GhPullRequest) -> Option<PullRequestInfo> {
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<Item = GhPullRequest>,
repository: &str,
) -> Option<PullRequestInfo> {
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::<Vec<Vec<GhPullRequest>>>(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::<GhRepository>(
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)
);
}
}
+3
View File
@@ -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,
+2
View File
@@ -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;
+2
View File
@@ -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() {
+1
View File
@@ -1494,6 +1494,7 @@ impl ClientShellState {
self.overlay,
Some(ClientShellOverlay::Settings(ClientSettingsOverlay {
section: ClientSettingsSection::Indicators
| ClientSettingsSection::PullRequests
| ClientSettingsSection::Sound
| ClientSettingsSection::Toast,
..
+23
View File
@@ -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);
}
+31 -9
View File
@@ -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() {
+55 -3
View File
@@ -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<Vec<crate::ui::ResolvedToken>> {
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::<HashMap<_, _>>();
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(
+5 -1
View File
@@ -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",
@@ -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(),
+3
View File
@@ -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(),
+1
View File
@@ -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(),
+21 -1
View File
@@ -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);
}
+3 -3
View File
@@ -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,
+22
View File
@@ -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<LegacyAgentPanelScopeConfig>,
/// 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(),
+8
View File
@@ -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)
}
+12 -3
View File
@@ -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<u8> },
ClipboardWrite {
content: Vec<u8>,
},
/// 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<WorkspaceGitStatus>,
cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
},
PullRequestsRefreshed(Vec<crate::app::pull_requests::WorkspacePullRequest>),
/// A configured tab bar status command finished.
TabBarCommandFinished {
generation: u64,
+3
View File
@@ -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"
+2
View File
@@ -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,
+4 -1
View File
@@ -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<String>,
pub git_ahead_behind: Option<(usize, usize)>,
#[serde(default)]
pub pull_request: Option<crate::workspace::PullRequestInfo>,
pub tokens: Vec<(String, String)>,
pub worktree: Option<ClientShellWorktree>,
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(),
+3 -3
View File
@@ -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 {
+1
View File
@@ -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
+1
View File
@@ -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
+14
View File
@@ -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::<Vec<_>>();
@@ -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]),
+10 -4
View File
@@ -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())
}
}
+33
View File
@@ -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<String>,
pub(crate) cached_pull_request: Option<PullRequestInfo>,
pub(crate) cached_pull_request_repository: Option<String>,
/// 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();
+1 -1
View File
@@ -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);
}
+6 -6
View File
@@ -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"]
+1 -1
View File
@@ -13,7 +13,7 @@ static INIT: Once = Once::new();
static CLEANUP_GUARD: OnceLock<CleanupGuard> = 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;