first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -0,0 +1,389 @@
use std::path::PathBuf;
use std::time::Duration;
use settings::Setting as _;
use warpui::r#async::SpawnedFutureHandle;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity as _};
use super::GitHubRepoEvent;
use crate::code_review::git_repo_model::{GitRepoStatusEvent, GitRepoStatusModel};
use crate::report_if_error;
#[cfg(feature = "local_tty")]
use crate::terminal::local_shell::LocalShellState;
use crate::terminal::session_settings::{GithubPrPromptChipDefaultValidation, SessionSettings};
use crate::util::git::{
get_pr_for_branch, get_repository_info, is_gh_auth_error, is_gh_missing_error, PrInfo,
RepositoryInfo,
};
const PR_INFO_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
const GITHUB_INFO_PERIODIC_REFRESH: Duration = Duration::from_secs(60);
const REPOSITORY_INFO_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
/// Per-repository model that owns the GitHub-sourced metadata lifecycle for a
/// single repo — the values fetched through the (relatively expensive) `gh`
/// CLI rather than local `git`:
/// - `pr_info` for the current branch (`gh pr view`), and
/// - `repository_info` (name/owner) for the repo (`gh repo view`).
///
/// `GitHubRepoModel` is created lazily when a consumer asks for it via
/// [`crate::code_review::git_repo_model::GitRepoModels::subscribe_github_repo`].
/// While at least one strong `ModelHandle<GitHubRepoModel>` is alive, the model:
/// - tracks the current branch by subscribing to its sibling
/// [`GitRepoStatusModel`] for `MetadataChanged` events,
/// - fetches `gh pr view` for the current branch on creation, on branch
/// change, and on a periodic timer,
/// - fetches `gh repo view` on creation and re-checks it on the periodic
/// timer (independent of the branch), and
/// - emits [`GitHubRepoEvent`] when the cached PR or repository info moves.
///
/// `repository_info` is intentionally NOT refreshed on branch change: the
/// repo's name/owner does not depend on the checked-out branch, so a branch
/// flip must not trigger a fresh `gh repo view`.
///
/// When the last strong handle is dropped, the model is torn down and any
/// in-flight `gh` fetch is aborted. The sibling [`GitRepoStatusModel`] is
/// retained via a strong handle, so creating a `LocalGitHubRepoModel` keeps git
/// status alive for as long as GitHub info is needed.
pub struct LocalGitHubRepoModel {
repo_path: PathBuf,
/// Strong handle to the sibling git-status model. Keeps it alive so we
/// always have a branch source.
git_status: ModelHandle<GitRepoStatusModel>,
/// Current branch name, mirrored from `git_status`. `None` until the
/// sibling's metadata is available.
branch: Option<String>,
/// PR info for `branch`. `None` means no fetch has succeeded yet, the
/// branch has no PR, or fetching is suppressed (gh missing/auth error).
pr_info: Option<PrInfo>,
/// Repository info (name/owner) returned by `gh repo view`. Branch-
/// independent; fetched on creation and re-checked on the periodic tick.
repository_info: Option<RepositoryInfo>,
/// Handle for the in-flight `gh pr view` fetch, if any. Aborted in `Drop`.
/// Used to avoid overlapping PR-info fetches; branch changes abort the
/// current handle before starting a new branch's fetch.
refreshing_pr_info_abort_handle: Option<SpawnedFutureHandle>,
/// Handle for the in-flight `gh repo view` fetch, if any. Aborted in
/// `Drop`. Used to avoid overlapping repository-info fetches.
repository_info_abort_handle: Option<SpawnedFutureHandle>,
/// Handle for the pending periodic-refresh tick. Aborted in `Drop` so
/// the timer doesn't outlive the model.
periodic_refresh_handle: Option<SpawnedFutureHandle>,
}
impl Entity for LocalGitHubRepoModel {
type Event = GitHubRepoEvent;
}
impl LocalGitHubRepoModel {
/// Create a new per-repo GitHub-info model.
///
/// Subscribes to `git_status` for `MetadataChanged` events to track the
/// current branch. Seeds `branch` from the sibling's current metadata
/// (if any) and kicks off an initial PR fetch when the branch is known.
/// Also schedules a periodic refresh so a previously-suppressed default
/// chip can recover after the user installs/authenticates `gh`, and kicks
/// off the one-shot `gh repo view` fetch.
pub(crate) fn new(
repo_path: PathBuf,
git_status: ModelHandle<GitRepoStatusModel>,
ctx: &mut ModelContext<Self>,
) -> Self {
let branch = git_status
.as_ref(ctx)
.metadata(ctx)
.map(|m| m.current_branch_name.clone());
// Track branch changes from the sibling. Only PR info depends on the
// branch — repository info is deliberately left untouched here.
ctx.subscribe_to_model(&git_status, |me, _, event, ctx| match event {
GitRepoStatusEvent::MetadataChanged => {
let new_branch = me
.git_status
.as_ref(ctx)
.metadata(ctx)
.map(|m| m.current_branch_name.clone());
if new_branch != me.branch {
me.branch = new_branch;
if me.pr_info.take().is_some() {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
if let Some(handle) = me.refreshing_pr_info_abort_handle.take() {
handle.abort();
}
me.refresh_pr_info(ctx);
}
}
});
let mut model = Self {
repo_path,
git_status,
branch,
pr_info: None,
repository_info: None,
refreshing_pr_info_abort_handle: None,
repository_info_abort_handle: None,
periodic_refresh_handle: None,
};
// Schedule periodic refresh of PR info and repository info.
// This is necessary to recover from transient `gh` command failures.
model.schedule_periodic_refresh(ctx);
// Fetch repository info which is branch-independent.
model.refresh_repository_info(ctx);
// Fetch PR info if the branch is known.
if model.branch.is_some() {
model.refresh_pr_info(ctx);
}
model
}
/// Schedules a periodic timer that refreshes PR info and repository info.
fn schedule_periodic_refresh(&mut self, ctx: &mut ModelContext<Self>) {
let handle = ctx.spawn(
async {
async_io::Timer::after(GITHUB_INFO_PERIODIC_REFRESH).await;
},
|me, _, ctx| {
me.refresh_pr_info(ctx);
me.refresh_repository_info(ctx);
me.schedule_periodic_refresh(ctx);
},
);
self.periodic_refresh_handle = Some(handle);
}
/// PR info for the current branch.
pub fn pr_info(&self) -> Option<&PrInfo> {
self.pr_info.as_ref()
}
/// Repository info (name/owner) returned by `gh repo view`.
pub fn repository_info(&self) -> Option<&RepositoryInfo> {
self.repository_info.as_ref()
}
/// Whether a `gh pr view` fetch is currently in flight.
pub fn is_refreshing_pr_info(&self) -> bool {
self.refreshing_pr_info_abort_handle.is_some()
}
/// Manually trigger a PR-info refresh. Called after `gh`/`gt` commands
/// complete, since those don't touch `.git/` so the filesystem watcher won't
/// catch them.
pub fn refresh_pr_info(&mut self, ctx: &mut ModelContext<Self>) {
let Some(branch) = self.branch.clone() else {
return;
};
// Branch changes abort in-flight fetches, so any handle
// here is already for the current branch.
if self.refreshing_pr_info_abort_handle.is_some() {
return;
}
let repo_path = self.repo_path.clone();
#[cfg(feature = "local_tty")]
let path_future = {
// Use the shell's interactive PATH so `gh` can be found when Warp
// was launched outside of a login shell, e.g. from the macOS GUI.
LocalShellState::handle(ctx).update(ctx, |shell_state, ctx| {
shell_state.get_interactive_path_env_var(ctx)
})
};
#[cfg(not(feature = "local_tty"))]
let path_future = futures::future::ready(None);
let branch_for_callback = branch.clone();
let abort_handle = ctx.spawn(
async move {
let path_env = path_future.await;
let fetch = get_pr_for_branch(&repo_path, path_env.as_deref());
let timeout = async_io::Timer::after(PR_INFO_FETCH_TIMEOUT);
futures::pin_mut!(fetch);
match futures::future::select(fetch, timeout).await {
futures::future::Either::Left((result, _)) => result,
futures::future::Either::Right((_, _)) => {
Err(anyhow::anyhow!("PR info fetch timed out"))
}
}
},
move |me, result, ctx| {
me.refreshing_pr_info_abort_handle = None;
me.handle_fetch_result(result, branch_for_callback, ctx);
},
);
self.refreshing_pr_info_abort_handle = Some(abort_handle);
}
/// Fetch repository info (`gh repo view`). Branch-independent: kicked off
/// on creation and re-checked by the periodic timer on each tick. Never
/// called from the branch-change path, so switching branches does not
/// trigger a `gh repo view`.
pub fn refresh_repository_info(&mut self, ctx: &mut ModelContext<Self>) {
// Guard against overlapping fetches.
if self.repository_info_abort_handle.is_some() {
return;
}
let repo_path = self.repo_path.clone();
#[cfg(feature = "local_tty")]
let path_future = {
// Use the shell's interactive PATH so `gh` can be found when Warp
// was launched outside of a login shell, e.g. from the macOS GUI.
LocalShellState::handle(ctx).update(ctx, |shell_state, ctx| {
shell_state.get_interactive_path_env_var(ctx)
})
};
#[cfg(not(feature = "local_tty"))]
let path_future = futures::future::ready(None);
self.repository_info_abort_handle = Some(ctx.spawn(
async move {
let path_env = path_future.await;
let fetch = get_repository_info(&repo_path, path_env.as_deref());
let timeout = async_io::Timer::after(REPOSITORY_INFO_FETCH_TIMEOUT);
futures::pin_mut!(fetch);
match futures::future::select(fetch, timeout).await {
futures::future::Either::Left((result, _)) => result,
futures::future::Either::Right((_, _)) => {
Err(anyhow::anyhow!("Repository info fetch timed out"))
}
}
},
|me, result, ctx| {
me.repository_info_abort_handle = None;
me.handle_repository_info_result(result, ctx);
},
));
}
fn handle_repository_info_result(
&mut self,
result: anyhow::Result<Option<RepositoryInfo>>,
ctx: &mut ModelContext<Self>,
) {
match result {
Ok(repository_info) => {
if self.repository_info != repository_info {
self.repository_info = repository_info;
ctx.emit(GitHubRepoEvent::RepositoryInfoChanged);
}
}
Err(err) => {
log::debug!("GitHubRepoModel: repository info load failed: {err}");
}
}
}
fn handle_fetch_result(
&mut self,
result: anyhow::Result<Option<PrInfo>>,
branch: String,
ctx: &mut ModelContext<Self>,
) {
match result {
Ok(pr_info) => {
Self::maybe_validate_github_pr_default(ctx);
// Only emit when the updated branch is still current.
if self.branch.as_deref() == Some(branch.as_str()) {
let changed = self.pr_info.as_ref() != pr_info.as_ref();
self.pr_info = pr_info;
if changed {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
}
}
Err(e) => {
let error_msg = e.to_string();
if is_gh_missing_error(&error_msg) || is_gh_auth_error(&error_msg) {
log::info!(
"GitHubRepoModel: suppressing default PR chip \
due to deterministic gh setup error"
);
if self.pr_info.take().is_some() {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
Self::maybe_suppress_github_pr_default(ctx);
}
// On transient errors, keep existing PR info to avoid
// flashing the UI.
}
}
}
fn maybe_suppress_github_pr_default(ctx: &mut ModelContext<Self>) {
let current = *SessionSettings::as_ref(ctx).github_pr_chip_default_validation;
if current != GithubPrPromptChipDefaultValidation::Suppressed {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.github_pr_chip_default_validation
.set_value(GithubPrPromptChipDefaultValidation::Suppressed, ctx));
});
}
}
fn maybe_validate_github_pr_default(ctx: &mut ModelContext<Self>) {
let current = *SessionSettings::as_ref(ctx).github_pr_chip_default_validation;
if current != GithubPrPromptChipDefaultValidation::Validated {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.github_pr_chip_default_validation
.set_value(GithubPrPromptChipDefaultValidation::Validated, ctx));
});
}
}
}
#[cfg(test)]
impl LocalGitHubRepoModel {
/// Inert constructor: no branch-tracking subscription, timers, or `gh`
/// fetch, so tests stay deterministic and never spawn a real subprocess.
/// Drive state via the `set_*_for_test` helpers.
pub(crate) fn new_for_test(git_status: ModelHandle<GitRepoStatusModel>) -> Self {
Self {
repo_path: PathBuf::from("/test"),
git_status,
branch: None,
pr_info: None,
repository_info: None,
refreshing_pr_info_abort_handle: None,
repository_info_abort_handle: None,
periodic_refresh_handle: None,
}
}
pub(crate) fn set_pr_info_for_test(
&mut self,
pr_info: Option<PrInfo>,
ctx: &mut ModelContext<Self>,
) {
self.pr_info = pr_info;
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
pub(crate) fn set_repository_info_for_test(
&mut self,
repository_info: Option<RepositoryInfo>,
ctx: &mut ModelContext<Self>,
) {
self.repository_info = repository_info;
ctx.emit(GitHubRepoEvent::RepositoryInfoChanged);
}
}
#[cfg(test)]
#[path = "local_tests.rs"]
mod tests;
impl Drop for LocalGitHubRepoModel {
fn drop(&mut self) {
if let Some(h) = self.refreshing_pr_info_abort_handle.take() {
h.abort();
}
if let Some(h) = self.repository_info_abort_handle.take() {
h.abort();
}
if let Some(h) = self.periodic_refresh_handle.take() {
h.abort();
}
}
}
@@ -0,0 +1,167 @@
use repo_metadata::DirectoryWatcher;
use warp_util::standardized_path::StandardizedPath;
use warpui::{App, ModelHandle};
use super::*;
use crate::code_review::git_repo_model::GitRepoStatusModel;
use crate::util::git::RepositoryInfo;
fn pr(number: u64) -> PrInfo {
PrInfo {
number,
url: format!("https://github.com/warp/warp/pull/{number}"),
state: "OPEN".to_string(),
draft: false,
base_branch: "main".to_string(),
}
}
fn repository_info() -> RepositoryInfo {
RepositoryInfo {
name: "warp".to_string(),
owner: Some("warpdotdev".to_string()),
}
}
fn test_repository_handle(
app: &mut App,
temp_dir: &tempfile::TempDir,
) -> ModelHandle<repo_metadata::Repository> {
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing);
watcher_handle.update(app, |watcher, ctx| {
watcher
.add_directory(
StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap(),
ctx,
)
.unwrap()
})
}
/// Builds an inert `GitHubRepoModel` over a throwaway sibling git-status
/// model. The model never subscribes or fetches; tests drive state directly.
fn new_github_repo_model_for_test(
app: &mut App,
) -> (tempfile::TempDir, ModelHandle<LocalGitHubRepoModel>) {
let temp_dir = tempfile::TempDir::new().unwrap();
let repository = test_repository_handle(app, &temp_dir);
let git_status =
app.add_model(move |ctx| GitRepoStatusModel::new_local_for_test(repository, None, ctx));
let model = app.add_model(move |_| LocalGitHubRepoModel::new_for_test(git_status));
(temp_dir, model)
}
#[test]
fn pr_info_cleared_on_branch_change() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
// On feature-a with a cached PR.
model.update(&mut app, |model, ctx| {
model.branch = Some("feature-a".to_string());
model.set_pr_info_for_test(Some(pr(123)), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.pr_info(), Some(&pr(123)));
});
// Switching branches clears the now-stale PR.
model.update(&mut app, |model, ctx| {
model.branch = Some("feature-b".to_string());
if model.pr_info.take().is_some() {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
});
model.read(&app, |model, _| {
assert_eq!(model.pr_info(), None);
});
});
}
#[test]
fn repository_info_preserved_on_fetch_error() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
model.update(&mut app, |model, ctx| {
model.set_repository_info_for_test(Some(repository_info()), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
model.update(&mut app, |model, ctx| {
model.handle_repository_info_result(Err(anyhow::anyhow!("gh failed")), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
});
}
#[test]
fn repository_info_cleared_on_authoritative_empty_result() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
model.update(&mut app, |model, ctx| {
model.set_repository_info_for_test(Some(repository_info()), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
model.update(&mut app, |model, ctx| {
model.handle_repository_info_result(Ok(None), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), None);
});
});
}
#[test]
fn pr_info_cleared_when_branch_goes_away() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
model.update(&mut app, |model, ctx| {
model.branch = Some("feature-a".to_string());
model.set_pr_info_for_test(Some(pr(123)), ctx);
});
// Branch goes to `None` (e.g. metadata load failure / detached HEAD).
model.update(&mut app, |model, ctx| {
model.branch = None;
if model.pr_info.take().is_some() {
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
});
model.read(&app, |model, _| {
assert_eq!(model.pr_info(), None);
});
});
}
#[test]
fn repository_info_survives_branch_change() {
App::test((), |mut app| async move {
let (_temp_dir, model) = new_github_repo_model_for_test(&mut app);
model.update(&mut app, |model, ctx| {
model.branch = Some("feature-a".to_string());
model.set_repository_info_for_test(Some(repository_info()), ctx);
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
// Repository info is branch-independent — a branch change leaves it.
model.update(&mut app, |model, _| {
model.branch = Some("feature-b".to_string());
});
model.read(&app, |model, _| {
assert_eq!(model.repository_info(), Some(&repository_info()));
});
});
}
@@ -0,0 +1,136 @@
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
#[cfg(feature = "local_fs")]
mod local;
#[cfg(feature = "local_fs")]
pub use local::LocalGitHubRepoModel;
mod remote;
pub use remote::RemoteGitHubRepoModel;
#[cfg(all(test, feature = "local_fs"))]
use crate::code_review::git_repo_model::GitRepoStatusModel;
use crate::util::git::{PrInfo, RepositoryInfo};
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
#[derive(Debug)]
pub enum GitHubRepoEvent {
/// Emitted when `pr_info` changes value (fetch result differs from
/// cached, branch change cleared the cache, etc.).
PrInfoChanged,
/// Emitted when `repository_info` changes value.
RepositoryInfoChanged,
}
// ── Unified GitHubRepoModel (local or remote backend) ───────────────────────
/// Unified per-repo GitHub-info model that dispatches to a local or remote
/// backend, mirroring [`crate::code_review::git_repo_model::GitRepoStatusModel`].
///
/// Consumers (prompt chips, code review, agent context) hold a
/// `ModelHandle<GitHubRepoModel>` and subscribe to its [`GitHubRepoEvent`]s
/// without caring whether the repository is local or on an SSH host.
pub enum GitHubRepoModel {
#[cfg(feature = "local_fs")]
Local(ModelHandle<LocalGitHubRepoModel>),
Remote(ModelHandle<RemoteGitHubRepoModel>),
}
impl Entity for GitHubRepoModel {
type Event = GitHubRepoEvent;
}
impl GitHubRepoModel {
/// Re-emit a sub-model event so subscribers of the unified model observe
/// the same `GitHubRepoEvent`s regardless of backend.
pub(crate) fn forward_event(&mut self, event: &GitHubRepoEvent, ctx: &mut ModelContext<Self>) {
match event {
GitHubRepoEvent::PrInfoChanged => ctx.emit(GitHubRepoEvent::PrInfoChanged),
GitHubRepoEvent::RepositoryInfoChanged => {
ctx.emit(GitHubRepoEvent::RepositoryInfoChanged)
}
}
}
/// PR info for the current branch.
pub fn pr_info<'a>(&self, ctx: &'a AppContext) -> Option<&'a PrInfo> {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.as_ref(ctx).pr_info(),
Self::Remote(m) => m.as_ref(ctx).pr_info(),
}
}
/// Repository info (name/owner) returned by `gh repo view`.
pub fn repository_info<'a>(&self, ctx: &'a AppContext) -> Option<&'a RepositoryInfo> {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.as_ref(ctx).repository_info(),
Self::Remote(m) => m.as_ref(ctx).repository_info(),
}
}
/// Whether a `gh pr view` fetch is currently in flight.
pub fn is_refreshing_pr_info(&self, ctx: &AppContext) -> bool {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.as_ref(ctx).is_refreshing_pr_info(),
Self::Remote(m) => m.as_ref(ctx).is_refreshing_pr_info(),
}
}
/// Force a PR info refresh (e.g. after a `gh`/`gt` command completes).
pub fn refresh_pr_info(&self, ctx: &mut ModelContext<Self>) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| m.refresh_pr_info(ctx)),
Self::Remote(m) => m.update(ctx, |m, ctx| m.refresh_pr_info(ctx)),
}
}
/// Force a repository-info refresh.
pub fn refresh_repository_info(&self, ctx: &mut ModelContext<Self>) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| m.refresh_repository_info(ctx)),
Self::Remote(m) => m.update(ctx, |m, ctx| m.refresh_repository_info(ctx)),
}
}
}
#[cfg(all(test, feature = "local_fs"))]
impl GitHubRepoModel {
/// Wraps an inert local-backend test model in the unified enum.
pub(crate) fn new_local_for_test(
git_status: ModelHandle<GitRepoStatusModel>,
ctx: &mut ModelContext<Self>,
) -> Self {
let inner = ctx.add_model(move |_| LocalGitHubRepoModel::new_for_test(git_status));
ctx.subscribe_to_model(&inner, |me, _, event, ctx| me.forward_event(event, ctx));
Self::Local(inner)
}
pub(crate) fn set_pr_info_for_test(
&mut self,
pr_info: Option<PrInfo>,
ctx: &mut ModelContext<Self>,
) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| m.set_pr_info_for_test(pr_info, ctx)),
Self::Remote(_) => unreachable!("remote test models are not used"),
}
}
pub(crate) fn set_repository_info_for_test(
&mut self,
repository_info: Option<RepositoryInfo>,
ctx: &mut ModelContext<Self>,
) {
match self {
#[cfg(feature = "local_fs")]
Self::Local(m) => m.update(ctx, |m, ctx| {
m.set_repository_info_for_test(repository_info, ctx)
}),
Self::Remote(_) => unreachable!("remote test models are not used"),
}
}
}
@@ -0,0 +1,148 @@
use remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
use warp_util::remote_path::RemotePath;
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use super::GitHubRepoEvent;
use crate::remote_server::proto;
use crate::util::git::{PrInfo, RepositoryInfo};
/// Client-side per-repo GitHub info for a repository on an SSH host.
///
/// Presents the same read surface as [`super::LocalGitHubRepoModel`] and emits the
/// same [`GitHubRepoEvent`]s so the unified [`super::GitHubRepoModel`] can substitute
/// it transparently (mirrors `RemoteGitRepoStatusModel`).
///
/// Pure push receiver: holds the latest PR / repository info for its
/// `(host_id, repo_path)`. On construction (and again on reconnect) it sends
/// `UpdateGitHubPrInfo` / `UpdateGitHubRepoInfo` notifications asking the daemon
/// to create the per-repo model if needed and refresh; results then arrive as
/// server-broadcast push messages filtered by `(host_id, repo_path)`. The
/// daemon's `GitHubRepoModel` is the single source of truth, so there is no
/// request/response and no client-side refresh state. `HostDisconnected`
/// preserves stale data.
pub struct RemoteGitHubRepoModel {
remote_path: RemotePath,
pr_info: Option<PrInfo>,
repository_info: Option<RepositoryInfo>,
}
impl Entity for RemoteGitHubRepoModel {
type Event = GitHubRepoEvent;
}
impl RemoteGitHubRepoModel {
pub fn new(remote_path: RemotePath, ctx: &mut ModelContext<Self>) -> Self {
let mgr = RemoteServerManager::handle(ctx);
ctx.subscribe_to_model(&mgr, Self::handle_manager_event);
let model = Self {
remote_path,
pr_info: None,
repository_info: None,
};
model.request_github_info(ctx);
model
}
fn handle_manager_event(
&mut self,
_: ModelHandle<RemoteServerManager>,
event: &RemoteServerManagerEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
RemoteServerManagerEvent::GitHubPrInfoPushReceived {
host_id,
repo_path,
pr_info,
} if self.remote_path.matches(host_id, repo_path) => {
self.apply_pr_info_push(pr_info.as_ref(), ctx);
}
RemoteServerManagerEvent::GitHubRepositoryInfoPushReceived {
host_id,
repo_path,
repository_info,
} if self.remote_path.matches(host_id, repo_path) => {
self.apply_repository_info_push(repository_info.as_ref(), ctx);
}
RemoteServerManagerEvent::HostConnected { host_id }
if host_id == &self.remote_path.host_id =>
{
self.request_github_info(ctx);
}
_ => {}
}
}
/// Asks the daemon to (create and) refresh both PR and repository info.
/// Fire-and-forget; results arrive as push broadcasts.
fn request_github_info(&self, ctx: &mut ModelContext<Self>) {
self.request_pr_info(ctx);
self.request_repository_info(ctx);
}
fn request_pr_info(&self, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, _| {
mgr.update_github_pr_info(host_id, &repo_path);
});
}
fn request_repository_info(&self, ctx: &mut ModelContext<Self>) {
let host_id = self.remote_path.host_id.clone();
let repo_path = self.remote_path.path.clone();
RemoteServerManager::handle(ctx).update(ctx, |mgr, _| {
mgr.update_github_repo_info(host_id, &repo_path);
});
}
/// Replace the stored PR info from a push, emitting `PrInfoChanged` only
/// when the value moved.
fn apply_pr_info_push(
&mut self,
pr_info: Option<&proto::PrInfo>,
ctx: &mut ModelContext<Self>,
) {
let pr_info = pr_info.map(PrInfo::from);
if self.pr_info != pr_info {
self.pr_info = pr_info;
ctx.emit(GitHubRepoEvent::PrInfoChanged);
}
}
/// Replace the stored repository info from a push, emitting
/// `RepositoryInfoChanged` only when the value moved.
fn apply_repository_info_push(
&mut self,
repository_info: Option<&proto::RepositoryInfo>,
ctx: &mut ModelContext<Self>,
) {
let repository_info = repository_info.map(RepositoryInfo::from);
if self.repository_info != repository_info {
self.repository_info = repository_info;
ctx.emit(GitHubRepoEvent::RepositoryInfoChanged);
}
}
pub fn pr_info(&self) -> Option<&PrInfo> {
self.pr_info.as_ref()
}
pub fn repository_info(&self) -> Option<&RepositoryInfo> {
self.repository_info.as_ref()
}
/// Always `false`: the remote backend does not track refresh state, since
/// results arrive as broadcasts with no request correlation.
pub fn is_refreshing_pr_info(&self) -> bool {
false
}
pub fn refresh_pr_info(&self, ctx: &mut ModelContext<Self>) {
self.request_pr_info(ctx);
}
pub fn refresh_repository_info(&self, ctx: &mut ModelContext<Self>) {
self.request_repository_info(ctx);
}
}