diff --git a/app/src/context_chips/cloud_artifact_pr.rs b/app/src/context_chips/cloud_artifact_pr.rs new file mode 100644 index 0000000000..b24e5fdcfc --- /dev/null +++ b/app/src/context_chips/cloud_artifact_pr.rs @@ -0,0 +1,237 @@ +use std::collections::HashSet; + +use crate::ai::artifacts::Artifact; + +use super::{prompt_snapshot::PromptSnapshot, ChipValue, ContextChipKind}; + +pub(crate) fn apply_cloud_artifact_pr_to_prompt_snapshot( + snapshot: &mut PromptSnapshot, + artifacts: &[Artifact], +) -> bool { + if !artifacts + .iter() + .any(|artifact| matches!(artifact, Artifact::PullRequest { .. })) + { + return false; + } + + let current_working_directory = snapshot + .chip_value(&ContextChipKind::WorkingDirectory) + .and_then(|value| value.as_text().map(str::to_string)); + let current_git_branch = snapshot + .chip_value(&ContextChipKind::ShellGitBranch) + .and_then(|value| value.as_text().map(str::to_string)); + + let matching_pr_urls = matching_pull_request_urls( + artifacts, + current_working_directory.as_deref(), + current_git_branch.as_deref(), + ); + let value = (matching_pr_urls.len() == 1).then(|| ChipValue::Text(matching_pr_urls[0].clone())); + + snapshot.set_chip_value(&ContextChipKind::GithubPullRequest, value) +} + +fn matching_pull_request_urls( + artifacts: &[Artifact], + current_working_directory: Option<&str>, + current_git_branch: Option<&str>, +) -> Vec { + let mut urls = Vec::new(); + let mut seen = HashSet::new(); + + for artifact in artifacts { + let Artifact::PullRequest { + url, branch, repo, .. + } = artifact + else { + continue; + }; + + if url.trim().is_empty() { + continue; + } + + if !repo_matches_current_working_directory(repo.as_deref(), current_working_directory) { + continue; + } + + if !branch_matches_current_git_branch(branch, current_git_branch) { + continue; + } + + if seen.insert(url.as_str()) { + urls.push(url.clone()); + } + } + + urls +} + +fn repo_matches_current_working_directory( + repo: Option<&str>, + current_working_directory: Option<&str>, +) -> bool { + let Some(repo) = repo.map(str::trim).filter(|repo| !repo.is_empty()) else { + return false; + }; + let Some(current_working_directory) = current_working_directory + .map(str::trim) + .filter(|cwd| !cwd.is_empty()) + else { + return false; + }; + + current_working_directory + .split(['/', '\\']) + .any(|segment| segment == repo) +} + +fn branch_matches_current_git_branch(branch: &str, current_git_branch: Option<&str>) -> bool { + let branch = branch.trim(); + if branch.is_empty() { + return false; + } + + let Some(current_git_branch) = current_git_branch + .map(str::trim) + .filter(|current_git_branch| !current_git_branch.is_empty()) + else { + return true; + }; + + branch == current_git_branch +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context_chips::ChipResult; + use crate::settings::WarpPromptSeparator; + + fn pr_artifact(url: &str, branch: &str) -> Artifact { + let (repo, number) = crate::ai::artifacts::parse_github_pr_url(url).unzip(); + Artifact::PullRequest { + url: url.to_string(), + branch: branch.to_string(), + repo, + number, + } + } + + fn snapshot(cwd: Option<&str>, branch: Option<&str>, pr: Option<&str>) -> PromptSnapshot { + let chips = vec![ + ChipResult { + kind: ContextChipKind::WorkingDirectory, + value: cwd.map(|cwd| ChipValue::Text(cwd.to_string())), + on_click_values: vec![], + }, + ChipResult { + kind: ContextChipKind::ShellGitBranch, + value: branch.map(|branch| ChipValue::Text(branch.to_string())), + on_click_values: vec![], + }, + ChipResult { + kind: ContextChipKind::GithubPullRequest, + value: pr.map(|pr| ChipValue::Text(pr.to_string())), + on_click_values: vec![], + }, + ]; + PromptSnapshot::from_chips(chips, false, WarpPromptSeparator::None) + } + + #[test] + fn sets_pr_chip_when_repo_and_branch_match() { + let mut snapshot = snapshot(Some("/workspace/warp/app"), Some("feature"), None); + let changed = apply_cloud_artifact_pr_to_prompt_snapshot( + &mut snapshot, + &[pr_artifact( + "https://github.com/warpdotdev/warp/pull/123", + "feature", + )], + ); + + assert!(changed); + assert_eq!( + snapshot + .chip_value(&ContextChipKind::GithubPullRequest) + .and_then(|value| value.as_text().map(str::to_string)), + Some("https://github.com/warpdotdev/warp/pull/123".to_string()) + ); + } + + #[test] + fn does_not_set_pr_chip_when_repo_does_not_match_cwd() { + let mut snapshot = snapshot(Some("/workspace/docs"), Some("feature"), None); + let changed = apply_cloud_artifact_pr_to_prompt_snapshot( + &mut snapshot, + &[pr_artifact( + "https://github.com/warpdotdev/warp/pull/123", + "feature", + )], + ); + + assert!(!changed); + assert_eq!( + snapshot.chip_value(&ContextChipKind::GithubPullRequest), + None + ); + } + + #[test] + fn does_not_set_pr_chip_when_branch_does_not_match() { + let mut snapshot = snapshot(Some("/workspace/warp"), Some("other"), None); + let changed = apply_cloud_artifact_pr_to_prompt_snapshot( + &mut snapshot, + &[pr_artifact( + "https://github.com/warpdotdev/warp/pull/123", + "feature", + )], + ); + + assert!(!changed); + assert_eq!( + snapshot.chip_value(&ContextChipKind::GithubPullRequest), + None + ); + } + + #[test] + fn clears_pr_chip_when_multiple_artifact_prs_match() { + let mut snapshot = snapshot( + Some("/workspace/warp"), + Some("feature"), + Some("https://github.com/warpdotdev/warp/pull/123"), + ); + let changed = apply_cloud_artifact_pr_to_prompt_snapshot( + &mut snapshot, + &[ + pr_artifact("https://github.com/warpdotdev/warp/pull/123", "feature"), + pr_artifact("https://github.com/warpdotdev/warp/pull/456", "feature"), + ], + ); + + assert!(changed); + assert_eq!( + snapshot.chip_value(&ContextChipKind::GithubPullRequest), + None + ); + } + + #[test] + fn leaves_snapshot_unchanged_without_pr_artifacts() { + let mut snapshot = snapshot( + Some("/workspace/warp"), + Some("feature"), + Some("https://github.com/warpdotdev/warp/pull/123"), + ); + let original = snapshot.chip_value(&ContextChipKind::GithubPullRequest); + let changed = apply_cloud_artifact_pr_to_prompt_snapshot(&mut snapshot, &[]); + + assert!(!changed); + assert_eq!( + snapshot.chip_value(&ContextChipKind::GithubPullRequest), + original + ); + } +} diff --git a/app/src/context_chips/mod.rs b/app/src/context_chips/mod.rs index a5615744f3..cac5427bfc 100644 --- a/app/src/context_chips/mod.rs +++ b/app/src/context_chips/mod.rs @@ -1,5 +1,6 @@ // TODO: restrict what we make public here. mod builtins; +pub(crate) mod cloud_artifact_pr; pub mod context_chip; pub mod current_prompt; pub mod directory_fetcher; diff --git a/app/src/context_chips/prompt_snapshot.rs b/app/src/context_chips/prompt_snapshot.rs index 81e27761f0..a6fd99e597 100644 --- a/app/src/context_chips/prompt_snapshot.rs +++ b/app/src/context_chips/prompt_snapshot.rs @@ -79,6 +79,27 @@ impl PromptSnapshot { }) } + pub(crate) fn set_chip_value( + &mut self, + chip: &ContextChipKind, + value: Option, + ) -> bool { + let Some(chip_result) = self + .chips + .iter_mut() + .find(|chip_result| chip_result.kind == *chip) + else { + return false; + }; + + if chip_result.value == value { + return false; + } + + chip_result.value = value; + true + } + pub(crate) fn chips(&self) -> &Vec { &self.chips } diff --git a/app/src/terminal/shared_session/viewer/terminal_manager.rs b/app/src/terminal/shared_session/viewer/terminal_manager.rs index f70963d1fb..410ee825d7 100644 --- a/app/src/terminal/shared_session/viewer/terminal_manager.rs +++ b/app/src/terminal/shared_session/viewer/terminal_manager.rs @@ -1341,32 +1341,34 @@ impl TerminalManager { active_prompt: &ActivePrompt, ctx: &mut AppContext, ) { - let mut model = model.lock(); - match active_prompt { - ActivePrompt::WarpPrompt(serialized_prompt_snapshot) => { - match serde_json::from_str::(serialized_prompt_snapshot) { - Ok(prompt_snapshot) => { - model.block_list_mut().set_honor_ps1(false); - // Overwrite the static prompt with the new snapshot. - prompt_type.update(ctx, |prompt_type, ctx| { - if let PromptType::Static { snapshot } = prompt_type { - *snapshot = prompt_snapshot; - ctx.notify(); - } else { - log::warn!("Received ActivePrompt::WarpPrompt updated but prompt type is not Static"); - } - }); - } - Err(e) => { - log::error!( - "Failed to deserialize prompt snapshot from shared session server: {e}" - ) + { + let mut model = model.lock(); + match active_prompt { + ActivePrompt::WarpPrompt(serialized_prompt_snapshot) => { + match serde_json::from_str::(serialized_prompt_snapshot) { + Ok(prompt_snapshot) => { + model.block_list_mut().set_honor_ps1(false); + // Overwrite the static prompt with the new snapshot. + prompt_type.update(ctx, |prompt_type, ctx| { + if let PromptType::Static { snapshot } = prompt_type { + *snapshot = prompt_snapshot; + ctx.notify(); + } else { + log::warn!("Received ActivePrompt::WarpPrompt updated but prompt type is not Static"); + } + }); + } + Err(e) => { + log::error!( + "Failed to deserialize prompt snapshot from shared session server: {e}" + ) + } } } - } - ActivePrompt::PS1 => { - // The viewer already receives bytes from the pty for the PS1 prompt, so we only need to choose to render it. - model.block_list_mut().set_honor_ps1(true); + ActivePrompt::PS1 => { + // The viewer already receives bytes from the pty for the PS1 prompt, so we only need to choose to render it. + model.block_list_mut().set_honor_ps1(true); + } } } let Some(view) = weak_view_handle.upgrade(ctx) else { @@ -1374,6 +1376,7 @@ impl TerminalManager { }; // This is needed to re-render the input if we changed prompt types. view.update(ctx, |view, ctx| { + view.refresh_cloud_artifact_pr_prompt_chip(ctx); view.input().update(ctx, |input, ctx| { input.notify_and_notify_children(ctx); }) diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 63477ecb36..2454497c90 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -245,6 +245,7 @@ use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{CloudObject, GenericStringObjectFormat, JsonObjectType}; #[cfg(feature = "local_fs")] use crate::code::editor_management::CodeSource; +use crate::context_chips::cloud_artifact_pr::apply_cloud_artifact_pr_to_prompt_snapshot; use crate::context_chips::prompt::Prompt; use crate::context_chips::prompt_type::PromptType; use crate::context_chips::ContextChipKind; @@ -3659,6 +3660,9 @@ impl TerminalView { | AgentConversationsModelEvent::ConversationUpdated { .. } | AgentConversationsModelEvent::ConversationArtifactsUpdated { .. } ); + if should_refresh_details_panel { + me.refresh_cloud_artifact_pr_prompt_chip(ctx); + } // Only refresh panel if it's currently open (avoids unnecessary work) if should_refresh_details_panel && me.is_conversation_details_panel_open @@ -10476,6 +10480,37 @@ impl TerminalView { }); } + pub(crate) fn refresh_cloud_artifact_pr_prompt_chip(&mut self, ctx: &mut ViewContext) { + let Some(task_id) = self.ambient_agent_task_id_for_details_panel(ctx) else { + return; + }; + let Some(task) = AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.get_or_async_fetch_task_data(&task_id, ctx) + }) else { + return; + }; + if task.artifacts.is_empty() { + return; + } + + let did_update = self.current_prompt.update(ctx, |prompt_type, ctx| { + let PromptType::Static { snapshot } = prompt_type else { + return false; + }; + let did_update = apply_cloud_artifact_pr_to_prompt_snapshot(snapshot, &task.artifacts); + if did_update { + ctx.notify(); + } + did_update + }); + + if did_update { + self.input.update(ctx, |input, ctx| { + input.notify_and_notify_children(ctx); + }); + } + } + pub fn current_state(&self) -> TerminalViewStateChange { self.current_state }