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
+75 -26
View File
@@ -3,13 +3,10 @@
use chrono::Local;
use galaxy_util::path::user_friendly_path;
use super::context_chip::{GeneratorContext, ShellCommand, ShellCommandGenerator};
use super::ChipValue;
use crate::terminal::shell::ShellType;
use super::{
context_chip::{GeneratorContext, ShellCommand, ShellCommandGenerator},
ChipValue,
};
#[cfg(test)]
#[path = "builtins_tests.rs"]
mod tests;
@@ -91,7 +88,7 @@ pub fn time24_with_seconds(_: &GeneratorContext) -> Option<ChipValue> {
/// Generator function for SSH session chip.
pub fn ssh_session(ctx: &GeneratorContext) -> Option<ChipValue> {
let session = ctx.active_session?;
if session.is_legacy_ssh_session()
if session.is_ssh_wrapper_session()
|| matches!(
session.session_type(),
crate::terminal::model::session::SessionType::WarpifiedRemote { .. }
@@ -147,10 +144,80 @@ pub fn shell_git_branch() -> ShellCommandGenerator {
}
pub fn shell_other_git_branches() -> ShellCommandGenerator {
const SH_COMMAND: &str = "git --no-optional-locks branch --no-color --sort=-committerdate";
const SH_COMMAND: &str = "git --no-optional-locks branch --no-color --sort=-committerdate; \
printf '\\036\\n'; \
git --no-optional-locks worktree list --porcelain";
let pwsh_command = safe_git_powershell(
"git --no-optional-locks branch --no-color --sort=-committerdate; \
[char]30; \
git --no-optional-locks worktree list --porcelain",
);
let command = ShellCommand::shell_specific([
(ShellType::PowerShell, SH_COMMAND.to_string()),
(ShellType::PowerShell, pwsh_command),
(ShellType::Bash, SH_COMMAND.to_string()),
(ShellType::Zsh, SH_COMMAND.to_string()),
(ShellType::Fish, SH_COMMAND.to_string()),
]);
ShellCommandGenerator::new(command, Some(vec!["git".to_owned()]))
}
pub fn shell_git_branch_status() -> ShellCommandGenerator {
const SH_COMMAND: &str = "\
sh -c 'branch=$(GIT_OPTIONAL_LOCKS=0 git symbolic-ref --short HEAD 2>/dev/null || \
GIT_OPTIONAL_LOCKS=0 git rev-parse --short HEAD 2>/dev/null) || exit 1; \
[ -n \"$branch\" ] || exit 1; \
display_count() { if [ \"$1\" -gt 999 ]; then printf \"999+\"; else printf \"%s\" \"$1\"; fi; }; \
if counts=$(GIT_OPTIONAL_LOCKS=0 git rev-list --left-right --cherry-mark --count HEAD...@{u} 2>/dev/null); then \
set -- $counts; \
ahead=${1:-0}; behind=${2:-0}; equivalent=${3:-0}; status=\"\"; \
if [ \"$ahead\" -eq 0 ] && [ \"$behind\" -eq 0 ] && [ \"$equivalent\" -gt 0 ]; then \
status=\"\"; \
else \
if [ \"$ahead\" -gt 0 ]; then status=\"↑$(display_count \"$ahead\")\"; fi; \
if [ \"$behind\" -gt 0 ]; then \
behind_status=\"↓$(display_count \"$behind\")\"; \
if [ -n \"$status\" ]; then status=\"$status $behind_status\"; else status=\"$behind_status\"; fi; \
fi; \
fi; \
if [ -n \"$status\" ]; then printf \"%s • %s\\n\" \"$branch\" \"$status\"; else printf \"%s\\n\" \"$branch\"; fi; \
else \
printf \"%s\\n\" \"$branch\"; \
fi'";
let pwsh_command = safe_git_powershell(
"$branch = git symbolic-ref --short HEAD 2>$null; \
if ($LASTEXITCODE -ne 0 -or -not $branch) { \
$branch = git rev-parse --short HEAD 2>$null; \
} \
if ($LASTEXITCODE -ne 0 -or -not $branch) { throw } \
function Format-GitCount($count) { if ($count -gt 999) { '999+' } else { [string]$count } } \
$counts = git rev-list --left-right --cherry-mark --count 'HEAD...@{u}' 2>$null; \
if ($LASTEXITCODE -eq 0 -and $counts) { \
$parts = $counts -split '\\s+'; \
if ($parts.Length -ge 2) { \
$ahead = [int]$parts[0]; \
$behind = [int]$parts[1]; \
$equivalent = if ($parts.Length -ge 3) { [int]$parts[2] } else { 0 }; \
$status = @(); \
if ($ahead -eq 0 -and $behind -eq 0 -and $equivalent -gt 0) { \
$status += '⇅'; \
} else { \
if ($ahead -gt 0) { $status += \"↑$(Format-GitCount $ahead)\" } \
if ($behind -gt 0) { $status += \"↓$(Format-GitCount $behind)\" } \
} \
if ($status.Count -gt 0) { \"$branch • $($status -join ' ')\" } else { $branch } \
} else { \
$branch; \
} \
} else { \
$branch; \
$global:LASTEXITCODE = 0; \
}",
);
let command = ShellCommand::shell_specific([
(ShellType::PowerShell, pwsh_command),
(ShellType::Bash, SH_COMMAND.to_string()),
(ShellType::Zsh, SH_COMMAND.to_string()),
(ShellType::Fish, SH_COMMAND.to_string()),
@@ -179,24 +246,6 @@ pub fn shell_git_line_changes() -> ShellCommandGenerator {
ShellCommandGenerator::new(command, Some(vec!["git".to_owned()]))
}
pub fn github_pull_request_url() -> ShellCommandGenerator {
// `gh pr view` exits non-zero both when there is no PR for the current branch and when the
// command actually fails. We inspect its output so that "no PR found" is treated as an empty
// success, while auth/config/network failures still propagate as real failures.
const SH_COMMAND: &str = include_str!("scripts/github_pull_request_prompt_chip.sh");
const FISH_COMMAND: &str = include_str!("scripts/github_pull_request_prompt_chip.fish");
const PWSH_COMMAND: &str = include_str!("scripts/github_pull_request_prompt_chip.ps1");
let command = ShellCommand::shell_specific([
(ShellType::PowerShell, PWSH_COMMAND.to_string()),
(ShellType::Bash, SH_COMMAND.to_string()),
(ShellType::Zsh, SH_COMMAND.to_string()),
(ShellType::Fish, FISH_COMMAND.to_string()),
]);
ShellCommandGenerator::new(command, Some(vec!["gh".to_owned(), "git".to_owned()]))
}
pub fn kubernetes_current_context() -> ShellCommandGenerator {
ShellCommandGenerator::new(
ShellCommand::portable("kubectl config current-context"),
+4 -24
View File
@@ -1,16 +1,9 @@
use std::sync::Arc;
use crate::{
context_chips::context_chip::GeneratorContext,
terminal::model::{
block::BlockMetadata,
session::{
command_executor::testing::TestCommandExecutor, BootstrapSessionType, Session,
SessionInfo,
},
},
terminal::shell::ShellType,
};
use crate::context_chips::context_chip::GeneratorContext;
use crate::terminal::model::block::BlockMetadata;
use crate::terminal::model::session::command_executor::testing::TestCommandExecutor;
use crate::terminal::model::session::{BootstrapSessionType, Session, SessionInfo};
#[test]
fn test_working_directory() {
@@ -107,7 +100,6 @@ fn test_remote_sessions() {
#[test]
fn test_node_version() {
use crate::context_chips::context_chip::Environment;
use crate::terminal::model::block::BlockMetadata;
use crate::terminal::model::session::Session;
let session = Session::test();
@@ -140,15 +132,3 @@ fn test_node_version() {
Some("v18.0.0")
);
}
#[test]
fn test_github_pull_request_url_command_avoids_zsh_status_assignment() {
let generator = super::github_pull_request_url();
let command = generator
.command()
.for_shell(ShellType::Zsh)
.expect("zsh command should exist");
assert!(command.contains("exit_code=$?"));
assert!(!command.contains("status=$?"));
assert!(!command.contains("status=$?;"));
}
+5 -9
View File
@@ -1,15 +1,11 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use super::ChipValue;
use crate::terminal::model::{
block::{Block, BlockMetadata},
session::{Session, SessionId},
};
use crate::terminal::model::block::{Block, BlockMetadata};
use crate::terminal::model::session::{Session, SessionId};
use crate::terminal::shell::ShellType;
#[derive(Clone, Debug, Serialize, Deserialize)]
+278 -314
View File
@@ -1,61 +1,45 @@
use crate::features::FeatureFlag;
use crate::report_if_error;
use crate::settings::{InputSettings, WarpPromptSeparator};
use crate::terminal::event::{BlockType, UserBlockCompleted};
use crate::terminal::model::session::{ExecuteCommandOptions, Session, SessionsEvent};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::{
debounce::debounce,
editor::EditorView,
menu::{MenuItem, MenuItemFields},
terminal::{
model::{
block::{Block, BlockMetadata},
session::Sessions,
},
session_settings::{
GithubPrPromptChipDefaultValidation, SessionSettings, SessionSettingsChangedEvent,
ToolbarChipSelection,
},
view::{ContextMenuAction, PromptPart, PromptPosition, TerminalAction},
},
};
use futures::{pin_mut, FutureExt as _};
use galaxy_completer::completer::{CommandExitStatus, CommandOutput};
use galaxy_core::user_preferences::GetUserPreferences;
use itertools::Itertools;
use settings::Setting as _;
use super::ChipResult;
use super::{
chips_to_string,
context_chip::{
ChipAvailability, ChipDisabledReason, ChipFingerprintInput, ChipRuntimeCapabilities,
ContextChip, Environment, ExternalCommandsAvailability, GeneratorContext, PromptGenerator,
RefreshConfig, ShellCommandGenerator,
},
logging::{ChipCommandLogEntry, PromptChipExecutionPhase, PromptChipLogger},
prompt::Prompt,
ChipValue, ContextChipKind,
};
#[cfg(feature = "local_fs")]
use crate::code_review::git_status_update::{GitRepoStatusEvent, GitRepoStatusModel};
#[cfg(feature = "local_fs")]
use crate::context_chips::display_chip::GitLineChanges;
#[cfg(feature = "local_fs")]
use galaxyui::WeakModelHandle;
use galaxyui::{
r#async::{SpawnedFutureHandle, Timer},
AppContext, ViewHandle,
};
use galaxyui::{Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity};
use std::collections::{HashMap, HashSet};
use std::hash::{Hash as _, Hasher as _};
use std::sync::Arc;
use std::time::Duration;
use futures::{pin_mut, FutureExt as _};
use itertools::Itertools;
use galaxy_completer::completer::CommandExitStatus;
use galaxy_core::r#async::debounce;
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::{
AppContext, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, ViewHandle,
WeakModelHandle,
};
use super::context_chip::{
ChipAvailability, ChipFingerprintInput, ChipRuntimeCapabilities, ContextChip, Environment,
ExternalCommandsAvailability, GeneratorContext, PromptGenerator, RefreshConfig,
ShellCommandGenerator,
};
use super::logging::{ChipCommandLogEntry, PromptChipExecutionPhase, PromptChipLogger};
use super::prompt::Prompt;
use super::{chips_to_string, ChipResult, ChipValue, ContextChipKind};
use crate::code_review::git_repo_model::{GitRepoStatusEvent, GitRepoStatusModel};
use crate::code_review::github_repo_model::{GitHubRepoEvent, GitHubRepoModel};
use crate::context_chips::display_chip::GitLineChanges;
use crate::editor::EditorView;
use crate::features::FeatureFlag;
use crate::menu::{MenuItem, MenuItemFields};
use crate::settings::{InputSettings, WarpPromptSeparator};
use crate::terminal::event::{BlockType, UserBlockCompleted};
use crate::terminal::model::block::{Block, BlockMetadata};
use crate::terminal::model::session::{ExecuteCommandOptions, Session, Sessions, SessionsEvent};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::session_settings::{
SessionSettings, SessionSettingsChangedEvent, ToolbarChipSelection,
};
use crate::terminal::view::{ContextMenuAction, PromptPart, PromptPosition, TerminalAction};
#[cfg(test)]
#[path = "current_prompt_test.rs"]
#[path = "current_prompt_tests.rs"]
mod tests;
const PROMPT_DEBOUNCE_PERIOD: Duration = Duration::from_millis(50);
@@ -75,13 +59,6 @@ enum ChipUpdateStatus {
Error,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GithubPrPromptChipCommandOutcome {
Validated,
DeterministicAuthFailure,
RetryableFailure,
}
/// ChipState stores the state and point-in-time information related to a specific chip.
/// For example, it's last computed value or a refresh handle.
#[derive(Clone, Debug, Default)]
@@ -179,10 +156,13 @@ pub struct CurrentPrompt {
prompt_chip_logger: PromptChipLogger,
update_tx: async_channel::Sender<()>,
/// When set, `ShellGitBranch` chip values are driven by filesystem events from
/// `GitRepoStatusModel` instead of the 30s periodic timer.
#[cfg(feature = "local_fs")]
/// When set, branch, branch status, and diff stats are populated from
/// `GitRepoStatusModel` filesystem events.
git_repo_status: Option<WeakModelHandle<GitRepoStatusModel>>,
/// When set, the `GithubPullRequest` chip value is populated from
/// `GitHubRepoModel` for the current repository.
github_repo_model: Option<WeakModelHandle<GitHubRepoModel>>,
}
/// Context about the current terminal session, needed to update the prompt.
@@ -217,7 +197,7 @@ impl CurrentPrompt {
&SessionSettings::handle(ctx),
Self::handle_session_settings_changed,
);
ctx.subscribe_to_model(&sessions, |me, event, ctx| {
ctx.subscribe_to_model(&sessions, |me, _, event, ctx| {
if let SessionsEvent::EnvironmentVariablesUpdated { .. } = event {
me.update_states_with_new_context(ctx);
}
@@ -252,8 +232,8 @@ impl CurrentPrompt {
update_tx,
same_line_prompt_enabled: prompt.as_ref(ctx).same_line_prompt_enabled(),
separator: prompt.as_ref(ctx).separator(),
#[cfg(feature = "local_fs")]
git_repo_status: None,
github_repo_model: None,
}
}
@@ -266,7 +246,7 @@ impl CurrentPrompt {
) {
// A WeakViewHandle is used here to avoid leaking the terminal model
let weak_editor_handle = editor.downgrade();
ctx.subscribe_to_view(&editor, move |me, _, ctx| {
ctx.subscribe_to_view(&editor, move |me, _, _, ctx| {
// CurrentPrompt exists and this fn is called even if we're not using warp prompt.
// We don't need to do anything if we're honoring PS1 unless universal developer input
// or AgentView is enabled (agent view needs chips regardless of PS1 setting).
@@ -518,9 +498,21 @@ impl CurrentPrompt {
return false;
};
// A retryable failure (`Error`, `TimedOut`) is not a usable cached
// result: `last_fingerprint` is recorded before the command runs, and
// such failures intentionally do not populate `last_failure_fingerprint`.
// Without this guard, the next periodic tick would treat the failed
// attempt as a cache hit and never retry. Deterministic failures
// continue to be suppressed via `last_failure_fingerprint`.
let should_skip = self
.states
.get(chip_kind)
.filter(|state| {
!matches!(
state.update_status,
ChipUpdateStatus::Error | ChipUpdateStatus::TimedOut
)
})
.and_then(|state| state.last_fingerprint.as_ref())
.is_some_and(|existing| existing == &new_fingerprint);
@@ -619,26 +611,7 @@ impl CurrentPrompt {
&self,
values_opt: Option<Vec<String>>,
) -> Option<Vec<String>> {
values_opt.map(|values| {
let mut trimmed: Vec<String> = values
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// We want to sort the branches so the current branch is first (denoted by *).
// The rest of the branches maintain their relative order.
trimmed.sort_by(|a, b| {
let a_starts_with_star = a.starts_with('*');
let b_starts_with_star = b.starts_with('*');
b_starts_with_star.cmp(&a_starts_with_star)
});
trimmed
.into_iter()
.map(|s| s.trim_start_matches('*').trim().to_string())
.collect()
})
super::git_branch_on_click::filter_git_branch_on_click_values(values_opt)
}
/// Perform a single update of the given chip.
@@ -691,19 +664,6 @@ impl CurrentPrompt {
handle.abort();
}
}
// If the GithubPullRequest chip is disabled because `gh` is missing,
// transition validation state to Suppressed so future default
// resolution excludes it.
if matches!(chip_kind, ContextChipKind::GithubPullRequest) {
if let ChipAvailability::Disabled(ChipDisabledReason::RequiresExecutable {
ref command,
}) = availability
{
if command == "gh" {
Self::maybe_suppress_github_pr_default(ctx);
}
}
}
self.update_chip_value(chip_kind, None);
self.update_on_click_value(chip_kind, None);
self.set_chip_update_status(chip_kind, ChipUpdateStatus::Disabled);
@@ -729,7 +689,6 @@ impl CurrentPrompt {
}
}
}
match generator {
PromptGenerator::ShellCommand(cmd) => {
let Some(exec_ctx) = self.prepare_shell_command_context(cmd, ctx) else {
@@ -769,7 +728,7 @@ impl CurrentPrompt {
.await;
(value, timed_out, chip_kind, exec_ctx, chip_title)
},
move |me, (value, timed_out, chip_kind, exec_ctx, chip_title), ctx| {
move |me, (value, timed_out, chip_kind, exec_ctx, chip_title), _| {
logger.log_shell_command(&ChipCommandLogEntry {
chip_kind: &chip_kind,
chip_title: &chip_title,
@@ -780,24 +739,22 @@ impl CurrentPrompt {
output: value.as_ref(),
timed_out,
});
// GitDiffStats has two value sources that can race when entering a repo:
// this shell fallback (`git diff --shortstat HEAD`, tracked changes only)
// and a repo-status watcher that also counts untracked files. If the
// watcher attached while this fallback was in flight, drop the fallback's
// result
if matches!(chip_kind, ContextChipKind::GitDiffStats)
&& me.is_updated_externally(&chip_kind)
{
return;
}
if timed_out {
if suppress_on_failure
&& Self::should_cache_failure_fingerprint(
&chip_kind,
value.as_ref(),
timed_out,
)
{
if suppress_on_failure {
if let Some(state) = me.states.get_mut(&chip_kind) {
state.last_failure_fingerprint = current_fingerprint;
}
} else if suppress_on_failure {
if let Some(state) = me.states.get_mut(&chip_kind) {
if state.last_failure_fingerprint == current_fingerprint {
state.last_failure_fingerprint = None;
}
}
}
me.update_chip_value(&chip_kind, None);
me.set_chip_update_status(&chip_kind, ChipUpdateStatus::TimedOut);
@@ -821,29 +778,7 @@ impl CurrentPrompt {
_ => (None, ChipUpdateStatus::Error, true),
};
if matches!(chip_kind, ContextChipKind::GithubPullRequest) {
match Self::github_pr_prompt_chip_command_outcome(
value.as_ref(),
timed_out,
) {
GithubPrPromptChipCommandOutcome::Validated => {
Self::maybe_validate_github_pr_default(ctx);
}
GithubPrPromptChipCommandOutcome::DeterministicAuthFailure => {
Self::maybe_suppress_github_pr_default(ctx);
}
GithubPrPromptChipCommandOutcome::RetryableFailure => {}
}
}
if suppress_on_failure
&& failed
&& Self::should_cache_failure_fingerprint(
&chip_kind,
value.as_ref(),
timed_out,
)
{
if suppress_on_failure && failed {
if let Some(state) = me.states.get_mut(&chip_kind) {
state.last_failure_fingerprint = current_fingerprint;
}
@@ -854,7 +789,8 @@ impl CurrentPrompt {
}
}
}
me.update_chip_value(&chip_kind, output.map(ChipValue::Text));
let chip_value = output.map(ChipValue::Text);
me.update_chip_value(&chip_kind, chip_value);
me.set_chip_update_status(&chip_kind, status);
},
);
@@ -1001,7 +937,7 @@ impl CurrentPrompt {
chip_kind_clone
},
|me, chip_kind, ctx| {
me.fetch_chip_value_at_interval(&chip_kind, None, None, false, ctx);
me.fetch_chip_value_at_interval(&chip_kind, None, None, true, ctx);
},
);
@@ -1028,6 +964,43 @@ impl CurrentPrompt {
let state = ChipState::new(chip_kind);
self.states.insert(chip_kind.clone(), state);
}
if self.is_updated_externally(chip_kind) {
// For chips updated externally (e.g. by the per-repo git status
// filesystem watcher), avoid running the shell-based fallback
// generator. Doing so can briefly overwrite the structured
// watcher value with one that uses different semantics (for
// example, the `GitDiffStats` shell fallback runs `git diff
// --shortstat HEAD`, which excludes untracked files, whereas
// the watcher counts untracked files as changes), causing the
// chip to flicker between the tracked-only count and the
// all-files count when untracked files are present.
//
// If a chip provides an `initial_value_generator` that sources
// from the prompt context (rather than running a shell
// command), use it for a fast initial value until the watcher
// emits a metadata-changed event.
if let Some(initial_gen) = chip_kind.initial_value_generator() {
self.fetch_chip_value_once(
chip_kind,
&initial_gen,
chip.on_click_generator().cloned(),
true,
ctx,
);
} else {
// Externally-updated chips without an `initial_value_generator`
// are left blank after a state rebuild (`states.clear()` in
// `handle_prompt_changed`, or `clear_cache()` on a session
// change) until their backing model emits a change event.
// `GithubPullRequest` only emits when cached PR info actually
// changes, so after a rebuild there may be no event to restore
// the already-cached value until the next periodic refresh.
if matches!(chip_kind, ContextChipKind::GithubPullRequest) {
self.sync_pr_chip_from_model(ctx);
}
}
return;
}
match chip.refresh_config() {
RefreshConfig::OnDemandOnly => {
@@ -1040,25 +1013,13 @@ impl CurrentPrompt {
);
}
RefreshConfig::Periodically { .. } => {
if self.is_updated_externally(chip_kind) {
let initial_gen = chip_kind.initial_value_generator();
let generator = initial_gen.as_ref().unwrap_or(chip.generator());
self.fetch_chip_value_once(
chip_kind,
generator,
chip.on_click_generator().cloned(),
true,
ctx,
);
} else {
self.fetch_chip_value_at_interval(
chip_kind,
chip_kind.initial_value_generator(),
chip.on_click_generator().cloned(),
true,
ctx,
);
}
self.fetch_chip_value_at_interval(
chip_kind,
chip_kind.initial_value_generator(),
chip.on_click_generator().cloned(),
true,
ctx,
);
}
RefreshConfig::OnFileChanges { filepath } => {
log::debug!("Unimplemented: would've watched changes to filepath: {filepath}");
@@ -1133,8 +1094,6 @@ impl CurrentPrompt {
/// existing states map with new information.
/// This is called when the context gets updated (ie. a new block metadata is received).
fn update_states_with_new_context_and_session(&mut self, ctx: &mut ModelContext<Self>) {
self.maybe_unsuppress_github_pr_default(ctx);
// 1. Terminating existing spawned operations.
self.clear_chips_and_cache();
@@ -1148,6 +1107,7 @@ impl CurrentPrompt {
/// spot, and changing Prompt configuration most likely doesn't mean updating the context.
fn handle_prompt_changed(
&mut self,
_: ModelHandle<Prompt>,
_prompt_event: &<Prompt as Entity>::Event,
ctx: &mut ModelContext<Self>,
) {
@@ -1164,6 +1124,7 @@ impl CurrentPrompt {
fn handle_session_settings_changed(
&mut self,
_: ModelHandle<SessionSettings>,
event: &SessionSettingsChangedEvent,
ctx: &mut ModelContext<Self>,
) {
@@ -1192,6 +1153,12 @@ impl CurrentPrompt {
// Recompute which chips to run when the agent footer config changes.
self.update_states_with_new_context(ctx);
}
if let SessionSettingsChangedEvent::GithubPrChipDefaultValidation { .. } = event {
// Re-resolve the default prompt's chip list (which gates the
// PR chip on `is_suppressed()`) and re-run chips with the new
// suppression state.
self.update_states_with_new_context(ctx);
}
if let SessionSettingsChangedEvent::CLIAgentToolbarChipSelectionSetting { .. } = event {
self.update_states_with_new_context(ctx);
@@ -1220,7 +1187,6 @@ impl CurrentPrompt {
ctx: &mut galaxyui::AppContext,
) -> futures_util::future::BoxFuture<'static, ()> {
use futures_util::FutureExt;
use itertools::Itertools;
// This structure prevents the returned Future from referencing self.
let chip_futures = self
.states
@@ -1258,7 +1224,12 @@ impl CurrentPrompt {
.any(|handle| !handle.abort_handle().is_aborted())
}
fn handle_model_event(&mut self, event: &ModelEvent, ctx: &mut ModelContext<Self>) {
fn handle_model_event(
&mut self,
_: ModelHandle<ModelEventDispatcher>,
event: &ModelEvent,
ctx: &mut ModelContext<Self>,
) {
if let ModelEvent::AfterBlockCompleted(after_block_completed) = event {
if let BlockType::User(UserBlockCompleted { command, .. }) =
&after_block_completed.block_type
@@ -1407,9 +1378,9 @@ impl CurrentPrompt {
}
/// Set the per-repo git status model handle. When `Some`, subscribes to
/// metadata-changed events so `ShellGitBranch` and `GitDiffStats` are updated
/// by filesystem events instead of the 30s periodic timer.
#[cfg(feature = "local_fs")]
/// metadata events so git-backed prompt chips are updated from the
/// per-repo status model. PR info is handled separately by
/// [`Self::set_github_repo_model`].
pub fn set_git_repo_status(
&mut self,
handle: Option<WeakModelHandle<GitRepoStatusModel>>,
@@ -1422,169 +1393,162 @@ impl CurrentPrompt {
}
}
// Repo detached, clear git chips that require repository metadata.
if handle.is_none() {
for chip_kind in [
ContextChipKind::GitDiffStats,
ContextChipKind::GitBranchStatus,
] {
if let Some(state) = self.states.get_mut(&chip_kind) {
state.clear_abort_handlers();
state.clear_cache();
}
}
let _ = self.update_tx.try_send(());
return;
}
if let Some(weak) = handle {
if let Some(strong) = weak.upgrade(ctx) {
self.git_repo_status = Some(weak);
ctx.subscribe_to_model(&strong, |me, event, ctx| match event {
ctx.subscribe_to_model(&strong, |me, _, event, ctx| match event {
GitRepoStatusEvent::MetadataChanged => {
let metadata = me
.git_repo_status
.as_ref()
.and_then(|w| w.upgrade(ctx))
.and_then(|h| h.as_ref(ctx).metadata().cloned());
let Some(metadata) = metadata else {
return;
};
// Update ShellGitBranch.
let new_branch = ChipValue::Text(metadata.current_branch_name.clone());
let current_branch = me
.latest_chip_value(&ContextChipKind::ShellGitBranch)
.cloned();
if current_branch.as_ref() != Some(&new_branch) {
me.update_chip_value(
&ContextChipKind::ShellGitBranch,
Some(new_branch),
);
// Refresh the branch dropdown so it stays in sync.
let chip_kind = ContextChipKind::ShellGitBranch;
if let Some(chip) = chip_kind.to_chip() {
if let Some(on_click_gen) = chip.on_click_generator().cloned() {
me.refresh_on_click_values(&chip_kind, on_click_gen, ctx);
}
}
}
// Update GitDiffStats with structured data directly.
let new_diff_stats = ChipValue::GitDiffStats(
GitLineChanges::from_diff_stats(&metadata.stats_against_head),
);
let current_diff_stats = me
.latest_chip_value(&ContextChipKind::GitDiffStats)
.cloned();
if current_diff_stats.as_ref() != Some(&new_diff_stats) {
me.update_chip_value(
&ContextChipKind::GitDiffStats,
Some(new_diff_stats),
);
}
me.apply_git_repo_metadata(ctx);
}
});
// Eagerly populate chips if metadata is already available (the
// initial `refresh_metadata` in `GitRepoStatusModel::new` may
// have completed before we subscribed). If it hasn't finished
// yet, the subscription above will catch the `MetadataChanged`
// event when it does.
if strong.as_ref(ctx).metadata(ctx).is_some() {
self.apply_git_repo_metadata(ctx);
}
}
}
}
/// Set the per-repo GitHub-info model handle. When `Some`, subscribes to
/// its events so the `GithubPullRequest` chip value is updated.
pub fn set_github_repo_model(
&mut self,
handle: Option<WeakModelHandle<GitHubRepoModel>>,
ctx: &mut ModelContext<Self>,
) {
// Unsubscribe from the previous model, if any.
if let Some(old_weak) = self.github_repo_model.take() {
if let Some(old_strong) = old_weak.upgrade(ctx) {
ctx.unsubscribe_from_model(&old_strong);
}
}
if handle.is_none() {
// GitHub-info handle detached: clear any stale PR chip state.
if let Some(state) = self.states.get_mut(&ContextChipKind::GithubPullRequest) {
state.clear_abort_handlers();
state.clear_cache();
}
let _ = self.update_tx.try_send(());
return;
}
if let Some(weak) = handle {
if let Some(strong) = weak.upgrade(ctx) {
self.github_repo_model = Some(weak);
// Only PR info drives the chip value; repository name/owner
// changes don't affect it.
ctx.subscribe_to_model(&strong, |me, _, event, ctx| match event {
GitHubRepoEvent::PrInfoChanged => {
me.sync_pr_chip_from_model(ctx);
}
GitHubRepoEvent::RepositoryInfoChanged => {}
});
// Eagerly populate the PR chip if PR info has already landed.
self.sync_pr_chip_from_model(ctx);
}
}
}
/// Read the current `GitRepoStatusModel` metadata and push it into the
/// git-backed chip states.
fn apply_git_repo_metadata(&mut self, ctx: &mut ModelContext<Self>) {
let metadata = self
.git_repo_status
.as_ref()
.and_then(|w| w.upgrade(ctx))
.and_then(|h| h.as_ref(ctx).metadata(ctx).cloned());
let Some(metadata) = metadata else {
return;
};
// Update ShellGitBranch.
let new_branch = ChipValue::Text(metadata.current_branch_name.clone());
let current_branch = self
.latest_chip_value(&ContextChipKind::ShellGitBranch)
.cloned();
if current_branch.as_ref() != Some(&new_branch) {
self.update_chip_value(&ContextChipKind::ShellGitBranch, Some(new_branch));
// Refresh the branch dropdown so it stays in sync.
let chip_kind = ContextChipKind::ShellGitBranch;
if let Some(chip) = chip_kind.to_chip() {
if let Some(on_click_gen) = chip.on_click_generator().cloned() {
self.refresh_on_click_values(&chip_kind, on_click_gen, ctx);
}
}
}
let new_branch_status = ChipValue::GitBranchStatus(metadata.branch_tracking_status.clone());
let current_branch_status = self
.latest_chip_value(&ContextChipKind::GitBranchStatus)
.cloned();
if current_branch_status.as_ref() != Some(&new_branch_status) {
self.update_chip_value(&ContextChipKind::GitBranchStatus, Some(new_branch_status));
}
// Update GitDiffStats with structured data directly.
let new_diff_stats = ChipValue::GitDiffStats(GitLineChanges::from_diff_stats(
&metadata.stats_against_head,
));
let current_diff_stats = self
.latest_chip_value(&ContextChipKind::GitDiffStats)
.cloned();
if current_diff_stats.as_ref() != Some(&new_diff_stats) {
self.update_chip_value(&ContextChipKind::GitDiffStats, Some(new_diff_stats));
}
}
/// Reads PR info from the per-repo `GitHubRepoModel` and updates the
/// `GithubPullRequest` chip value if it differs from the current one.
fn sync_pr_chip_from_model(&mut self, ctx: &AppContext) {
let new_pr_value = self
.github_repo_model
.as_ref()
.and_then(|w| w.upgrade(ctx))
.and_then(|h| {
h.as_ref(ctx)
.pr_info(ctx)
.map(|info| ChipValue::Text(info.url.clone()))
});
let current_pr = self
.latest_chip_value(&ContextChipKind::GithubPullRequest)
.cloned();
if current_pr != new_pr_value {
self.update_chip_value(&ContextChipKind::GithubPullRequest, new_pr_value);
}
}
/// Returns `true` when the given chip's value is updated externally
/// (e.g. by a filesystem watcher) and the periodic timer should be skipped.
fn is_updated_externally(&self, chip_kind: &ContextChipKind) -> bool {
#[cfg(feature = "local_fs")]
{
if matches!(
chip_kind,
ContextChipKind::ShellGitBranch | ContextChipKind::GitDiffStats
) {
return self.git_repo_status.is_some();
}
}
let _ = chip_kind;
false
}
/// Heuristic check for `gh` CLI authentication errors in stderr output.
fn is_gh_auth_error(stderr: &str) -> bool {
let lower = stderr.to_lowercase();
lower.contains("not logged in")
|| lower.contains("authentication required")
|| lower.contains("gh auth login")
}
fn github_pr_prompt_chip_command_outcome(
output: Option<&CommandOutput>,
timed_out: bool,
) -> GithubPrPromptChipCommandOutcome {
if timed_out {
return GithubPrPromptChipCommandOutcome::RetryableFailure;
}
match output {
Some(command_output) if command_output.status == CommandExitStatus::Success => {
GithubPrPromptChipCommandOutcome::Validated
}
Some(command_output) => {
let stderr = String::from_utf8(command_output.stderr.clone()).unwrap_or_default();
if Self::is_gh_auth_error(&stderr) {
GithubPrPromptChipCommandOutcome::DeterministicAuthFailure
} else {
GithubPrPromptChipCommandOutcome::RetryableFailure
}
}
None => GithubPrPromptChipCommandOutcome::RetryableFailure,
}
}
fn should_cache_failure_fingerprint(
chip_kind: &ContextChipKind,
output: Option<&CommandOutput>,
timed_out: bool,
) -> bool {
if !matches!(chip_kind, ContextChipKind::GithubPullRequest) {
return true;
}
matches!(
Self::github_pr_prompt_chip_command_outcome(output, timed_out),
GithubPrPromptChipCommandOutcome::DeterministicAuthFailure
)
}
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));
});
}
}
/// On session changes (including app startup), re-check whether a previously
/// suppressed PR chip should get another chance. Suppression is sticky across
/// restarts, but if the user has since installed `gh`, resetting to Unvalidated
/// lets the normal chip execution path re-validate or re-suppress.
fn maybe_unsuppress_github_pr_default(&self, ctx: &mut ModelContext<Self>) {
if !SessionSettings::as_ref(ctx)
.github_pr_chip_default_validation
.is_suppressed()
{
return;
}
let gh_on_path = self
.with_current_generator_context(ctx, |generator_context| {
generator_context.active_session.is_some_and(|session| {
session.has_loaded_external_commands()
&& session.executable_names().any(|name| name == "gh")
})
})
.unwrap_or(false);
if gh_on_path {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.github_pr_chip_default_validation
.set_value(GithubPrPromptChipDefaultValidation::Unvalidated, 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::Unvalidated {
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.github_pr_chip_default_validation
.set_value(GithubPrPromptChipDefaultValidation::Validated, ctx));
});
match chip_kind {
ContextChipKind::ShellGitBranch
| ContextChipKind::GitBranchStatus
| ContextChipKind::GitDiffStats => self.git_repo_status.is_some(),
ContextChipKind::GithubPullRequest => self.github_repo_model.is_some(),
_ => false,
}
}
+17 -7
View File
@@ -1,13 +1,14 @@
use std::cmp::Ordering;
use crate::completer::SessionContext;
use crate::ui_components::icons::Icon;
use galaxy_completer::completer::{EngineDirEntry, EngineFileType, PathCompletionContext};
use galaxy_util::file_type::is_binary_file;
use galaxyui::{r#async::SpawnedFutureHandle, AppContext, Entity, ModelContext};
use typed_path::TypedPathBuf;
use galaxy_completer::completer::{EngineDirEntry, EngineFileType};
use galaxy_util::file_type::is_binary_file;
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::{AppContext, Entity, ModelContext};
use super::display_menu::GenericMenuItem;
use crate::completer::SessionContext;
use crate::ui_components::icons::Icon;
/// DirectoryFetcher model that caches directory state and provides an explicit refetch API
pub struct DirectoryFetcher {
@@ -90,8 +91,9 @@ impl DirectoryFetcher {
TypedPathBuf::from(dir_path)
};
// Use SessionContext to get directory entries (works for both local and remote sessions)
let entries = session_context.list_directory_entries(typed_path).await;
// Force re-read the directory from disk so the chip reflects its current contents rather
// than serving the possibly-stale entry from the shared `SessionContext` cache.
let entries = session_context.refresh_directory_entries(typed_path).await;
// Convert EngineDirEntry to GenericMenuItem, filtering out hidden files
let mut items: Vec<DirectoryItem> = entries
@@ -149,6 +151,14 @@ impl Entity for DirectoryFetcher {
type Event = DirectoryFetcherEvent;
}
impl Drop for DirectoryFetcher {
fn drop(&mut self) {
if let Some(handle) = self.fetch_handle.take() {
handle.abort();
}
}
}
#[derive(Debug, Clone, PartialOrd, PartialEq)]
pub enum DirectoryType {
Directory,
+35 -36
View File
@@ -1,34 +1,30 @@
use std::path::PathBuf;
use std::sync::Arc;
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use crate::context_chips::display_chip::format_git_branch_command;
use crate::settings::InputSettings;
use crate::terminal::model_events::ModelEventDispatcher;
use crate::{
ai::blocklist::{BlocklistAIContextModel, BlocklistAIInputEvent, BlocklistAIInputModel},
completer::SessionContext,
context_chips::display_chip::DisplayChipAction,
terminal::input::MenuPositioningProvider,
};
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::{
ChildView, Clipped, Container, CrossAxisAlignment, Element, Flex, MainAxisAlignment,
MainAxisSize, ParentElement, Wrap,
};
use galaxyui::{
elements::{
ChildView, Clipped, Container, CrossAxisAlignment, Element, Flex, MainAxisAlignment,
MainAxisSize, ParentElement, Wrap,
},
AppContext, Entity, EntityId, FocusContext, ModelHandle, SingletonEntity, TypedActionView,
View, ViewContext, ViewHandle,
};
use std::path::PathBuf;
use super::{
display_chip::{DisplayChip, DisplayChipConfig, PromptDisplayChipEvent},
git_line_changes_from_chips,
prompt_type::PromptType,
ChipResult, ContextChipKind,
use super::display_chip::{DisplayChip, DisplayChipConfig, PromptDisplayChipEvent};
use super::prompt_type::PromptType;
use super::{git_line_changes_from_chips, ChipResult, ContextChipKind};
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::blocklist::{
BlocklistAIContextModel, BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
BlocklistAIInputEvent, BlocklistAIInputModel,
};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use crate::completer::SessionContext;
use crate::context_chips::display_chip::{DisplayChipAction, PromptChipShellCommand};
use crate::settings::InputSettings;
use crate::terminal::input::MenuPositioningProvider;
use crate::terminal::model_events::ModelEventDispatcher;
/// Enum introduced to abstract over the different row types we use for the prompt display,
/// between the non-UDI and UDI cases.
@@ -91,7 +87,7 @@ pub enum PromptDisplayEvent {
OpenConversationHistory,
OpenCommandPaletteFiles,
RunAgentQuery(String),
TryExecuteCommand(String),
TryExecuteCommand(PromptChipShellCommand),
OpenAIDocument {
document_id: AIDocumentId,
document_version: AIDocumentVersion,
@@ -130,8 +126,11 @@ impl PromptDisplay {
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|me, _, event, ctx| {
if let BlocklistAIHistoryEvent::UpdatedTodoList { terminal_view_id } = event {
if *terminal_view_id != me.terminal_view_id {
if let BlocklistAIHistoryEvent::UpdatedTodoList {
terminal_surface_id,
} = event
{
if *terminal_surface_id != me.terminal_view_id {
return;
}
ctx.notify();
@@ -174,15 +173,9 @@ impl PromptDisplay {
|| new_chips.iter().enumerate().any(|(i, chip_result)| {
let existing_chip = &self.display_chips[i];
existing_chip.read(ctx, |chip, _| {
chip.text()
!= chip_result
.value
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default()
chip.value() != chip_result.value.as_ref()
|| chip.chip_kind() != &chip_result.kind
// I'm only comparing the first on-click values for efficiency, but we may need to change this in the future.
|| chip.first_on_click_value() != chip_result.on_click_values.first()
|| chip.on_click_values() != chip_result.on_click_values.as_slice()
})
})
}
@@ -294,7 +287,9 @@ impl PromptDisplay {
pub fn on_pane_focus_changed(&mut self, focused: bool, ctx: &mut ViewContext<Self>) {
self.pane_is_focused = focused;
let new_chips = self.collect_chips(ctx);
self.reset_chips(&new_chips, ctx);
if self.check_if_chip_values_have_changed(&new_chips, ctx) {
self.reset_chips(&new_chips, ctx);
}
ctx.notify();
}
@@ -359,7 +354,9 @@ impl PromptDisplay {
pub fn update_repo_path(&mut self, repo_path: Option<PathBuf>, ctx: &mut ViewContext<Self>) {
self.current_repo_path = repo_path;
let new_chips = self.collect_chips(ctx);
self.reset_chips(&new_chips, ctx);
if self.check_if_chip_values_have_changed(&new_chips, ctx) {
self.reset_chips(&new_chips, ctx);
}
ctx.notify();
}
}
@@ -375,7 +372,9 @@ impl TypedActionView for PromptDisplay {
match action {
PromptDisplayAction::SelectGitBranch { value } => {
ctx.emit(PromptDisplayEvent::TryExecuteCommand(
format_git_branch_command(value),
PromptChipShellCommand::GitCheckout {
branch_name: value.clone(),
},
));
}
}
+594 -79
View File
@@ -1,21 +1,55 @@
use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Arc;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use warpui::elements::{
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Empty, Flex, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Stack, Text, DEFAULT_UI_LINE_HEIGHT_RATIO,
};
use warpui::fonts::{Cache, FamilyId, Properties, Weight};
use warpui::keymap::Keystroke;
use warpui::platform::Cursor;
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::{
AppContext, Element, Entity, EntityId, Gradient, ModelHandle, SingletonEntity, TypedActionView,
View, ViewContext, ViewHandle,
};
use super::directory_fetcher::{
DirectoryFetcher, DirectoryFetcherEvent, DirectoryItem, DirectoryType,
};
use super::display_menu::{
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
};
use super::{
agent_view_chip_color, github_pr_display_text_from_url, render_text_from_kind, ChipResult,
ChipValue, ContextChipKind,
};
use crate::ai::blocklist::agent_view::AgentViewController;
use crate::ai::blocklist::prompt::plan_and_todo_list::{PlanAndTodoListEvent, PlanAndTodoListView};
use crate::ai::{
blocklist::{BlocklistAIContextModel, BlocklistAIInputModel},
document::ai_document_model::{AIDocumentId, AIDocumentVersion},
};
use crate::ai::blocklist::{BlocklistAIContextModel, BlocklistAIInputModel};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
use crate::appearance::Appearance;
use crate::code::editor::{add_color, remove_color};
use crate::code_review::code_review_view::CODE_REVIEW_TOOLTIP_TEXT;
use crate::code_review::diff_state::DiffStats;
use crate::completer::SessionContext;
use crate::context_chips::git_branch_on_click::{
is_plausible_new_branch_name, GitBranchOnClickValue,
};
use crate::context_chips::node_version_popup::{NodeVersionPopupEvent, NodeVersionPopupView};
use crate::context_chips::spacing;
use crate::settings::{AISettings, AISettingsChangedEvent, InputSettings};
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::input::{MenuPositioning, MenuPositioningProvider};
use crate::terminal::model::session::SessionType;
use crate::terminal::model_events::ModelEventDispatcher;
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;
use crate::ui_components::blended_colors;
@@ -24,40 +58,8 @@ use crate::util::bindings::keybinding_name_to_display_string;
use crate::util::truncation::truncate_from_beginning;
use crate::view_components::action_button::{ActionButtonTheme, NakedTheme};
use crate::view_components::{FeaturePopup, NewFeaturePopupEvent, NewFeaturePopupLabel};
use galaxy_core::ui::theme::Fill;
use galaxy_core::{features::FeatureFlag, ui::theme::color::internal_colors};
use galaxyui::elements::Empty;
use galaxyui::keymap::Keystroke;
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::UiComponentStyles;
use galaxyui::ui_components::components::{Coords, UiComponent};
use galaxyui::{
elements::{
Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Flex, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack, Text, DEFAULT_UI_LINE_HEIGHT_RATIO,
},
fonts::{Cache, FamilyId, Properties, Weight},
AppContext, Element, Entity, EntityId, Gradient, ModelHandle, SingletonEntity, TypedActionView,
View, ViewContext, ViewHandle,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use std::path::PathBuf;
use crate::appearance::Appearance;
use crate::completer::SessionContext;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
use super::{
agent_view_chip_color,
directory_fetcher::{DirectoryFetcher, DirectoryFetcherEvent, DirectoryItem, DirectoryType},
display_menu::{
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
},
github_pr_display_text_from_url, render_text_from_kind, ChipResult, ContextChipKind,
};
use crate::workspace::view::TOGGLE_RIGHT_PANEL_BINDING_NAME;
use crate::{send_telemetry_from_ctx, TelemetryEvent};
/// Helper function to render git diff stats content (file icon or +- icons, file count, bullet, +/- counts)
/// Used by both the context chips and the AI control panel
@@ -164,6 +166,62 @@ pub fn render_git_diff_stats_content(
git_content.finish()
}
fn git_branch_status_icon(icon: Icon, color: ColorU, icon_size: f32) -> Box<dyn Element> {
Container::new(
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(color)).finish())
.with_height(icon_size)
.with_width(icon_size)
.finish(),
)
.finish()
}
fn git_branch_status_text(
text: String,
color: ColorU,
font_family: FamilyId,
font_size: f32,
appearance: &Appearance,
) -> Box<dyn Element> {
Text::new_inline(text, font_family, font_size)
.with_color(Fill::Solid(color).into())
.with_line_height_ratio(appearance.line_height_ratio())
.with_style(Properties::default().weight(Weight::Semibold))
.finish()
}
fn git_branch_status_count(
icon: Icon,
count: String,
color: ColorU,
font_family: FamilyId,
font_size: f32,
appearance: &Appearance,
) -> Box<dyn Element> {
let mut content = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
add_git_branch_status_child(
&mut content,
git_branch_status_icon(icon, color, GIT_BRANCH_STATUS_STATUS_ICON_SIZE),
GIT_BRANCH_STATUS_COUNT_GAP,
);
content.add_child(git_branch_status_text(
count,
color,
font_family,
font_size,
appearance,
));
content.finish()
}
fn add_git_branch_status_child(content: &mut Flex, child: Box<dyn Element>, margin_right: f32) {
content.add_child(
Container::new(child)
.with_margin_right(margin_right)
.finish(),
);
}
const PROMPT_CHIP_DISPLAY_ID: &str = "PromptChipDisplay";
const DROP_SHADOW_COLOR: ColorU = ColorU {
r: 0,
@@ -176,6 +234,12 @@ const CHIP_MARGIN_RIGHT: f32 = 8.;
const UDI_CHIP_MAX_NUM_CHARACTERS: usize = 40;
const CHIP_CORNER_RADIUS: f32 = 4.0;
const GIT_BRANCH_STATUS_CHIP_HORIZONTAL_PADDING: f32 = 8.0;
const GIT_BRANCH_STATUS_CHIP_VERTICAL_PADDING: f32 = 4.0;
const GIT_BRANCH_STATUS_MAIN_GAP: f32 = 4.0;
const GIT_BRANCH_STATUS_COUNT_GAP: f32 = 2.0;
const GIT_BRANCH_STATUS_BRANCH_ICON_SIZE: f32 = 14.0;
const GIT_BRANCH_STATUS_STATUS_ICON_SIZE: f32 = 12.0;
pub(crate) const CHIP_BORDER_WIDTH: f32 = 1.0;
/// Inner rounded corners are 1px smaller than the outer border radius
const CHIP_INNER_CORNER_RADIUS: f32 = CHIP_CORNER_RADIUS - CHIP_BORDER_WIDTH;
@@ -284,10 +348,11 @@ pub struct DisplayChip {
mouse_state: MouseStateHandle,
diff_stats_mouse_state: MouseStateHandle,
text: String,
value: Option<ChipValue>,
chip_kind: ContextChipKind,
display_chip_kind: DisplayChipKind,
next_chip_kind: Option<ContextChipKind>,
first_on_click_value: Option<String>,
on_click_values: Vec<String>,
quota_reset_popup: ViewHandle<FeaturePopup>,
session_context: Option<SessionContext>,
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
@@ -355,6 +420,198 @@ impl GitLineChanges {
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GitBranchTrackingStatus {
pub branch: String,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
pub counts_available: bool,
#[serde(default)]
pub rebased: bool,
}
impl GitBranchTrackingStatus {
pub fn new(branch: String, upstream: Option<String>, ahead: u32, behind: u32) -> Self {
let counts_available = upstream.is_some();
Self {
branch,
upstream,
ahead,
behind,
counts_available,
rebased: false,
}
}
pub fn without_counts(branch: String, upstream: Option<String>) -> Self {
Self {
branch,
upstream,
ahead: 0,
behind: 0,
counts_available: false,
rebased: false,
}
}
pub fn rebased(branch: String, upstream: String) -> Self {
Self {
branch,
upstream: Some(upstream),
ahead: 0,
behind: 0,
counts_available: true,
rebased: true,
}
}
pub fn from_display_text(text: &str) -> Option<Self> {
let text = text.trim();
if text.is_empty() {
return None;
}
let Some((branch, status_text)) = text.rsplit_once("") else {
return Some(Self {
branch: text.to_string(),
upstream: None,
ahead: 0,
behind: 0,
counts_available: false,
rebased: false,
});
};
let Some((ahead, behind, rebased)) = Self::parse_display_status(status_text) else {
return Some(Self {
branch: text.to_string(),
upstream: None,
ahead: 0,
behind: 0,
counts_available: false,
rebased: false,
});
};
let branch = branch.trim();
if branch.is_empty() {
return None;
}
Some(Self {
branch: branch.to_string(),
upstream: None,
ahead,
behind,
counts_available: true,
rebased,
})
}
pub fn display_text(&self) -> String {
let mut parts = Vec::new();
if self.is_rebased() {
parts.push("".to_string());
} else {
if let Some(ahead) = self.ahead_display_count() {
parts.push(format!("{ahead}"));
}
if let Some(behind) = self.behind_display_count() {
parts.push(format!("{behind}"));
}
}
if parts.is_empty() {
self.branch.clone()
} else {
format!("{}{}", self.branch, parts.join(" "))
}
}
pub fn is_rebased(&self) -> bool {
self.counts_available && self.rebased
}
pub fn ahead_display_count(&self) -> Option<String> {
(!self.is_rebased() && self.counts_available && self.ahead > 0)
.then(|| Self::format_display_count(self.ahead))
}
pub fn behind_display_count(&self) -> Option<String> {
(!self.is_rebased() && self.counts_available && self.behind > 0)
.then(|| Self::format_display_count(self.behind))
}
fn format_display_count(count: u32) -> String {
const MAX_DISPLAY_COUNT: u32 = 999;
if count > MAX_DISPLAY_COUNT {
format!("{MAX_DISPLAY_COUNT}+")
} else {
count.to_string()
}
}
fn parse_display_count(count: &str) -> Option<u32> {
if let Some(capped_count) = count.strip_suffix('+') {
capped_count.parse::<u32>().ok()?.checked_add(1)
} else {
count.parse::<u32>().ok()
}
}
fn parse_display_status(status_text: &str) -> Option<(u32, u32, bool)> {
let mut ahead = 0;
let mut behind = 0;
let mut rebased = false;
let mut saw_status_token = false;
for part in status_text.split_whitespace() {
saw_status_token = true;
if part == "" {
rebased = true;
} else if let Some(ahead_count) = part.strip_prefix('↑') {
ahead = Self::parse_display_count(ahead_count)?;
} else if let Some(behind_count) = part.strip_prefix('↓') {
behind = Self::parse_display_count(behind_count)?;
} else {
return None;
}
}
saw_status_token.then_some((ahead, behind, rebased))
}
fn tooltip_text(&self) -> String {
match &self.upstream {
Some(upstream) if self.is_rebased() => {
format!("Tracking {upstream} • branch was rebased")
}
Some(upstream) if self.counts_available => format!(
"Tracking {upstream} • ahead {}, behind {}",
self.ahead, self.behind
),
Some(upstream) => {
format!("Tracking {upstream}; ahead/behind counts are unavailable")
}
None if self.is_rebased() => {
"Branch was rebased; upstream name is unavailable".to_string()
}
None if self.counts_available => format!(
"Ahead {}, behind {}; upstream name is unavailable",
self.ahead, self.behind
),
None => "No upstream configured".to_string(),
}
}
}
impl std::fmt::Display for GitBranchTrackingStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.display_text())
}
}
#[derive(Debug, Clone)]
pub enum DisplayChipKind {
Text,
@@ -379,6 +636,9 @@ pub enum DisplayChipKind {
menu_open: bool,
menu: ViewHandle<DisplayChipMenu>,
},
GitBranchStatus {
tracking_status: Option<GitBranchTrackingStatus>,
},
GithubPullRequest,
GitDiffStats {
line_changes_info: Option<GitLineChanges>,
@@ -392,6 +652,7 @@ impl DisplayChipKind {
DisplayChipKind::NodeVersion { popup_open, .. } => *popup_open,
DisplayChipKind::GitBranch { menu_open, .. } => *menu_open,
DisplayChipKind::GithubPullRequest
| DisplayChipKind::GitBranchStatus { .. }
| DisplayChipKind::GitDiffStats { .. }
| DisplayChipKind::Text
| DisplayChipKind::Ssh
@@ -443,17 +704,88 @@ pub struct DisplayChipConfig {
#[derive(Debug, Clone)]
pub struct GitBranch(String);
impl GitBranch {
fn prompt_chip_command(&self) -> PromptChipShellCommand {
let branch = GitBranchOnClickValue::decode(&self.0);
if let Some(worktree_path) = branch.worktree_path {
return PromptChipShellCommand::ChangeDirectory {
dir_name: worktree_path,
};
}
if branch.is_linked_worktree {
return PromptChipShellCommand::Echo {
message: "The branch is already checked out in another worktree, but Warp couldn't find its path.",
};
}
PromptChipShellCommand::GitCheckout {
branch_name: branch.branch_name,
}
}
fn icon_for_menu(&self) -> Icon {
let branch = GitBranchOnClickValue::decode(&self.0);
if branch.is_linked_worktree {
Icon::Dataflow02
} else {
Icon::GitBranch
}
}
}
impl GenericMenuItem for GitBranch {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn name(&self) -> String {
self.0.clone()
GitBranchOnClickValue::decode(&self.0).branch_name
}
fn icon(&self, _app: &AppContext) -> Option<Icon> {
Some(Icon::GitBranch)
Some(self.icon_for_menu())
}
fn action_data(&self) -> String {
self.0.clone()
}
}
/// Synthetic menu entry shown in the branch switcher when the user types a
/// query that does not match any existing branch. Selecting it runs
/// `git checkout -b <branch>` so the user can create the branch they were
/// about to switch to without leaving the picker.
#[derive(Debug, Clone)]
pub(crate) struct CreateGitBranch(String);
impl CreateGitBranch {
pub(crate) fn new(branch_name: String) -> Self {
Self(branch_name.trim().to_string())
}
pub(crate) fn branch_name(&self) -> &str {
&self.0
}
fn prompt_chip_command(&self) -> PromptChipShellCommand {
PromptChipShellCommand::GitCreateAndCheckoutBranch {
branch_name: self.0.clone(),
}
}
}
impl GenericMenuItem for CreateGitBranch {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn name(&self) -> String {
format!("Create new branch \"{}\"", self.0)
}
fn icon(&self, _app: &AppContext) -> Option<Icon> {
Some(Icon::Plus)
}
fn action_data(&self) -> String {
@@ -544,21 +876,34 @@ impl DisplayChip {
ChipMenuType::Branches,
ctx,
)
.with_create_item_from_query(Arc::new(|query: &str| {
if !is_plausible_new_branch_name(query) {
return None;
}
let create_branch = CreateGitBranch::new(query.to_string());
let arc: Arc<dyn GenericMenuItem> = Arc::new(create_branch);
Some(arc)
}))
});
ctx.subscribe_to_view(&menu_view, |me, _, event, ctx| match event {
PromptDisplayMenuEvent::MenuAction(generic_event) => {
let Some(git_branch) = generic_event
.action_item
.as_any()
.downcast_ref::<GitBranch>()
else {
log::warn!("MenuAction event should contain ActionItem action item");
return;
};
let action_item = generic_event.action_item.as_any();
let command =
if let Some(git_branch) = action_item.downcast_ref::<GitBranch>() {
git_branch.prompt_chip_command()
} else if let Some(create_branch) =
action_item.downcast_ref::<CreateGitBranch>()
{
create_branch.prompt_chip_command()
} else {
log::warn!(
"MenuAction event should contain a GitBranch or CreateGitBranch \
action item"
);
return;
};
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(
format_git_branch_command(&git_branch.name()),
));
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(command));
me.close_git_branch_menu(ctx);
ctx.notify();
}
@@ -577,6 +922,13 @@ impl DisplayChip {
ContextChipKind::GitDiffStats => DisplayChipKind::GitDiffStats {
line_changes_info: None,
},
ContextChipKind::GitBranchStatus => DisplayChipKind::GitBranchStatus {
tracking_status: chip_result
.value
.as_ref()
.and_then(|value| value.as_git_branch_tracking_status())
.cloned(),
},
ContextChipKind::GithubPullRequest => DisplayChipKind::GithubPullRequest,
ContextChipKind::WorkingDirectory => {
let dir_path = chip_result
@@ -602,17 +954,16 @@ impl DisplayChip {
});
// Subscribe to DirectoryFetcher events to update menu
let directory_fetcher_clone = directory_fetcher.clone();
ctx.subscribe_to_model(
&directory_fetcher,
move |display_chip, _model, event, ctx| {
move |display_chip, model, event, ctx| {
match event {
DirectoryFetcherEvent::DirectoryContentsUpdated => {
// Update the existing menu with new directory contents
if let DisplayChipKind::WorkingDirectory { menu, .. } =
&mut display_chip.display_chip_kind
{
let new_files = directory_fetcher_clone
let new_files = model
.read(ctx, |fetcher, _| fetcher.cached_files().to_vec());
// Update the existing menu with new content instead of recreating it
menu.update(ctx, |menu_view, menu_ctx| {
@@ -645,7 +996,9 @@ impl DisplayChip {
DirectoryType::Directory => {
// For directories, navigate action is change directory
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(
format_change_directory_command(&directory_item.name),
PromptChipShellCommand::ChangeDirectory {
dir_name: directory_item.name.clone(),
},
));
me.close_working_directory_menu(ctx);
ctx.notify();
@@ -668,7 +1021,9 @@ impl DisplayChip {
}
DirectoryType::NavigateToParent => {
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(
format_change_directory_command(".."),
PromptChipShellCommand::ChangeDirectory {
dir_name: "..".to_string(),
},
));
me.close_working_directory_menu(ctx);
ctx.notify();
@@ -706,9 +1061,11 @@ impl DisplayChip {
ctx.focus_self();
}
NodeVersionPopupEvent::SelectVersion { version } => {
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(format!(
"nvm use {version}"
)));
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(
PromptChipShellCommand::NvmUse {
version: version.clone(),
},
));
me.close_node_version_popup(ctx);
ctx.focus_self();
}
@@ -726,7 +1083,7 @@ impl DisplayChip {
}
NodeVersionPopupEvent::InstallLatestNodeVersion => {
ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(
"nvm install node".to_string(),
PromptChipShellCommand::NvmInstallLatestNode,
));
me.close_node_version_popup(ctx);
}
@@ -792,11 +1149,16 @@ impl DisplayChip {
Self {
mouse_state: Default::default(),
diff_stats_mouse_state: Default::default(),
text: chip_result.value.map(|v| v.to_string()).unwrap_or_default(),
text: chip_result
.value
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default(),
value: chip_result.value,
chip_kind: chip_result.kind,
display_chip_kind,
next_chip_kind,
first_on_click_value: chip_result.on_click_values.first().cloned(),
on_click_values: chip_result.on_click_values,
quota_reset_popup,
session_context: config.session_context,
menu_positioning_provider: config.menu_positioning_provider,
@@ -828,6 +1190,10 @@ impl DisplayChip {
&self.text
}
pub fn value(&self) -> Option<&ChipValue> {
self.value.as_ref()
}
pub fn chip_kind(&self) -> &ContextChipKind {
&self.chip_kind
}
@@ -836,8 +1202,8 @@ impl DisplayChip {
&self.display_chip_kind
}
pub fn first_on_click_value(&self) -> Option<&String> {
self.first_on_click_value.as_ref()
pub(crate) fn on_click_values(&self) -> &[String] {
&self.on_click_values
}
pub fn close_git_branch_menu(&mut self, ctx: &mut ViewContext<Self>) {
@@ -880,6 +1246,7 @@ impl DisplayChip {
}
}
DisplayChipKind::GitDiffStats { .. }
| DisplayChipKind::GitBranchStatus { .. }
| DisplayChipKind::Text
| DisplayChipKind::Ssh
| DisplayChipKind::Subshell
@@ -1110,6 +1477,127 @@ impl DisplayChip {
.finish()
}
fn git_branch_status_chip(
&self,
tracking_status: &Option<GitBranchTrackingStatus>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_color = internal_colors::neutral_6(theme);
let font_family = appearance.ui_font_family();
let font_size = udi_font_size(appearance);
let fallback_branch = self.text.clone();
let tracking_status = tracking_status
.clone()
.or_else(|| GitBranchTrackingStatus::from_display_text(&self.text));
let tooltip_text = tracking_status
.as_ref()
.map(GitBranchTrackingStatus::tooltip_text);
Hoverable::new(self.mouse_state.clone(), move |state| {
let branch = tracking_status
.as_ref()
.map(|status| status.branch.clone())
.unwrap_or_else(|| fallback_branch.clone());
let mut content = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
add_git_branch_status_child(
&mut content,
git_branch_status_icon(
Icon::GitBranch,
font_color,
GIT_BRANCH_STATUS_BRANCH_ICON_SIZE,
),
GIT_BRANCH_STATUS_MAIN_GAP,
);
add_git_branch_status_child(
&mut content,
git_branch_status_text(branch, font_color, font_family, font_size, appearance),
GIT_BRANCH_STATUS_MAIN_GAP,
);
if let Some(status) = tracking_status.as_ref() {
let show_rebased = status.is_rebased();
let ahead = status.ahead_display_count();
let behind = status.behind_display_count();
if show_rebased || ahead.is_some() || behind.is_some() {
add_git_branch_status_child(
&mut content,
git_branch_status_text(
"".to_string(),
font_color,
font_family,
font_size,
appearance,
),
GIT_BRANCH_STATUS_MAIN_GAP,
);
}
if show_rebased {
content.add_child(git_branch_status_icon(
Icon::SwitchVertical02,
font_color,
GIT_BRANCH_STATUS_STATUS_ICON_SIZE,
));
} else {
if let Some(ahead) = ahead {
add_git_branch_status_child(
&mut content,
git_branch_status_count(
Icon::ArrowUp,
ahead,
font_color,
font_family,
font_size,
appearance,
),
GIT_BRANCH_STATUS_MAIN_GAP,
);
}
if let Some(behind) = behind {
content.add_child(git_branch_status_count(
Icon::ArrowDown,
behind,
font_color,
font_family,
font_size,
appearance,
));
}
}
}
let mut chip_element = Container::new(content.finish())
.with_background(theme.surface_1())
.with_border(
Border::all(CHIP_BORDER_WIDTH)
.with_border_color(internal_colors::neutral_3(theme)),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CHIP_CORNER_RADIUS)))
.with_vertical_padding(GIT_BRANCH_STATUS_CHIP_VERTICAL_PADDING)
.with_horizontal_padding(GIT_BRANCH_STATUS_CHIP_HORIZONTAL_PADDING);
if state.is_hovered() {
chip_element = chip_element.with_background(theme.surface_2());
}
let mut stack = Stack::new().with_child(chip_element.finish());
if state.is_hovered() {
if let Some(tooltip_text) = tooltip_text.clone() {
let tool_tip = appearance
.ui_builder()
.tool_tip(tooltip_text)
.build()
.finish();
stack.add_positioned_overlay_child(tool_tip, udi_tooltip_positioning());
}
}
stack.finish()
})
.finish()
}
fn git_diff_stats_chip(
&self,
line_changes_info: &Option<GitLineChanges>,
@@ -1143,13 +1631,21 @@ impl DisplayChip {
appearance,
);
let is_local_session = self
// Code review is only supported on local sessions and
// on remote sessions with a connected host ID.
let supports_code_review = self
.session_context
.as_ref()
.map(|ctx| ctx.session.is_local())
.unwrap_or(true);
.map(|ctx| match ctx.session.session_type() {
SessionType::Local => true,
SessionType::WarpifiedRemote { host_id: Some(_) } => {
FeatureFlag::RemoteCodeReview.is_enabled()
}
SessionType::WarpifiedRemote { host_id: None } => false,
})
.unwrap_or(false);
let diff_stats_display = if is_local_session {
let diff_stats_display = if supports_code_review {
// Get the keybinding for the tooltip
let code_review_keybinding = self.code_review_keybinding.clone().unwrap_or_default();
@@ -1189,7 +1685,6 @@ impl DisplayChip {
.with_cursor(Cursor::PointingHand)
.finish()
} else {
// Remote session: chip is non-interactive (no tooltip, no click handler)
Container::new(git_diff_stats_content)
.with_vertical_padding(2.)
.with_horizontal_padding(4.)
@@ -1487,6 +1982,9 @@ impl DisplayChip {
DisplayChipKind::GitBranch { menu_open, menu } => {
Some(self.git_branch_chip(*menu_open, menu, app))
}
DisplayChipKind::GitBranchStatus { tracking_status } => {
Some(self.git_branch_status_chip(tracking_status, app))
}
DisplayChipKind::GithubPullRequest => Some(self.github_pull_request_chip(app)),
DisplayChipKind::GitDiffStats { line_changes_info } => {
self.git_diff_stats_chip(line_changes_info, app)
@@ -1535,6 +2033,30 @@ impl View for DisplayChip {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptChipShellCommand {
GitCheckout {
branch_name: String,
},
GitCreateAndCheckoutBranch {
branch_name: String,
},
ChangeDirectory {
dir_name: String,
},
NvmUse {
version: String,
},
NvmInstallLatestNode,
Echo {
/// The message to echo.
///
/// This is very intentionally a `&'static str` to ensure that the message is a compile-time constant.
/// This is to prevent accidental injection of user input into the message.
message: &'static str,
},
}
pub enum PromptDisplayChipEvent {
OpenFile(String),
OpenTextFileInCodeEditor(String),
@@ -1544,7 +2066,7 @@ pub enum PromptDisplayChipEvent {
OpenCodeReview,
OpenConversationHistory,
OpenCommandPaletteFiles,
TryExecuteCommand(String),
TryExecuteCommand(PromptChipShellCommand),
RunAgentQuery(String),
OpenAIDocument {
document_id: AIDocumentId,
@@ -1581,6 +2103,7 @@ impl TypedActionView for DisplayChip {
| DisplayChipKind::AgentPlanAndTodoList { .. }
| DisplayChipKind::Text
| DisplayChipKind::GithubPullRequest
| DisplayChipKind::GitBranchStatus { .. }
| DisplayChipKind::GitDiffStats { .. } => {}
DisplayChipKind::NodeVersion { popup_open, .. } => {
*popup_open = false;
@@ -1774,14 +2297,6 @@ impl ActionButtonTheme for EnterAgentViewButton {
}
}
fn format_change_directory_command(dir_name: &str) -> String {
format!("cd '{}'", dir_name.replace("'", "'\\'''"))
}
pub fn format_git_branch_command(branch_name: &str) -> String {
format!("git checkout {branch_name}")
}
pub(crate) fn chip_container(
content: Box<dyn Element>,
border_override: Option<Border>,
@@ -1881,5 +2396,5 @@ pub fn udi_icon_size(appearance: &Appearance, app: &AppContext) -> f32 {
}
#[cfg(test)]
#[path = "display_chip_test.rs"]
#[path = "display_chip_tests.rs"]
mod tests;
@@ -1,5 +1,11 @@
use super::{truncate_from_beginning, GitLineChanges};
use super::{
truncate_from_beginning, CreateGitBranch, GitBranch, GitBranchTrackingStatus, GitLineChanges,
};
use crate::context_chips::display_chip::PromptChipShellCommand;
use crate::context_chips::display_menu::GenericMenuItem;
use crate::context_chips::git_branch_on_click::GitBranchOnClickValue;
use crate::context_chips::{github_pr_display_text_from_url, ContextChipKind};
use crate::ui_components::icons::Icon;
#[test]
fn test_github_pr_display_text_from_url() {
@@ -40,6 +46,218 @@ fn test_github_pr_chip_display_value_falls_back_to_raw_value() {
);
}
#[test]
fn test_git_branch_tracking_status_displays_ahead_and_behind_when_upstream_exists() {
let status = GitBranchTrackingStatus::new(
"feature-a".to_string(),
Some("origin/feature-a".to_string()),
2,
1,
);
assert_eq!(status.display_text(), "feature-a • ↑2 ↓1");
}
#[test]
fn test_git_branch_tracking_status_hides_zero_counts() {
let status = GitBranchTrackingStatus::new(
"feature-a".to_string(),
Some("origin/feature-a".to_string()),
2,
0,
);
assert_eq!(status.display_text(), "feature-a • ↑2");
}
#[test]
fn test_git_branch_tracking_status_caps_large_counts() {
let status = GitBranchTrackingStatus::new(
"feature-a".to_string(),
Some("origin/feature-a".to_string()),
1000,
1001,
);
assert_eq!(status.display_text(), "feature-a • ↑999+ ↓999+");
}
#[test]
fn test_git_branch_tracking_status_displays_rebased_indicator() {
let status =
GitBranchTrackingStatus::rebased("feature-a".to_string(), "origin/feature-a".to_string());
assert_eq!(status.display_text(), "feature-a • ⇅");
}
#[test]
fn test_git_branch_tracking_status_parses_shell_fallback_display_text() {
let status = GitBranchTrackingStatus::from_display_text("feature-a • ↑999+ ↓2").unwrap();
assert_eq!(status.branch, "feature-a");
assert_eq!(status.ahead, 1000);
assert_eq!(status.behind, 2);
assert!(status.counts_available);
}
#[test]
fn test_git_branch_tracking_status_keeps_branch_names_with_bullet_delimiter() {
let status = GitBranchTrackingStatus::from_display_text("feature • test").unwrap();
assert_eq!(status.branch, "feature • test");
assert!(!status.counts_available);
}
#[test]
fn test_git_branch_tracking_status_parses_status_after_branch_name_with_bullet_delimiter() {
let status = GitBranchTrackingStatus::from_display_text("feature • test • ↑2").unwrap();
assert_eq!(status.branch, "feature • test");
assert_eq!(status.ahead, 2);
assert!(status.counts_available);
}
#[test]
fn test_git_branch_tracking_status_tooltip_reports_fallback_counts_without_upstream() {
let status = GitBranchTrackingStatus::from_display_text("feature-a • ↑2 ↓1").unwrap();
assert_eq!(
status.tooltip_text(),
"Ahead 2, behind 1; upstream name is unavailable"
);
}
#[test]
fn test_git_branch_tracking_status_parses_shell_fallback_rebased_text() {
let status = GitBranchTrackingStatus::from_display_text("feature-a • ⇅").unwrap();
assert_eq!(status.branch, "feature-a");
assert!(status.is_rebased());
}
#[test]
fn test_git_branch_tracking_status_displays_branch_only_without_upstream() {
let status = GitBranchTrackingStatus::new("feature-a".to_string(), None, 0, 0);
assert_eq!(status.display_text(), "feature-a");
}
#[test]
fn test_git_branch_tracking_status_displays_branch_only_when_counts_are_unavailable() {
let status = GitBranchTrackingStatus::without_counts(
"feature-a".to_string(),
Some("origin/feature-a".to_string()),
);
assert_eq!(status.display_text(), "feature-a");
}
#[test]
fn test_git_branch_status_chip_display_value_uses_git_prefix() {
let value = crate::context_chips::ChipValue::GitBranchStatus(GitBranchTrackingStatus::new(
"feature-a".to_string(),
Some("origin/feature-a".to_string()),
2,
1,
));
assert_eq!(
ContextChipKind::GitBranchStatus.display_value(&value),
"git:(feature-a • ↑2 ↓1)"
);
}
#[test]
fn test_format_git_branch_command_checks_out_normal_branch() {
let value = GitBranchOnClickValue::new("feature/alice's-work".to_string()).encode();
assert_eq!(
GitBranch(value).prompt_chip_command(),
PromptChipShellCommand::GitCheckout {
branch_name: "feature/alice\'s-work".to_string()
}
);
}
#[test]
fn test_format_git_branch_command_changes_to_linked_worktree_path() {
let value = GitBranchOnClickValue {
branch_name: "feature-a".to_string(),
worktree_path: Some("/tmp/repo feature-a".to_string()),
is_linked_worktree: true,
}
.encode();
assert_eq!(
GitBranch(value).prompt_chip_command(),
PromptChipShellCommand::ChangeDirectory {
dir_name: "/tmp/repo feature-a".to_string()
}
);
}
#[test]
fn test_format_git_branch_command_reports_missing_linked_worktree_path() {
let value = GitBranchOnClickValue {
branch_name: "feature-a".to_string(),
worktree_path: None,
is_linked_worktree: true,
}
.encode();
assert_eq!(
GitBranch(value).prompt_chip_command(),
PromptChipShellCommand::Echo {
message: "The branch is already checked out in another worktree, but Warp couldn't find its path."
}
);
}
#[test]
fn test_git_branch_menu_icon_uses_branch_icon_for_normal_branch() {
let value = GitBranchOnClickValue::new("feature-a".to_string()).encode();
assert_eq!(GitBranch(value).icon_for_menu(), Icon::GitBranch);
}
#[test]
fn test_git_branch_menu_icon_uses_worktree_icon_for_linked_worktree() {
let value = GitBranchOnClickValue {
branch_name: "feature-a".to_string(),
worktree_path: Some("/tmp/repo-feature-a".to_string()),
is_linked_worktree: true,
}
.encode();
assert_eq!(GitBranch(value).icon_for_menu(), Icon::Dataflow02);
}
#[test]
fn test_create_git_branch_menu_name_quotes_query() {
let item = CreateGitBranch::new("feature/xyz".to_string());
assert_eq!(item.name(), "Create new branch \"feature/xyz\"");
}
#[test]
fn test_create_git_branch_action_data_returns_branch_name() {
let item = CreateGitBranch::new("feature/xyz".to_string());
assert_eq!(item.action_data(), "feature/xyz");
assert_eq!(item.branch_name(), "feature/xyz");
assert_eq!(
item.prompt_chip_command(),
PromptChipShellCommand::GitCreateAndCheckoutBranch {
branch_name: "feature/xyz".to_string()
}
);
}
#[test]
fn test_create_git_branch_trims_whitespace_in_constructor() {
let item = CreateGitBranch::new(" feature/xyz ".to_string());
assert_eq!(item.branch_name(), "feature/xyz");
assert_eq!(item.name(), "Create new branch \"feature/xyz\"");
}
#[test]
fn test_parse_from_git_output_both_additions_and_deletions() {
let input = " 3 files changed, 5 insertions(+), 2 deletions(-)";
+155 -74
View File
@@ -1,54 +1,49 @@
use std::cmp;
use std::collections::HashMap;
use std::fmt::Debug;
use std::rc::Rc;
use std::sync::Arc;
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
use instant::Instant;
use pathfinder_geometry::vector::vec2f;
use crate::{
ai::cloud_environments::CloudAmbientAgentEnvironment,
cloud_object::model::generic_string_model::StringModel,
editor::{
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
TextOptions,
},
server::ids::{ClientId, HashableId, ServerId, SyncId},
ui_components::icons::Icon,
view_components::copyable_text_field::{
render_copyable_text_field, CopyButtonPlacement, CopyableTextFieldConfig,
COPY_FEEDBACK_DURATION,
},
};
use fuzzy_match::{match_indices_case_insensitive, FuzzyMatchResult};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::builder::MIN_FONT_SIZE;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::{appearance::Appearance, builder::MIN_FONT_SIZE, theme::Fill};
use galaxy_core::ui::theme::Fill;
use galaxy_editor::editor::NavigationKey;
use galaxyui::clipboard::ClipboardContent;
use galaxyui::color::ColorU;
use galaxyui::elements::{
Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, DropShadow, Empty,
EventHandler, Flex, Highlight, Hoverable, MainAxisAlignment, MainAxisSize, MouseInBehavior,
MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, SavePosition, ScrollStateHandle, Scrollable,
ScrollableElement, ScrollbarWidth, Shrinkable, Stack, Text, UniformList, UniformListState,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::FixedBinding;
use galaxyui::r#async::Timer;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::units::Pixels;
use galaxyui::{
color::ColorU,
elements::Highlight,
fonts::{Properties, Weight},
ui_components::components::{Coords, UiComponentStyles},
};
use galaxyui::{
elements::{
Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult,
DropShadow, Empty, EventHandler, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
MouseInBehavior, MouseStateHandle, OffsetPositioning, ParentElement,
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition,
ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Shrinkable, Stack, Text,
UniformList, UniformListState,
},
keymap::FixedBinding,
ui_components::components::UiComponent,
AppContext, Element, Entity, FocusContext, SingletonEntity as _, TypedActionView, View,
ViewContext, ViewHandle, WindowId,
};
use galaxyui::clipboard::ClipboardContent;
use galaxyui::r#async::Timer;
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::cloud_object::CloudObjectLookup as _;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, TextOptions,
};
use crate::server::ids::{ClientId, HashableId, ServerId, SyncId};
use crate::ui_components::icons::Icon;
use crate::view_components::copyable_text_field::{
render_copyable_text_field, CopyButtonPlacement, CopyableTextFieldConfig,
COPY_FEEDBACK_DURATION,
};
/// Trait for items that can be displayed in a generic menu
pub trait GenericMenuItem: Debug + 'static {
@@ -176,17 +171,53 @@ enum EnvironmentSidecarSide {
Right,
}
/// Builds an optional synthetic menu item from the current search query.
///
/// When set, [`DisplayChipMenu`] calls the builder on every search-query
/// change. If the builder returns `Some(item)` and no existing menu item
/// already has the same name (compared ASCII case-insensitively), the
/// returned item is prepended to the filtered results so the user can act on
/// the unmatched query (for example, "Create new branch <name>"). The
/// builder itself is responsible for validating the query (e.g. rejecting
/// empty / invalid inputs) and returning `None` when no synthetic item
/// should be offered.
pub type CreateItemFromQueryFn =
dyn Fn(&str) -> Option<Arc<dyn GenericMenuItem>> + Send + Sync + 'static;
/// Returns whether `query` matches any of `item_names`, ignoring ASCII case.
///
/// Used by [`DisplayChipMenu::update_filtered_items`] to suppress the
/// "create from query" affordance when an existing item already covers the
/// query. The comparison is case-insensitive on purpose: case-insensitive
/// filesystems (the default on macOS and Windows) treat refs like `main` and
/// `Main` as the same branch, so offering "Create new branch \"Main\"" while
/// `main` already exists would just hand the user a `branch already exists`
/// failure from git.
fn query_matches_existing_name<I, S>(item_names: I, query: &str) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
item_names
.into_iter()
.any(|name| name.as_ref().eq_ignore_ascii_case(query))
}
pub struct DisplayChipMenu {
list_state: UniformListState,
scroll_state: ScrollStateHandle,
menu_items: Vec<Arc<dyn GenericMenuItem>>,
filtered_items: Vec<FilteredMenuItem>,
filtered_items: Rc<Vec<FilteredMenuItem>>,
selected_index: usize,
is_footer_selected: bool,
fixed_footer: Option<FixedFooter>,
search_input: Option<ViewHandle<EditorView>>,
search_query: String,
chip_menu_type: ChipMenuType,
/// When set, the menu offers a synthetic "create from query" item whenever
/// the user's query doesn't exactly match an existing item. See
/// [`CreateItemFromQueryFn`].
create_item_from_query: Option<Arc<CreateItemFromQueryFn>>,
// Environment sidecar state
window_id: WindowId,
@@ -338,13 +369,15 @@ impl DisplayChipMenu {
})
.collect();
let filtered_items: Vec<FilteredMenuItem> = menu_items
.iter()
.map(|item| FilteredMenuItem {
item: item.clone(),
match_result: None,
})
.collect();
let filtered_items: Rc<Vec<FilteredMenuItem>> = Rc::new(
menu_items
.iter()
.map(|item| FilteredMenuItem {
item: item.clone(),
match_result: None,
})
.collect(),
);
// Always start selection at the top (first item) for consistent behavior
let initial_selected_index = 0;
@@ -360,6 +393,7 @@ impl DisplayChipMenu {
search_input,
search_query: String::new(),
chip_menu_type,
create_item_from_query: None,
window_id: ctx.window_id(),
env_sidecar_copy_id_mouse_state: Default::default(),
@@ -369,6 +403,13 @@ impl DisplayChipMenu {
}
}
/// Register a builder that produces a synthetic top-of-list item for
/// otherwise-unmatched search queries. See [`CreateItemFromQueryFn`].
pub fn with_create_item_from_query(mut self, builder: Arc<CreateItemFromQueryFn>) -> Self {
self.create_item_from_query = Some(builder);
self
}
pub fn reset_selected_index(&mut self) {
if self.filtered_items.is_empty() && self.fixed_footer.is_some() {
self.is_footer_selected = true;
@@ -406,37 +447,64 @@ impl DisplayChipMenu {
fn update_filtered_items(&mut self) {
if self.search_query.is_empty() {
// No search query - show all items
self.filtered_items = self
.menu_items
.iter()
.map(|item| FilteredMenuItem {
item: item.clone(),
match_result: None,
})
.collect();
} else {
// Filter items based on search query
self.filtered_items = self
.menu_items
.iter()
.filter_map(|item| {
let item_name = item.name();
match_indices_case_insensitive(&item_name, &self.search_query).map(
|match_result| FilteredMenuItem {
item: item.clone(),
match_result: Some(match_result),
},
)
})
.collect();
// Sort by match score (higher scores first)
self.filtered_items.sort_by(|a, b| {
let score_a = a.match_result.as_ref().map(|r| r.score).unwrap_or(0);
let score_b = b.match_result.as_ref().map(|r| r.score).unwrap_or(0);
score_b.cmp(&score_a)
});
self.filtered_items = Rc::new(
self.menu_items
.iter()
.map(|item| FilteredMenuItem {
item: item.clone(),
match_result: None,
})
.collect(),
);
return;
}
// Filter items based on search query
let mut filtered_items: Vec<FilteredMenuItem> = self
.menu_items
.iter()
.filter_map(|item| {
let item_name = item.name();
match_indices_case_insensitive(&item_name, &self.search_query).map(|match_result| {
FilteredMenuItem {
item: item.clone(),
match_result: Some(match_result),
}
})
})
.collect();
// Sort by match score (higher scores first)
filtered_items.sort_by(|a, b| {
let score_a = a.match_result.as_ref().map(|r| r.score).unwrap_or(0);
let score_b = b.match_result.as_ref().map(|r| r.score).unwrap_or(0);
score_b.cmp(&score_a)
});
// Offer a synthetic top-of-list "create from query" item when the
// current query has no exact match against an existing item. This is
// what powers the "Create new branch …" affordance in the branch
// switcher.
if let Some(builder) = self.create_item_from_query.as_ref() {
let trimmed = self.search_query.trim();
let already_matches_existing = query_matches_existing_name(
self.menu_items.iter().map(|item| item.name()),
trimmed,
);
if !already_matches_existing {
if let Some(synthetic) = builder(trimmed) {
filtered_items.insert(
0,
FilteredMenuItem {
item: synthetic,
match_result: None,
},
);
}
}
}
self.filtered_items = Rc::new(filtered_items);
}
pub fn update_search_query(&mut self, query: String, ctx: &mut ViewContext<Self>) {
@@ -467,6 +535,15 @@ impl DisplayChipMenu {
ctx.notify();
}
pub fn select_index(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
if index >= self.filtered_items.len() {
return;
}
self.is_footer_selected = false;
self.select(index, ctx);
self.list_state.scroll_to(self.selected_index);
}
fn is_footer_selected(&self) -> bool {
self.is_footer_selected
|| self
@@ -1429,3 +1506,7 @@ impl TypedActionView for DisplayChipMenu {
}
}
}
#[cfg(test)]
#[path = "display_menu_tests.rs"]
mod tests;
@@ -0,0 +1,31 @@
use super::query_matches_existing_name;
#[test]
fn query_matches_existing_name_is_ascii_case_insensitive() {
let names = ["main", "feature/Foo"];
assert!(query_matches_existing_name(names, "main"));
assert!(query_matches_existing_name(names, "Main"));
assert!(query_matches_existing_name(names, "MAIN"));
assert!(query_matches_existing_name(names, "feature/foo"));
assert!(query_matches_existing_name(names, "FEATURE/FOO"));
}
#[test]
fn query_matches_existing_name_returns_false_when_no_overlap() {
let names = ["main", "feature/foo"];
assert!(!query_matches_existing_name(names, "develop"));
assert!(!query_matches_existing_name(names, "feature/bar"));
}
#[test]
fn query_matches_existing_name_returns_false_for_empty_input() {
let names: [&str; 0] = [];
assert!(!query_matches_existing_name(names, "main"));
}
#[test]
fn query_matches_existing_name_works_with_owned_strings() {
let names = [String::from("main"), String::from("Develop")];
assert!(query_matches_existing_name(names.iter(), "Main"));
assert!(query_matches_existing_name(names.iter(), "develop"));
}
@@ -0,0 +1,210 @@
use std::collections::HashMap;
pub(crate) const WORKTREE_LIST_SEPARATOR: &str = "\u{1e}";
const ENCODED_VALUE_SEPARATOR: char = '\u{1f}';
const WORKTREE_TAG: &str = "worktree";
const GIT_BRANCH_REF_PREFIX: &str = "refs/heads/";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GitBranchOnClickValue {
pub(crate) branch_name: String,
pub(crate) worktree_path: Option<String>,
pub(crate) is_linked_worktree: bool,
}
impl GitBranchOnClickValue {
pub(crate) fn new(branch_name: String) -> Self {
Self {
branch_name,
worktree_path: None,
is_linked_worktree: false,
}
}
fn linked_worktree(branch_name: String, worktree_path: Option<String>) -> Self {
Self {
branch_name,
worktree_path,
is_linked_worktree: true,
}
}
pub(crate) fn encode(&self) -> String {
if self.is_linked_worktree {
match &self.worktree_path {
Some(path) => format!(
"{}{ENCODED_VALUE_SEPARATOR}{WORKTREE_TAG}{ENCODED_VALUE_SEPARATOR}{path}",
self.branch_name
),
None => format!(
"{}{ENCODED_VALUE_SEPARATOR}{WORKTREE_TAG}",
self.branch_name
),
}
} else {
self.branch_name.clone()
}
}
pub(crate) fn decode(value: &str) -> Self {
let mut parts = value.splitn(3, ENCODED_VALUE_SEPARATOR);
let branch_name = parts.next().unwrap_or_default().to_string();
match parts.next() {
Some(WORKTREE_TAG) => {
let worktree_path = parts
.next()
.filter(|path| !path.is_empty())
.map(str::to_string);
Self::linked_worktree(branch_name, worktree_path)
}
_ => Self::new(branch_name),
}
}
}
struct ParsedGitBranchLine {
branch_name: String,
is_current: bool,
is_linked_worktree: bool,
}
pub(crate) fn filter_git_branch_on_click_values(
values_opt: Option<Vec<String>>,
) -> Option<Vec<String>> {
values_opt.map(|values| {
let worktree_list_separator_index = values
.iter()
.position(|value| value.trim() == WORKTREE_LIST_SEPARATOR);
let (branch_lines, worktree_lines) = match worktree_list_separator_index {
Some(index) => (&values[..index], &values[index + 1..]),
None => (&values[..], &[][..]),
};
let branch_to_worktree_path = parse_git_worktree_paths(worktree_lines);
let branches: Vec<ParsedGitBranchLine> = branch_lines
.iter()
.filter_map(|line| parse_git_branch_line(line))
.collect();
// Keep the current branch first (denoted by *), preserving relative order
// for the remaining branches.
let (current_branches, other_branches): (Vec<_>, Vec<_>) =
branches.into_iter().partition(|branch| branch.is_current);
current_branches
.into_iter()
.chain(other_branches)
.map(|branch| {
if branch.is_linked_worktree {
GitBranchOnClickValue::linked_worktree(
branch.branch_name.clone(),
branch_to_worktree_path.get(&branch.branch_name).cloned(),
)
} else {
GitBranchOnClickValue::new(branch.branch_name)
}
.encode()
})
.collect()
})
}
fn parse_git_branch_line(line: &str) -> Option<ParsedGitBranchLine> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let status_marker = ['*', '+'].into_iter().find_map(|marker| {
trimmed.strip_prefix(marker).and_then(|rest| {
rest.chars()
.next()
.filter(|c| c.is_whitespace())
.map(|_| marker)
})
});
let branch_name = match status_marker {
Some(marker) => trimmed
.strip_prefix(marker)
.map(str::trim)
.unwrap_or(trimmed),
None => trimmed,
};
if branch_name.is_empty() {
return None;
}
Some(ParsedGitBranchLine {
branch_name: branch_name.to_string(),
is_current: status_marker == Some('*'),
is_linked_worktree: status_marker == Some('+'),
})
}
fn parse_git_worktree_paths(lines: &[String]) -> HashMap<String, String> {
let mut branch_to_worktree_path = HashMap::new();
let mut current_worktree_path: Option<String> = None;
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
current_worktree_path = None;
continue;
}
if let Some(path) = trimmed.strip_prefix("worktree ") {
current_worktree_path = Some(path.to_string());
continue;
}
let Some(branch_ref) = trimmed.strip_prefix("branch ") else {
continue;
};
let Some(branch_name) = branch_ref.strip_prefix(GIT_BRANCH_REF_PREFIX) else {
continue;
};
let Some(path) = current_worktree_path.as_ref() else {
continue;
};
branch_to_worktree_path.insert(branch_name.to_string(), path.clone());
}
branch_to_worktree_path
}
/// Returns `true` when `name` looks like a plausible git branch name that can
/// be created via `git checkout -b`.
///
/// We err on the side of letting git itself reject borderline cases: this
/// helper only filters out the most obviously broken inputs so that the
/// "Create new branch …" affordance does not appear for clearly invalid
/// queries (e.g. an empty string after the user backspaces, or whitespace).
/// Anything we accept here may still be rejected by `git check-ref-format`,
/// in which case the user sees the failure in the terminal.
pub(crate) fn is_plausible_new_branch_name(name: &str) -> bool {
let trimmed = name.trim();
if trimmed.is_empty() {
return false;
}
// git rejects names beginning with `-` outright, and they would also be
// ambiguous with `git checkout -b` flags, so don't offer the affordance.
if trimmed.starts_with('-') {
return false;
}
// git refuses whitespace (other than as a separator) inside refs.
if trimmed.chars().any(char::is_whitespace) {
return false;
}
true
}
#[cfg(test)]
#[path = "git_branch_on_click_tests.rs"]
mod tests;
@@ -0,0 +1,113 @@
use super::*;
#[test]
fn test_git_branch_on_click_value_round_trips_through_encode_decode() {
let values = [
GitBranchOnClickValue::new("feature-a".to_string()),
GitBranchOnClickValue::linked_worktree(
"feature-b".to_string(),
Some("/repo/feature-b".to_string()),
),
GitBranchOnClickValue::linked_worktree("feature-c".to_string(), None),
];
for value in values {
assert_eq!(GitBranchOnClickValue::decode(&value.encode()), value);
}
}
#[test]
fn test_git_branch_on_click_value_decode_uses_branch_name_for_unknown_payload() {
let value =
format!("feature-a{ENCODED_VALUE_SEPARATOR}unknown{ENCODED_VALUE_SEPARATOR}metadata");
assert_eq!(
GitBranchOnClickValue::decode(&value),
GitBranchOnClickValue::new("feature-a".to_string())
);
}
#[test]
fn test_git_branch_on_click_values_resolve_linked_worktree_paths() {
let values = Some(vec![
" feature-a".to_string(),
"+ linked-worktree".to_string(),
"* main".to_string(),
"".to_string(),
" +literal-plus".to_string(),
WORKTREE_LIST_SEPARATOR.to_string(),
"worktree /repo".to_string(),
"branch refs/heads/main".to_string(),
"".to_string(),
"worktree /repo-linked".to_string(),
"branch refs/heads/linked-worktree".to_string(),
]);
let values = filter_git_branch_on_click_values(values).unwrap();
let values: Vec<_> = values
.iter()
.map(|value| GitBranchOnClickValue::decode(value))
.collect();
assert_eq!(
values,
vec![
GitBranchOnClickValue::new("main".to_string()),
GitBranchOnClickValue::new("feature-a".to_string()),
GitBranchOnClickValue::linked_worktree(
"linked-worktree".to_string(),
Some("/repo-linked".to_string()),
),
GitBranchOnClickValue::new("+literal-plus".to_string()),
]
);
}
#[test]
fn test_git_branch_on_click_values_keep_linked_marker_without_path() {
let values = filter_git_branch_on_click_values(Some(vec!["+ feature".to_string()]))
.expect("expected parsed branch values");
let value = GitBranchOnClickValue::decode(&values[0]);
assert_eq!(value.branch_name, "feature");
assert_eq!(value.worktree_path, None);
assert!(value.is_linked_worktree);
}
#[test]
fn test_is_plausible_new_branch_name_accepts_typical_names() {
for name in [
"feature/xyz",
"fix-123",
"release/v1.2.3",
"user/alice/work",
"main",
] {
assert!(
is_plausible_new_branch_name(name),
"expected {name:?} to be accepted",
);
}
}
#[test]
fn test_is_plausible_new_branch_name_rejects_empty_or_whitespace() {
for name in ["", " ", "\t\n"] {
assert!(
!is_plausible_new_branch_name(name),
"expected {name:?} to be rejected",
);
}
}
#[test]
fn test_is_plausible_new_branch_name_rejects_leading_dash() {
assert!(!is_plausible_new_branch_name("-foo"));
assert!(!is_plausible_new_branch_name("--all"));
}
#[test]
fn test_is_plausible_new_branch_name_rejects_internal_whitespace() {
assert!(!is_plausible_new_branch_name("my branch"));
assert!(!is_plausible_new_branch_name("foo\tbar"));
}
+3 -5
View File
@@ -1,4 +1,6 @@
use std::sync::mpsc;
#[cfg(test)]
use std::sync::Arc;
#[cfg(not(test))]
use std::sync::OnceLock;
#[cfg(not(target_family = "wasm"))]
@@ -13,12 +15,8 @@ use galaxy_completer::completer::{CommandExitStatus, CommandOutput};
#[cfg(test)]
use parking_lot::Mutex;
#[cfg(test)]
use std::sync::Arc;
use crate::terminal::shell::ShellType;
use super::ContextChipKind;
use crate::terminal::shell::ShellType;
const EMPTY_VALUE: &str = "<empty>";
const MISSING_VALUE: &str = "<none>";
+2 -1
View File
@@ -1,6 +1,7 @@
use super::*;
use galaxy_completer::completer::{CommandExitStatus, CommandOutput};
use super::*;
#[test]
fn test_prompt_chip_log_filename_uses_channel_logfile_stem() {
assert_eq!(
+63 -45
View File
@@ -6,6 +6,7 @@ pub mod directory_fetcher;
pub mod display;
pub mod display_chip;
pub mod display_menu;
pub(crate) mod git_branch_on_click;
pub(crate) mod logging;
pub mod node_version_popup;
pub mod prompt;
@@ -18,25 +19,23 @@ use std::collections::HashMap;
use std::time::Duration;
use context_chip::PromptGenerator;
use galaxyui::{
color::ColorU,
elements::Text,
fonts::{Properties, Weight},
};
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use crate::ui_components::{blended_colors, icons::Icon};
use crate::{appearance::Appearance, features::FeatureFlag, themes::theme::PromptColors};
use galaxyui::color::ColorU;
use galaxyui::elements::Text;
use galaxyui::fonts::{Properties, Weight};
#[allow(unused_imports)]
pub use self::context_chip::{
ChipAvailability, ChipDisabledReason, ChipRuntimeCapabilities, ExternalCommandsAvailability,
};
use self::{
context_chip::{ChipFingerprintInput, ChipRuntimePolicy, ContextChip, RefreshConfig},
renderer::RendererStyles,
};
use self::context_chip::{ChipFingerprintInput, ChipRuntimePolicy, ContextChip, RefreshConfig};
use self::renderer::RendererStyles;
use crate::appearance::Appearance;
use crate::features::FeatureFlag;
use crate::themes::theme::PromptColors;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
/// The value of a context chip. Most chips produce plain text, but some
/// (like `GitDiffStats`) carry structured data to avoid string round-trips.
@@ -45,6 +44,7 @@ use self::{
pub enum ChipValue {
Text(String),
GitDiffStats(display_chip::GitLineChanges),
GitBranchStatus(display_chip::GitBranchTrackingStatus),
}
impl ChipValue {
@@ -52,7 +52,7 @@ impl ChipValue {
pub fn as_text(&self) -> Option<&str> {
match self {
ChipValue::Text(s) => Some(s),
ChipValue::GitDiffStats(_) => None,
ChipValue::GitDiffStats(_) | ChipValue::GitBranchStatus(_) => None,
}
}
@@ -60,7 +60,14 @@ impl ChipValue {
pub fn as_git_diff_stats(&self) -> Option<&display_chip::GitLineChanges> {
match self {
ChipValue::GitDiffStats(g) => Some(g),
ChipValue::Text(_) => None,
ChipValue::Text(_) | ChipValue::GitBranchStatus(_) => None,
}
}
pub fn as_git_branch_tracking_status(&self) -> Option<&display_chip::GitBranchTrackingStatus> {
match self {
ChipValue::GitBranchStatus(status) => Some(status),
ChipValue::Text(_) | ChipValue::GitDiffStats(_) => None,
}
}
}
@@ -82,6 +89,7 @@ impl std::fmt::Display for ChipValue {
g.files_changed, g.lines_added, g.lines_removed
)
}
ChipValue::GitBranchStatus(status) => f.write_str(&status.display_text()),
}
}
}
@@ -92,10 +100,17 @@ impl From<String> for ChipValue {
}
}
pub(crate) fn github_pr_number_from_url(url: &str) -> Option<&str> {
pub(crate) fn github_pr_number_from_url(url: &str) -> Option<i32> {
let (_, tail) = url.trim().rsplit_once("/pull/")?;
let number = tail.split(['/', '?', '#']).next()?;
(!number.is_empty() && number.chars().all(|c| c.is_ascii_digit())).then_some(number)
parse_github_pr_number(number)
}
fn parse_github_pr_number(number: &str) -> Option<i32> {
if !number.chars().all(|c| c.is_ascii_digit()) {
return None;
}
number.parse::<i32>().ok().filter(|number| *number > 0)
}
pub(crate) fn github_pr_display_text_from_url(url: &str) -> Option<String> {
@@ -171,6 +186,7 @@ pub enum ContextChipKind {
title: String,
},
ShellGitBranch,
GitBranchStatus,
GitDiffStats,
GithubPullRequest,
KubernetesContext,
@@ -291,6 +307,12 @@ impl ContextChipKind {
Some(builtins::shell_other_git_branches()),
GIT_REFRESH_CONFIG,
)),
Self::GitBranchStatus => Some(ContextChip::shell_builtin(
"Git Branch Status",
builtins::shell_git_branch_status(),
None,
GIT_REFRESH_CONFIG,
)),
Self::GitDiffStats => Some(
ContextChip::shell_builtin(
"Git Diff Stats",
@@ -301,30 +323,11 @@ impl ContextChipKind {
.with_allow_empty_value(),
),
Self::GithubPullRequest if !FeatureFlag::GithubPrPromptChip.is_enabled() => None,
Self::GithubPullRequest => {
let generator = builtins::github_pull_request_url();
let policy = ChipRuntimePolicy::new(
generator.dependencies().to_vec(),
true,
Some(Duration::from_secs(5)),
[
ChipFingerprintInput::SessionId,
ChipFingerprintInput::WorkingDirectory,
ChipFingerprintInput::GitBranch,
ChipFingerprintInput::RequiredExecutablesPresence,
ChipFingerprintInput::InvalidatingCommandCount,
],
)
.with_suppress_on_failure()
.with_invalidate_on_commands(["git", "gh", "gt"]);
Some(ContextChip::shell_builtin_with_runtime_policy(
"GitHub Pull Request",
generator,
None,
GIT_REFRESH_CONFIG,
policy,
))
}
Self::GithubPullRequest => Some(ContextChip::builtin(
"GitHub Pull Request",
|_| None,
RefreshConfig::OnDemandOnly,
)),
Self::KubernetesContext => Some(ContextChip::shell_builtin(
"Kubernetes Context",
builtins::kubernetes_current_context(),
@@ -338,7 +341,7 @@ impl ContextChipKind {
RefreshConfig::OnDemandOnly,
)),
Self::SvnDirtyItems => Some(ContextChip::shell_builtin(
"Svn Uncommited File Count",
"Svn Uncommitted File Count",
builtins::svn_dirty_items(),
None,
RefreshConfig::OnDemandOnly,
@@ -391,6 +394,14 @@ impl ContextChipKind {
Self::Username => ChipValue::Text("alice".to_string()),
Self::Hostname => ChipValue::Text("ubuntu-04".to_string()),
Self::ShellGitBranch => ChipValue::Text("git-feature-branch".to_string()),
Self::GitBranchStatus => {
ChipValue::GitBranchStatus(display_chip::GitBranchTrackingStatus::new(
"main".to_string(),
Some("origin/main".to_string()),
1,
2,
))
}
Self::GitDiffStats => ChipValue::Text("3 • +10 -2".to_string()),
Self::GithubPullRequest => ChipValue::Text("PR #123".to_string()),
Self::VirtualEnvironment => ChipValue::Text("pyenv".to_string()),
@@ -424,6 +435,7 @@ impl ContextChipKind {
Self::Username => prompt_colors.input_prompt_user_and_host,
Self::Hostname => prompt_colors.input_prompt_user_and_host,
Self::ShellGitBranch => prompt_colors.input_prompt_branch,
Self::GitBranchStatus => prompt_colors.input_prompt_branch,
Self::GitDiffStats => prompt_colors.input_prompt_branch,
Self::GithubPullRequest => prompt_colors.input_prompt_branch,
Self::VirtualEnvironment => prompt_colors.input_prompt_virtual_env,
@@ -465,7 +477,7 @@ impl ContextChipKind {
pub fn display_value(&self, value: &ChipValue) -> String {
let text = value.to_string();
match self {
Self::ShellGitBranch => format!("git:({text})"),
Self::ShellGitBranch | Self::GitBranchStatus => format!("git:({text})"),
Self::GithubPullRequest => github_pr_display_text_from_url(&text).unwrap_or(text),
Self::KubernetesContext => format!("{text}"),
Self::SvnBranch => format!("svn:({text})"),
@@ -526,7 +538,7 @@ impl ContextChipKind {
Some(Icon::Terminal)
}
Self::NodeVersion => Some(Icon::NodeJS),
Self::ShellGitBranch | Self::SvnBranch => Some(Icon::GitBranch),
Self::ShellGitBranch | Self::GitBranchStatus | Self::SvnBranch => Some(Icon::GitBranch),
Self::GitDiffStats | Self::SvnDirtyItems => Some(Icon::File),
Self::GithubPullRequest => Some(Icon::Github),
Self::KubernetesContext => Some(Icon::Globe),
@@ -551,6 +563,7 @@ pub fn available_chips() -> Vec<ContextChipKind> {
ContextChipKind::Hostname,
ContextChipKind::Ssh,
ContextChipKind::ShellGitBranch,
ContextChipKind::GitBranchStatus,
ContextChipKind::GitDiffStats,
];
if FeatureFlag::GithubPrPromptChip.is_enabled() {
@@ -588,6 +601,11 @@ pub fn git_line_changes_from_chips(chips: &[ChipResult]) -> Option<display_chip:
lines_added: 0,
lines_removed: 0,
}),
ChipValue::GitBranchStatus(_) => display_chip::GitLineChanges {
files_changed: 0,
lines_added: 0,
lines_removed: 0,
},
})
} else {
None
@@ -645,7 +663,7 @@ pub fn render_text_from_kind(
// Keep in sync with `ContextChipKind::display_value`
match kind {
ContextChipKind::ShellGitBranch => {
ContextChipKind::ShellGitBranch | ContextChipKind::GitBranchStatus => {
text.add_text_with_highlights(
"git:(",
if is_in_agent_view {
@@ -699,7 +717,7 @@ pub fn render_text_from_kind(
text.add_text_with_highlights(value, styles.value_color, styles.font_properties);
match kind {
ContextChipKind::ShellGitBranch => {
ContextChipKind::ShellGitBranch | ContextChipKind::GitBranchStatus => {
text.add_text_with_highlights(
")",
if is_in_agent_view {
+7 -13
View File
@@ -1,25 +1,21 @@
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
ChildView, ClippedScrollStateHandle, ClippedScrollable, Dismiss, ParentElement, ScrollbarWidth,
ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Dismiss, DropShadow, Flex, MainAxisAlignment, MainAxisSize,
ParentElement, Radius, ScrollbarWidth, Text,
};
use galaxyui::fonts::FamilyId;
use galaxyui::fonts::{FamilyId, Properties};
use galaxyui::keymap::FixedBinding;
use galaxyui::{
elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Flex,
MainAxisAlignment, MainAxisSize, Radius, Text,
},
fonts::Properties,
keymap::FixedBinding,
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use pathfinder_color::ColorU;
use crate::menu::{self, Event as MenuEvent, Menu, MenuItemFields};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::ui_components::blended_colors;
use crate::ui_components::icons;
use crate::ui_components::{blended_colors, icons};
use crate::view_components::action_button::{ActionButton, SecondaryTheme};
const MENU_WIDTH: f32 = 300.0;
@@ -518,8 +514,6 @@ fn detect_nvm_installed() -> bool {
// Enumerate installed Node versions managed by nvm (best-effort, cross-OS)
fn list_nvm_versions() -> Vec<String> {
use std::env;
use std::path::Path;
let mut out: Vec<String> = Vec::new();
+14 -11
View File
@@ -1,16 +1,16 @@
use crate::{
settings::{
AISettings, AISettingsChangedEvent, InputSettings, InputSettingsChangedEvent,
WarpPromptSeparator,
},
terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent},
};
pub use super::ContextChipKind;
use galaxyui::{Entity, GetSingletonModelHandle, ModelContext, SingletonEntity, UpdateModel};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use settings::Setting as _;
use galaxyui::{
Entity, GetSingletonModelHandle, ModelContext, ModelHandle, SingletonEntity, UpdateModel,
};
pub use super::ContextChipKind;
use crate::settings::{
AISettings, AISettingsChangedEvent, InputSettings, InputSettingsChangedEvent,
WarpPromptSeparator,
};
use crate::terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent};
#[cfg(test)]
#[path = "prompt_tests.rs"]
@@ -246,7 +246,7 @@ impl Prompt {
}
}
/// Wehther same line prompt is enabled for Warp prompt.
/// Whether same line prompt is enabled for Warp prompt.
pub fn same_line_prompt_enabled(&self) -> bool {
self.config.same_line_prompt_enabled
}
@@ -264,6 +264,7 @@ impl Prompt {
/// Updates the in-memory prompt configuration to reflect a settings change.
fn handle_session_settings_change(
&mut self,
_: ModelHandle<SessionSettings>,
event: &SessionSettingsChangedEvent,
ctx: &mut ModelContext<Self>,
) {
@@ -280,6 +281,7 @@ impl Prompt {
fn handle_input_settings_change(
&mut self,
_: ModelHandle<InputSettings>,
event: &InputSettingsChangedEvent,
ctx: &mut ModelContext<Self>,
) {
@@ -292,6 +294,7 @@ impl Prompt {
/// Updates the in-memory prompt configuration to reflect an AI settings change.
fn handle_ai_settings_change(
&mut self,
_: ModelHandle<AISettings>,
event: &AISettingsChangedEvent,
ctx: &mut ModelContext<Self>,
) {
+1 -2
View File
@@ -2,11 +2,10 @@ use galaxyui::{AppContext, SingletonEntity};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use crate::context_chips::ContextChipKind;
use super::current_prompt::CurrentPrompt;
use super::prompt::Prompt;
use super::{chips_to_string, ChipResult, ChipValue};
use crate::context_chips::ContextChipKind;
use crate::settings::WarpPromptSeparator;
/// Struct that holds a point in time snapshot of a prompt (chips are no longer interactive)
+3 -7
View File
@@ -3,16 +3,12 @@ use serde_json::Value;
use super::Prompt;
use crate::auth::AuthStateProvider;
use crate::context_chips::prompt::{PromptConfiguration, PromptSelection};
use crate::context_chips::ContextChipKind;
use crate::settings::WarpPromptSeparator;
use crate::terminal::session_settings::SessionSettings;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
context_chips::{
prompt::{PromptConfiguration, PromptSelection},
ContextChipKind,
},
terminal::session_settings::SessionSettings,
};
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
+8 -14
View File
@@ -1,19 +1,13 @@
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use crate::{
menu::{MenuItem, MenuItemFields},
settings::WarpPromptSeparator,
terminal::{
model::session::Sessions,
session_settings::{SessionSettings, ToolbarChipSelection},
view::{ContextMenuAction, PromptPart, PromptPosition, TerminalAction},
},
};
use super::{
current_prompt::CurrentPrompt, prompt_snapshot::PromptSnapshot, ChipResult, ChipValue,
ContextChipKind,
};
use super::current_prompt::CurrentPrompt;
use super::prompt_snapshot::PromptSnapshot;
use super::{ChipResult, ChipValue, ContextChipKind};
use crate::menu::{MenuItem, MenuItemFields};
use crate::settings::WarpPromptSeparator;
use crate::terminal::model::session::Sessions;
use crate::terminal::session_settings::{SessionSettings, ToolbarChipSelection};
use crate::terminal::view::{ContextMenuAction, PromptPart, PromptPosition, TerminalAction};
/// The type of warp prompt being used
#[derive(Clone)]
+9 -15
View File
@@ -1,28 +1,22 @@
//! The renderer for a single context chip.
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
ConstrainedBox, DraggableState, Hoverable, MouseStateHandle, OffsetPositioning, ParentElement,
ParentOffsetBounds, Stack,
ConstrainedBox, Container, CrossAxisAlignment, DraggableState, Flex, Hoverable,
MouseStateHandle, OffsetPositioning, ParentElement, ParentOffsetBounds, Stack, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::Action;
use galaxyui::{
elements::{Container, CrossAxisAlignment, Flex, Text},
Element,
};
use pathfinder_color::ColorU;
use crate::appearance::Appearance;
use crate::ui_components::icons;
use galaxyui::{Action, Element};
use super::context_chip::ContextChip;
use super::display_chip::{chip_container, udi_font_size};
use super::spacing;
use super::{ChipAvailability, ChipValue, ContextChipKind};
use pathfinder_geometry::vector::vec2f;
use super::{spacing, ChipAvailability, ChipValue, ContextChipKind};
use crate::appearance::Appearance;
use crate::ui_components::icons;
/// Styling consts.
const CORNER_RADIUS_PIXELS: f32 = 4.;
@@ -249,5 +243,5 @@ impl Renderer {
}
#[cfg(test)]
#[path = "renderer_test.rs"]
#[path = "renderer_tests.rs"]
mod tests;
@@ -1,9 +1,8 @@
use galaxyui::fonts::Properties;
use pathfinder_color::ColorU;
use crate::context_chips::{ChipAvailability, ChipDisabledReason, ContextChipKind};
use super::{Renderer, RendererStyles};
use crate::context_chips::{ChipAvailability, ChipDisabledReason, ContextChipKind};
#[test]
fn test_constructor_availability_updates_disabled_state_and_tooltip_override() {
@@ -1,17 +0,0 @@
git rev-parse --is-inside-work-tree >/dev/null 2>/dev/null; or exit 0
git symbolic-ref --quiet --short HEAD >/dev/null 2>/dev/null; or exit 0
set remote_url (git remote get-url origin 2>/dev/null); or exit 0
string match -rq '^(git@github\.com:|https?://github\.com/|ssh://git@github\.com/)' -- $remote_url; or exit 0
set output (gh pr view --json url --jq .url 2>&1)
set exit_code $status
if test $exit_code -eq 0
printf '%s\n' "$output"
else
set joined_output (string join '\n' $output)
string match -rq 'no (open )?pull requests found for branch ' -- $joined_output; and exit 0
printf '%s\n' "$joined_output" >&2
exit $exit_code
end
@@ -1,22 +0,0 @@
git rev-parse --is-inside-work-tree 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { exit 0 }
$branch = git symbolic-ref --quiet --short HEAD 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($branch)) { exit 0 }
$remoteUrl = git remote get-url origin 2>$null
if ($LASTEXITCODE -ne 0) { exit 0 }
if ($remoteUrl -notmatch '^(git@github\.com:|https?://github\.com/|ssh://git@github\.com/)') { exit 0 }
$output = gh pr view --json url --jq .url 2>&1 | Out-String
$exitCode = $LASTEXITCODE
$output = $output.TrimEnd()
if ($exitCode -eq 0) {
if (-not [string]::IsNullOrWhiteSpace($output)) { $output }
exit 0
}
if ($output -match 'no (open )?pull requests found for branch ') { exit 0 }
if (-not [string]::IsNullOrWhiteSpace($output)) { [Console]::Error.WriteLine($output) }
exit $exitCode
@@ -1,26 +0,0 @@
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
git symbolic-ref --quiet --short HEAD >/dev/null 2>&1 || exit 0
remote_url=$(git remote get-url origin 2>/dev/null) || exit 0
case "$remote_url" in
git@github.com:*|https://github.com/*|http://github.com/*|ssh://git@github.com/*)
;;
*)
exit 0
;;
esac
output=$(gh pr view --json url --jq .url 2>&1)
exit_code=$?
if [ $exit_code -eq 0 ]; then
printf '%s\n' "$output"
else
case "$output" in
*'no pull requests found for branch '*|*'no open pull requests found for branch '*)
exit 0
;;
esac
printf '%s\n' "$output" >&2
exit $exit_code
fi