mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
feat: show GitHub pull requests in spaces
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) = ⌖
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1494,6 +1494,7 @@ impl ClientShellState {
|
||||
self.overlay,
|
||||
Some(ClientShellOverlay::Settings(ClientSettingsOverlay {
|
||||
section: ClientSettingsSection::Indicators
|
||||
| ClientSettingsSection::PullRequests
|
||||
| ClientSettingsSection::Sound
|
||||
| ClientSettingsSection::Toast,
|
||||
..
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user