first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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=$?;"));
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+280
-710
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+219
-1
@@ -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(-)";
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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>";
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>,
|
||||
) {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user