Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
//! Context chips built into Warp
|
||||
|
||||
use chrono::Local;
|
||||
use warp_util::path::user_friendly_path;
|
||||
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
use super::{
|
||||
context_chip::{GeneratorContext, ShellCommand, ShellCommandGenerator},
|
||||
ChipValue,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "builtins_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Generator function for the current working directory.
|
||||
pub fn working_directory(ctx: &GeneratorContext) -> Option<ChipValue> {
|
||||
let pwd = ctx.active_block_metadata.current_working_directory()?;
|
||||
let home_dir = ctx.active_session.and_then(|session| session.home_dir());
|
||||
Some(ChipValue::Text(
|
||||
user_friendly_path(pwd, home_dir).to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Generator function that always shows the username.
|
||||
pub fn username(ctx: &GeneratorContext) -> Option<ChipValue> {
|
||||
ctx.active_session
|
||||
.map(|session| ChipValue::Text(session.user().to_owned()))
|
||||
}
|
||||
|
||||
/// Generator function that always shows the host name.
|
||||
pub fn hostname(ctx: &GeneratorContext) -> Option<ChipValue> {
|
||||
ctx.active_session
|
||||
.map(|session| ChipValue::Text(session.hostname().to_owned()))
|
||||
}
|
||||
|
||||
/// Generator function that shows the current Python virtual environment.
|
||||
pub fn virtual_environment(ctx: &GeneratorContext) -> Option<ChipValue> {
|
||||
ctx.current_environment
|
||||
.python_virtualenv()
|
||||
.cloned()
|
||||
.map(ChipValue::Text)
|
||||
}
|
||||
|
||||
/// Generator function that shows the current Anaconda/conda environment.
|
||||
pub fn conda_environment(ctx: &GeneratorContext) -> Option<ChipValue> {
|
||||
ctx.current_environment
|
||||
.conda_environment()
|
||||
.cloned()
|
||||
.map(ChipValue::Text)
|
||||
}
|
||||
|
||||
/// Generator function that shows the current Node.js version.
|
||||
pub fn node_version(ctx: &GeneratorContext) -> Option<ChipValue> {
|
||||
ctx.current_environment
|
||||
.node_version()
|
||||
.cloned()
|
||||
.map(ChipValue::Text)
|
||||
}
|
||||
|
||||
/// Generator function that shows the current date.
|
||||
pub fn date(_: &GeneratorContext) -> Option<ChipValue> {
|
||||
Some(ChipValue::Text(
|
||||
Local::now().format("%a %b %d %Y").to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Generator function that shows the current time in 12-hour format.
|
||||
pub fn time12(_: &GeneratorContext) -> Option<ChipValue> {
|
||||
Some(ChipValue::Text(Local::now().format("%I:%M %P").to_string()))
|
||||
}
|
||||
|
||||
/// Generator function that shows the current time in 24-hour format.
|
||||
pub fn time24(_: &GeneratorContext) -> Option<ChipValue> {
|
||||
Some(ChipValue::Text(Local::now().format("%H:%M").to_string()))
|
||||
}
|
||||
|
||||
/// Generator function that shows the current 12-hour time with seconds.
|
||||
pub fn time12_with_seconds(_: &GeneratorContext) -> Option<ChipValue> {
|
||||
Some(ChipValue::Text(
|
||||
Local::now().format("%I:%M:%S %P").to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Generator function that shows the current 24-hour time with seconds.
|
||||
pub fn time24_with_seconds(_: &GeneratorContext) -> Option<ChipValue> {
|
||||
Some(ChipValue::Text(Local::now().format("%H:%M:%S").to_string()))
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|| matches!(
|
||||
session.session_type(),
|
||||
crate::terminal::model::session::SessionType::WarpifiedRemote { .. }
|
||||
)
|
||||
{
|
||||
let user = session.user();
|
||||
Some(ChipValue::Text(format!("{}@{}", user, session.hostname())))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Generator function for Subshell session chip.
|
||||
pub fn subshell(ctx: &GeneratorContext) -> Option<ChipValue> {
|
||||
let session = ctx.active_session?;
|
||||
let subshell_info = session.subshell_info().as_ref()?;
|
||||
|
||||
let session_type = if let Some(env_var_collection_name) = &subshell_info.env_var_collection_name
|
||||
{
|
||||
env_var_collection_name.clone()
|
||||
} else {
|
||||
subshell_info
|
||||
.spawning_command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("subshell")
|
||||
.to_string()
|
||||
};
|
||||
Some(ChipValue::Text(session_type))
|
||||
}
|
||||
|
||||
/// Generator function that shows the current Git branch.
|
||||
pub fn shell_git_branch() -> ShellCommandGenerator {
|
||||
// Note this command must stay in sync with how PrecmdValue::git_branch is generated in the
|
||||
// bootstrap scripts, at least until that is removed.
|
||||
const SH_COMMAND: &str = "GIT_OPTIONAL_LOCKS=0 git symbolic-ref --short HEAD 2> /dev/null || \
|
||||
GIT_OPTIONAL_LOCKS=0 git rev-parse --short HEAD 2> /dev/null";
|
||||
let pwsh_command = safe_git_powershell(
|
||||
"git symbolic-ref --short HEAD 2>$null; \
|
||||
if ($? -eq $false) { \
|
||||
git rev-parse --short HEAD 2>$null; \
|
||||
}",
|
||||
);
|
||||
|
||||
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()),
|
||||
]);
|
||||
|
||||
ShellCommandGenerator::new(command, Some(vec!["git".to_owned()]))
|
||||
}
|
||||
|
||||
pub fn shell_other_git_branches() -> ShellCommandGenerator {
|
||||
const SH_COMMAND: &str = "git --no-optional-locks branch --no-color --sort=-committerdate";
|
||||
|
||||
let command = ShellCommand::shell_specific([
|
||||
(ShellType::PowerShell, SH_COMMAND.to_string()),
|
||||
(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()]))
|
||||
}
|
||||
|
||||
/// Generator function to get summary of git diff (num files changed and num lines changed).
|
||||
///
|
||||
/// Used as a remote-session fallback when GitRepoStatusModel is unavailable.
|
||||
pub fn shell_git_line_changes() -> ShellCommandGenerator {
|
||||
const GIT_COMMAND: &str =
|
||||
"GIT_OPTIONAL_LOCKS=0 git -c diff.autoRefreshIndex=false diff --shortstat HEAD";
|
||||
|
||||
let command = ShellCommand::shell_specific([
|
||||
(ShellType::Bash, GIT_COMMAND.to_string()),
|
||||
(ShellType::Zsh, GIT_COMMAND.to_string()),
|
||||
(ShellType::Fish, GIT_COMMAND.to_string()),
|
||||
(
|
||||
ShellType::PowerShell,
|
||||
safe_git_powershell("git -c diff.autoRefreshIndex=false diff --shortstat HEAD"),
|
||||
),
|
||||
]);
|
||||
|
||||
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"),
|
||||
Some(vec!["kubectl".to_owned()]),
|
||||
)
|
||||
}
|
||||
|
||||
/// Generator function that shows the current svn "branch".
|
||||
/// Since svn uses directories for different branches and tags,
|
||||
/// we take the latest directory of the working copy as the branch/tag name.
|
||||
pub fn svn_branch_context() -> ShellCommandGenerator {
|
||||
const SH_COMMAND: &str = "basename $(svn info --show-item wc-root)";
|
||||
const PWSH_COMMAND: &str = "svn info --show-item wc-root | Split-Path -Leaf";
|
||||
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, SH_COMMAND.to_string()),
|
||||
]);
|
||||
|
||||
ShellCommandGenerator::new(command, Some(vec!["svn".to_owned()]))
|
||||
}
|
||||
|
||||
/// Generator function that shows the number of uncommitted svn files/directories.
|
||||
pub fn svn_dirty_items() -> ShellCommandGenerator {
|
||||
const SH_COMMAND: &str = "count=$(svn status | wc -l) \
|
||||
&& (( $count > 0 )) && echo $(( $count ))";
|
||||
const FISH_COMMAND: &str = "set count (svn status | wc -l) \
|
||||
&& test $count -gt 0 && string trim $count";
|
||||
const PWSH_COMMAND: &str = "svn status | Measure-Object -line | \
|
||||
where {$_.Lines -gt 0 } | foreach { $_.Lines }";
|
||||
let command = ShellCommand::shell_specific([
|
||||
(ShellType::Bash, SH_COMMAND.to_string()),
|
||||
(ShellType::Zsh, SH_COMMAND.to_string()),
|
||||
(ShellType::Fish, FISH_COMMAND.to_string()),
|
||||
(ShellType::PowerShell, PWSH_COMMAND.to_string()),
|
||||
]);
|
||||
ShellCommandGenerator::new(command, Some(vec!["svn".to_owned()]))
|
||||
}
|
||||
|
||||
fn safe_git_powershell(cmd: &str) -> String {
|
||||
format!(
|
||||
"\
|
||||
$gitOptionalLocks = $env:GIT_OPTIONAL_LOCKS; \
|
||||
$env:GIT_OPTIONAL_LOCKS = 0; \
|
||||
try {{ \
|
||||
{cmd} \
|
||||
}} finally {{ \
|
||||
$success = $?; \
|
||||
$exitCode = $LASTEXITCODE; \
|
||||
$env:GIT_OPTIONAL_LOCKS = $gitOptionalLocks; \
|
||||
if ($exitCode -ne 0 -or -not $success) {{ \
|
||||
throw \
|
||||
}} \
|
||||
}}"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_working_directory() {
|
||||
let session = Session::test();
|
||||
// SessionInfo forces the home directory in tests.
|
||||
let home_dir = session.home_dir().expect("Home dir is set in tests");
|
||||
|
||||
let block_in_cwd = BlockMetadata::new(Some(session.id()), Some(format!("{home_dir}/projects")));
|
||||
|
||||
assert_eq!(
|
||||
super::working_directory(&GeneratorContext {
|
||||
active_block_metadata: &block_in_cwd,
|
||||
active_session: Some(&session),
|
||||
current_environment: &Default::default(),
|
||||
})
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_text()),
|
||||
Some("~/projects")
|
||||
);
|
||||
|
||||
let block_outside_cwd = BlockMetadata::new(Some(session.id()), Some("/etc".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
super::working_directory(&GeneratorContext {
|
||||
active_block_metadata: &block_outside_cwd,
|
||||
active_session: Some(&session),
|
||||
current_environment: &Default::default(),
|
||||
})
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_text()),
|
||||
Some("/etc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remote_sessions() {
|
||||
let local_session = Session::test();
|
||||
let remote_session = Session::new(
|
||||
SessionInfo::new_for_test()
|
||||
.with_session_type(BootstrapSessionType::WarpifiedRemote)
|
||||
.with_hostname("remote-host".to_string())
|
||||
.with_user("remote-user".to_string()),
|
||||
Arc::new(TestCommandExecutor {}),
|
||||
);
|
||||
|
||||
let local_ctx = GeneratorContext {
|
||||
active_block_metadata: &BlockMetadata::new(Some(local_session.id()), None),
|
||||
active_session: Some(&local_session),
|
||||
current_environment: &Default::default(),
|
||||
};
|
||||
|
||||
let remote_ctx = GeneratorContext {
|
||||
active_block_metadata: &BlockMetadata::new(Some(remote_session.id()), None),
|
||||
active_session: Some(&remote_session),
|
||||
current_environment: &Default::default(),
|
||||
};
|
||||
|
||||
// The Username and Hostname chips are always present.
|
||||
assert_eq!(
|
||||
super::username(&local_ctx)
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_text()),
|
||||
Some("local:user")
|
||||
);
|
||||
assert_eq!(
|
||||
super::username(&remote_ctx)
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_text()),
|
||||
Some("remote-user")
|
||||
);
|
||||
assert_eq!(
|
||||
super::hostname(&local_ctx)
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_text()),
|
||||
Some("local:host")
|
||||
);
|
||||
assert_eq!(
|
||||
super::hostname(&remote_ctx)
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_text()),
|
||||
Some("remote-host")
|
||||
);
|
||||
|
||||
// The SSH chip is only shown for remote sessions.
|
||||
assert_eq!(super::ssh_session(&local_ctx), None);
|
||||
assert_eq!(
|
||||
super::ssh_session(&remote_ctx)
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_text()),
|
||||
Some("remote-user@remote-host")
|
||||
);
|
||||
}
|
||||
|
||||
#[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();
|
||||
let block_metadata = BlockMetadata::new(Some(session.id()), None);
|
||||
|
||||
// Test with no node version
|
||||
let environment_no_node = Environment::default();
|
||||
let ctx_no_node = GeneratorContext {
|
||||
active_block_metadata: &block_metadata,
|
||||
active_session: Some(&session),
|
||||
current_environment: &environment_no_node,
|
||||
};
|
||||
assert_eq!(super::node_version(&ctx_no_node), None);
|
||||
|
||||
// Test with node version - create environment with node version
|
||||
let environment_with_node = Environment::new(
|
||||
None, // virtual_env
|
||||
None, // conda_env
|
||||
Some("v18.0.0".to_string()), // node_version
|
||||
);
|
||||
let ctx_with_node = GeneratorContext {
|
||||
active_block_metadata: &block_metadata,
|
||||
active_session: Some(&session),
|
||||
current_environment: &environment_with_node,
|
||||
};
|
||||
assert_eq!(
|
||||
super::node_version(&ctx_with_node)
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_text()),
|
||||
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=$?;"));
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
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::shell::ShellType;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ShellCommandGenerator {
|
||||
command: ShellCommand,
|
||||
dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
/// Representation of a shell command. The command may or may not be supported on all shells.
|
||||
///
|
||||
/// In YAML (as a hypothetical example), this should work with a variable format like:
|
||||
/// ```yaml
|
||||
/// # This parses to ShellCommand::Portable
|
||||
/// - "this is a portable command"
|
||||
/// # This parses to ShellCommand::ShellSpecific
|
||||
/// - bash: "this works on bash"
|
||||
/// zsh: "this works on zsh"
|
||||
/// # this command does not support Fish
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum ShellCommand {
|
||||
/// A shell command that works on all shells.
|
||||
Portable(String),
|
||||
/// A shell command that only works on specific shells.
|
||||
ShellSpecific(HashMap<ShellType, String>),
|
||||
}
|
||||
|
||||
impl ShellCommandGenerator {
|
||||
pub fn command(&self) -> &ShellCommand {
|
||||
&self.command
|
||||
}
|
||||
|
||||
pub fn dependencies(&self) -> &[String] {
|
||||
&self.dependencies
|
||||
}
|
||||
|
||||
pub fn new(command: ShellCommand, dependencies: Option<Vec<String>>) -> Self {
|
||||
Self {
|
||||
command,
|
||||
dependencies: dependencies.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellCommand {
|
||||
/// Construct a new portable shell command.
|
||||
pub fn portable(command: impl Into<String>) -> Self {
|
||||
Self::Portable(command.into())
|
||||
}
|
||||
|
||||
/// Construct a set of shell-specific commands.
|
||||
pub fn shell_specific(commands: impl Into<HashMap<ShellType, String>>) -> Self {
|
||||
Self::ShellSpecific(commands.into())
|
||||
}
|
||||
|
||||
/// Gets the variant of this command that works on the given shell. If this command does not
|
||||
/// support the shell, returns `None`.
|
||||
pub fn for_shell(&self, shell_type: ShellType) -> Option<&str> {
|
||||
match self {
|
||||
Self::Portable(command) => Some(command.as_str()),
|
||||
Self::ShellSpecific(commands) => commands.get(&shell_type).map(String::as_str),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks whether the set of external commands (executables on `$PATH`) has been loaded for a
|
||||
/// session, and if so, which required commands are present.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ExternalCommandsAvailability {
|
||||
#[default]
|
||||
Unknown,
|
||||
Known {
|
||||
command_count: usize,
|
||||
required_command_presence: HashMap<String, bool>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExternalCommandsAvailability {
|
||||
pub fn contains(&self, command: &str) -> Option<bool> {
|
||||
match self {
|
||||
Self::Unknown => None,
|
||||
Self::Known {
|
||||
required_command_presence,
|
||||
..
|
||||
} => required_command_presence.get(command).copied(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command_count(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Unknown => None,
|
||||
Self::Known { command_count, .. } => Some(*command_count),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of session-level capabilities that a chip's runtime policy uses to determine
|
||||
/// availability (e.g. whether the session is local, which executables are present).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct ChipRuntimeCapabilities {
|
||||
pub session_id: Option<SessionId>,
|
||||
pub session_is_local: Option<bool>,
|
||||
pub external_commands: ExternalCommandsAvailability,
|
||||
}
|
||||
|
||||
impl ChipRuntimeCapabilities {
|
||||
pub fn from_session(session: &Session) -> Self {
|
||||
Self::from_session_with_external_command_queries(session, std::iter::empty::<&str>(), false)
|
||||
}
|
||||
|
||||
pub fn from_session_with_external_command_queries<'a>(
|
||||
session: &Session,
|
||||
required_executables: impl IntoIterator<Item = &'a str>,
|
||||
include_external_command_count: bool,
|
||||
) -> Self {
|
||||
let external_commands = if session.has_loaded_external_commands() {
|
||||
let mut required_command_presence = HashMap::new();
|
||||
let required_executables = required_executables.into_iter().collect::<HashSet<_>>();
|
||||
|
||||
let should_scan_executables =
|
||||
include_external_command_count || !required_executables.is_empty();
|
||||
let mut command_count = 0;
|
||||
if should_scan_executables {
|
||||
for executable in session.executable_names() {
|
||||
command_count += 1;
|
||||
if required_executables.contains(executable) {
|
||||
required_command_presence.insert(executable.to_string(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for required_executable in required_executables {
|
||||
required_command_presence
|
||||
.entry(required_executable.to_string())
|
||||
.or_insert(false);
|
||||
}
|
||||
|
||||
ExternalCommandsAvailability::Known {
|
||||
command_count,
|
||||
required_command_presence,
|
||||
}
|
||||
} else {
|
||||
ExternalCommandsAvailability::Unknown
|
||||
};
|
||||
|
||||
Self {
|
||||
session_id: Some(session.id()),
|
||||
session_is_local: Some(session.is_local()),
|
||||
external_commands,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ChipDisabledReason {
|
||||
RequiresLocalSession,
|
||||
RequiresExecutable { command: String },
|
||||
}
|
||||
|
||||
impl ChipDisabledReason {
|
||||
pub fn tooltip_text(&self) -> String {
|
||||
match self {
|
||||
Self::RequiresLocalSession => "Requires a local session".to_string(),
|
||||
Self::RequiresExecutable { command } if command == "gh" => {
|
||||
"Requires the GitHub CLI".to_string()
|
||||
}
|
||||
Self::RequiresExecutable { command } => format!("Requires the `{command}` command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub enum ChipAvailability {
|
||||
#[default]
|
||||
Enabled,
|
||||
Disabled(ChipDisabledReason),
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl ChipAvailability {
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
matches!(self, Self::Enabled)
|
||||
}
|
||||
|
||||
pub fn tooltip_override_text(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Disabled(reason) => Some(reason.tooltip_text()),
|
||||
Self::Enabled | Self::Hidden => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An input that contributes to a chip's fingerprint hash. When all fingerprint inputs match
|
||||
/// the previously computed fingerprint, the chip can skip re-fetching its value.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ChipFingerprintInput {
|
||||
SessionId,
|
||||
SessionIsLocal,
|
||||
WorkingDirectory,
|
||||
GitBranch,
|
||||
PythonVirtualenv,
|
||||
CondaEnvironment,
|
||||
NodeVersion,
|
||||
SessionUser,
|
||||
SessionHostname,
|
||||
ExternalCommandsState,
|
||||
RequiredExecutablesPresence,
|
||||
/// A per-chip monotonic counter that increments each time a user command matching
|
||||
/// the chip's `invalidate_on_commands` list completes, causing the fingerprint to change.
|
||||
InvalidatingCommandCount,
|
||||
}
|
||||
|
||||
/// Configuration that governs how a chip interacts with the runtime environment: which
|
||||
/// executables it requires, whether it's restricted to local sessions, its shell command
|
||||
/// timeout, and which inputs form its cache fingerprint.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ChipRuntimePolicy {
|
||||
required_executables: Vec<String>,
|
||||
local_only: bool,
|
||||
shell_command_timeout: Option<Duration>,
|
||||
fingerprint_inputs: Vec<ChipFingerprintInput>,
|
||||
/// When true, if the chip's shell command fails (or times out), the chip records the current
|
||||
/// fingerprint and skips re-execution on future fetches — including periodic refreshes —
|
||||
/// until the fingerprint changes (e.g. branch or directory change).
|
||||
suppress_on_failure: bool,
|
||||
/// Top-level command names (e.g. `["git", "gh", "gt"]`) whose execution should
|
||||
/// invalidate this chip's fingerprint. Pair with `ChipFingerprintInput::InvalidatingCommandCount`.
|
||||
invalidate_on_commands: Vec<String>,
|
||||
}
|
||||
|
||||
impl ChipRuntimePolicy {
|
||||
pub fn new(
|
||||
required_executables: impl IntoIterator<Item = impl Into<String>>,
|
||||
local_only: bool,
|
||||
shell_command_timeout: Option<Duration>,
|
||||
fingerprint_inputs: impl IntoIterator<Item = ChipFingerprintInput>,
|
||||
) -> Self {
|
||||
Self {
|
||||
required_executables: required_executables.into_iter().map(Into::into).collect(),
|
||||
local_only,
|
||||
shell_command_timeout,
|
||||
fingerprint_inputs: fingerprint_inputs.into_iter().collect(),
|
||||
suppress_on_failure: false,
|
||||
invalidate_on_commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_shell_generator(generator: &ShellCommandGenerator) -> Self {
|
||||
Self::new(
|
||||
generator.dependencies().to_vec(),
|
||||
false,
|
||||
None,
|
||||
std::iter::empty(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn required_executables(&self) -> &[String] {
|
||||
&self.required_executables
|
||||
}
|
||||
|
||||
pub fn shell_command_timeout(&self) -> Option<Duration> {
|
||||
self.shell_command_timeout
|
||||
}
|
||||
|
||||
pub fn fingerprint_inputs(&self) -> &[ChipFingerprintInput] {
|
||||
&self.fingerprint_inputs
|
||||
}
|
||||
|
||||
pub fn suppress_on_failure(&self) -> bool {
|
||||
self.suppress_on_failure
|
||||
}
|
||||
|
||||
pub fn with_suppress_on_failure(mut self) -> Self {
|
||||
self.suppress_on_failure = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn invalidate_on_commands(&self) -> &[String] {
|
||||
&self.invalidate_on_commands
|
||||
}
|
||||
|
||||
pub fn with_invalidate_on_commands(
|
||||
mut self,
|
||||
commands: impl IntoIterator<Item = impl Into<String>>,
|
||||
) -> Self {
|
||||
self.invalidate_on_commands = commands.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn availability(&self, capabilities: &ChipRuntimeCapabilities) -> ChipAvailability {
|
||||
if self.local_only && matches!(capabilities.session_is_local, Some(false)) {
|
||||
return ChipAvailability::Disabled(ChipDisabledReason::RequiresLocalSession);
|
||||
}
|
||||
|
||||
for command in &self.required_executables {
|
||||
if matches!(
|
||||
capabilities.external_commands.contains(command),
|
||||
Some(false)
|
||||
) {
|
||||
return ChipAvailability::Disabled(ChipDisabledReason::RequiresExecutable {
|
||||
command: command.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ChipAvailability::Enabled
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for built-in contextual [`PromptGenerator`]s.
|
||||
pub struct GeneratorContext<'a> {
|
||||
/// The latest block in the session. While the prompt is shown, this block should have precmd
|
||||
/// metadata but will not have executed yet.
|
||||
pub active_block_metadata: &'a BlockMetadata,
|
||||
/// The session that the active block is part of. This should always be available once the
|
||||
/// session is bootstrapped. However, it may be missing due to errors restoring previous
|
||||
/// sessions or extracting session info from the shell.
|
||||
pub active_session: Option<&'a Session>,
|
||||
/// The most-recently-available environment data for the terminal session. Unlike the username,
|
||||
/// hostname, and other session-level info, this can change over the lifetime of a session -
|
||||
/// users can activate/deactivate virtualenvs, change branches, and so on.
|
||||
pub current_environment: &'a Environment,
|
||||
}
|
||||
|
||||
/// Environment information for a terminal session.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Environment {
|
||||
/// The Git branch that's checked out.
|
||||
git_branch: Option<String>,
|
||||
/// The name of the active Python virtual environment.
|
||||
python_virtualenv: Option<String>,
|
||||
/// The Anaconda environment name.
|
||||
conda_environment: Option<String>,
|
||||
/// The Node.js version.
|
||||
node_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PromptGenerator {
|
||||
ShellCommand(ShellCommandGenerator),
|
||||
Contextual {
|
||||
/// A function that extracts the chip value from the prompt-generation context.
|
||||
from_context_fn: fn(&GeneratorContext) -> Option<ChipValue>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum RefreshConfig {
|
||||
#[default]
|
||||
OnDemandOnly,
|
||||
#[allow(dead_code)]
|
||||
Periodically { interval: Duration },
|
||||
#[allow(dead_code)]
|
||||
OnFileChanges { filepath: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ContextChip {
|
||||
title: String,
|
||||
generator: PromptGenerator,
|
||||
on_click_generator: Option<PromptGenerator>,
|
||||
/// TODO: this likely needs to move to a config state.
|
||||
icon_path: Option<&'static str>,
|
||||
refresh_config: RefreshConfig,
|
||||
runtime_policy: ChipRuntimePolicy,
|
||||
/// When `true`, a shell command that succeeds with empty output produces `Some("")` instead of `None`.
|
||||
/// Useful for chips like `GitDiffStats` where empty output can still mean
|
||||
/// a valid and clean working tree rather than "not applicable".
|
||||
allow_empty_value: bool,
|
||||
}
|
||||
|
||||
impl ContextChip {
|
||||
/// Create a new built-in context chip using the given generator function.
|
||||
pub fn builtin(
|
||||
title: impl Into<String>,
|
||||
generator: fn(&GeneratorContext) -> Option<ChipValue>,
|
||||
refresh_config: RefreshConfig,
|
||||
) -> Self {
|
||||
Self::builtin_with_runtime_policy(
|
||||
title,
|
||||
generator,
|
||||
refresh_config,
|
||||
ChipRuntimePolicy::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn builtin_with_runtime_policy(
|
||||
title: impl Into<String>,
|
||||
generator: fn(&GeneratorContext) -> Option<ChipValue>,
|
||||
refresh_config: RefreshConfig,
|
||||
runtime_policy: ChipRuntimePolicy,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
generator: PromptGenerator::Contextual {
|
||||
from_context_fn: generator,
|
||||
},
|
||||
on_click_generator: None,
|
||||
icon_path: None,
|
||||
refresh_config,
|
||||
runtime_policy,
|
||||
allow_empty_value: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shell_builtin(
|
||||
title: impl Into<String>,
|
||||
generator: ShellCommandGenerator,
|
||||
on_click_generator: Option<ShellCommandGenerator>,
|
||||
refresh_config: RefreshConfig,
|
||||
) -> Self {
|
||||
let runtime_policy = ChipRuntimePolicy::for_shell_generator(&generator);
|
||||
Self::shell_builtin_with_runtime_policy(
|
||||
title,
|
||||
generator,
|
||||
on_click_generator,
|
||||
refresh_config,
|
||||
runtime_policy,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn shell_builtin_with_runtime_policy(
|
||||
title: impl Into<String>,
|
||||
generator: ShellCommandGenerator,
|
||||
on_click_generator: Option<ShellCommandGenerator>,
|
||||
refresh_config: RefreshConfig,
|
||||
runtime_policy: ChipRuntimePolicy,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
generator: PromptGenerator::ShellCommand(generator),
|
||||
on_click_generator: on_click_generator.map(PromptGenerator::ShellCommand),
|
||||
icon_path: None,
|
||||
refresh_config,
|
||||
runtime_policy,
|
||||
allow_empty_value: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_custom_chip(title: String, shell_command_generator: ShellCommandGenerator) -> Self {
|
||||
let runtime_policy = ChipRuntimePolicy::for_shell_generator(&shell_command_generator);
|
||||
Self {
|
||||
title,
|
||||
generator: PromptGenerator::ShellCommand(shell_command_generator),
|
||||
on_click_generator: None,
|
||||
icon_path: None,
|
||||
refresh_config: Default::default(),
|
||||
runtime_policy,
|
||||
allow_empty_value: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
self.title.as_str()
|
||||
}
|
||||
|
||||
pub fn generator(&self) -> &PromptGenerator {
|
||||
&self.generator
|
||||
}
|
||||
|
||||
pub fn on_click_generator(&self) -> Option<&PromptGenerator> {
|
||||
self.on_click_generator.as_ref()
|
||||
}
|
||||
|
||||
pub fn refresh_config(&self) -> &RefreshConfig {
|
||||
&self.refresh_config
|
||||
}
|
||||
|
||||
pub fn runtime_policy(&self) -> &ChipRuntimePolicy {
|
||||
&self.runtime_policy
|
||||
}
|
||||
|
||||
pub fn availability(&self, capabilities: &ChipRuntimeCapabilities) -> ChipAvailability {
|
||||
self.runtime_policy.availability(capabilities)
|
||||
}
|
||||
|
||||
pub fn icon_path(&self) -> Option<&'static str> {
|
||||
self.icon_path
|
||||
}
|
||||
|
||||
pub fn allow_empty_value(&self) -> bool {
|
||||
self.allow_empty_value
|
||||
}
|
||||
|
||||
pub fn with_allow_empty_value(mut self) -> Self {
|
||||
self.allow_empty_value = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
/// Create a new environment with the given values.
|
||||
pub fn new(
|
||||
python_virtualenv: Option<String>,
|
||||
conda_environment: Option<String>,
|
||||
node_version: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
git_branch: None,
|
||||
python_virtualenv,
|
||||
conda_environment,
|
||||
node_version,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the environment from a block.
|
||||
pub fn from_block(block: &Block) -> Self {
|
||||
Self {
|
||||
git_branch: block.git_branch().cloned(),
|
||||
python_virtualenv: block.virtual_env_short_name(),
|
||||
conda_environment: block.conda_env().cloned(),
|
||||
node_version: block.node_version().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn git_branch(&self) -> Option<&String> {
|
||||
self.git_branch.as_ref()
|
||||
}
|
||||
|
||||
pub fn python_virtualenv(&self) -> Option<&String> {
|
||||
self.python_virtualenv.as_ref()
|
||||
}
|
||||
|
||||
pub fn conda_environment(&self) -> Option<&String> {
|
||||
self.conda_environment.as_ref()
|
||||
}
|
||||
|
||||
pub fn node_version(&self) -> Option<&String> {
|
||||
self.node_version.as_ref()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::completer::SessionContext;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use typed_path::TypedPathBuf;
|
||||
use warp_completer::completer::{EngineDirEntry, EngineFileType, PathCompletionContext};
|
||||
use warp_util::file_type::is_binary_file;
|
||||
use warpui::{r#async::SpawnedFutureHandle, AppContext, Entity, ModelContext};
|
||||
|
||||
use super::display_menu::GenericMenuItem;
|
||||
|
||||
/// DirectoryFetcher model that caches directory state and provides an explicit refetch API
|
||||
pub struct DirectoryFetcher {
|
||||
current_directory: String,
|
||||
/// Cached directory contents as menu items
|
||||
cached_files: Vec<DirectoryItem>,
|
||||
/// Session context for async operations (required for directory fetching)
|
||||
session_context: Option<SessionContext>,
|
||||
/// Handle to the fetch operation
|
||||
fetch_handle: Option<SpawnedFutureHandle>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DirectoryFetcherEvent {
|
||||
/// Emitted when directory contents have been updated
|
||||
DirectoryContentsUpdated,
|
||||
/// Emitted when a fetch operation starts
|
||||
FetchStarted,
|
||||
/// Emitted when a fetch operation completes (successfully or with error)
|
||||
FetchCompleted { success: bool },
|
||||
}
|
||||
|
||||
impl DirectoryFetcher {
|
||||
/// Create a new DirectoryFetcher for the given directory
|
||||
pub fn new(
|
||||
directory_path: String,
|
||||
session_context: Option<SessionContext>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let mut fetcher = Self {
|
||||
current_directory: directory_path.clone(),
|
||||
cached_files: vec![],
|
||||
session_context,
|
||||
fetch_handle: None,
|
||||
};
|
||||
|
||||
fetcher.refetch_directory(ctx);
|
||||
fetcher
|
||||
}
|
||||
|
||||
/// Explicitly refetch the directory contents
|
||||
pub fn refetch_directory(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.is_fetching() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always use async method - SessionContext works for both local and remote sessions
|
||||
if let Some(session_ctx) = self.session_context.clone() {
|
||||
let dir_path = self.current_directory.clone();
|
||||
|
||||
self.fetch_handle = Some(ctx.spawn(
|
||||
async move { Self::fetch_files_async(&session_ctx, &dir_path).await },
|
||||
|fetcher, files, ctx| {
|
||||
fetcher.cached_files = files;
|
||||
fetcher.fetch_handle = None;
|
||||
ctx.emit(DirectoryFetcherEvent::DirectoryContentsUpdated);
|
||||
ctx.emit(DirectoryFetcherEvent::FetchCompleted { success: true });
|
||||
ctx.notify();
|
||||
},
|
||||
));
|
||||
ctx.emit(DirectoryFetcherEvent::FetchStarted);
|
||||
} else {
|
||||
// If no session context, we can't fetch directory contents
|
||||
log::warn!("No SessionContext available for directory fetching");
|
||||
ctx.emit(DirectoryFetcherEvent::FetchCompleted { success: false });
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Asynchronously list directory files using SessionContext
|
||||
async fn fetch_files_async(
|
||||
session_context: &SessionContext,
|
||||
dir_path: &str,
|
||||
) -> Vec<DirectoryItem> {
|
||||
// Convert the directory path to TypedPathBuf, expanding ~ if needed
|
||||
let expanded_path = shellexpand::tilde(dir_path).into_owned();
|
||||
let typed_path = if expanded_path != dir_path {
|
||||
TypedPathBuf::from(expanded_path)
|
||||
} else {
|
||||
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;
|
||||
|
||||
// Convert EngineDirEntry to GenericMenuItem, filtering out hidden files
|
||||
let mut items: Vec<DirectoryItem> = entries
|
||||
.iter()
|
||||
.filter(|entry| !entry.is_hidden()) // Skip hidden files (starting with '.')
|
||||
.map(engine_entry_to_menu_item)
|
||||
.collect();
|
||||
|
||||
// Sort: directories first, then text files, then other files, all alphabetically within their groups
|
||||
sort_menu_items(&mut items);
|
||||
items
|
||||
}
|
||||
|
||||
/// Update the session context (useful when it becomes available later)
|
||||
pub fn update_session_context(
|
||||
&mut self,
|
||||
session_context: Option<SessionContext>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(handle) = self.fetch_handle.take() {
|
||||
// Cancel the fetch operation if it's in progress
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
self.session_context = session_context;
|
||||
self.refetch_directory(ctx);
|
||||
}
|
||||
|
||||
/// Get the current directory path
|
||||
pub fn current_directory(&self) -> &str {
|
||||
&self.current_directory
|
||||
}
|
||||
|
||||
/// Get the cached directory files
|
||||
pub fn cached_files(&self) -> &[DirectoryItem] {
|
||||
&self.cached_files
|
||||
}
|
||||
|
||||
/// Check if a fetch operation is in progress
|
||||
pub fn is_fetching(&self) -> bool {
|
||||
self.fetch_handle.is_some()
|
||||
}
|
||||
|
||||
/// Change the current directory and refetch contents
|
||||
pub fn change_directory(&mut self, new_directory: String, ctx: &mut ModelContext<Self>) {
|
||||
if self.current_directory != new_directory {
|
||||
self.current_directory = new_directory;
|
||||
self.cached_files.clear();
|
||||
self.refetch_directory(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DirectoryFetcher {
|
||||
type Event = DirectoryFetcherEvent;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialOrd, PartialEq)]
|
||||
pub enum DirectoryType {
|
||||
Directory,
|
||||
TextFile,
|
||||
OtherFile,
|
||||
NavigateToParent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialOrd, PartialEq)]
|
||||
pub struct DirectoryItem {
|
||||
pub name: String,
|
||||
pub directory_type: DirectoryType,
|
||||
}
|
||||
|
||||
impl GenericMenuItem for DirectoryItem {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn icon(&self, _app: &AppContext) -> Option<Icon> {
|
||||
Some(match self.directory_type {
|
||||
DirectoryType::Directory => Icon::Folder,
|
||||
DirectoryType::TextFile => Icon::File,
|
||||
DirectoryType::OtherFile => Icon::File,
|
||||
DirectoryType::NavigateToParent => Icon::ArrowUp,
|
||||
})
|
||||
}
|
||||
|
||||
fn action_data(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort menu items: directories first, then text files, then other files, all alphabetically within their groups
|
||||
fn sort_menu_items(items: &mut [DirectoryItem]) {
|
||||
items.sort_by(|a, b| {
|
||||
match (&a.directory_type, &b.directory_type) {
|
||||
(DirectoryType::Directory, DirectoryType::TextFile)
|
||||
| (DirectoryType::Directory, DirectoryType::OtherFile) => Ordering::Less,
|
||||
(DirectoryType::TextFile, DirectoryType::Directory)
|
||||
| (DirectoryType::OtherFile, DirectoryType::Directory) => Ordering::Greater,
|
||||
(DirectoryType::TextFile, DirectoryType::OtherFile) => Ordering::Less,
|
||||
(DirectoryType::OtherFile, DirectoryType::TextFile) => Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name), // Same type, sort alphabetically
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Convert an EngineDirEntry to a DirectoryItem
|
||||
fn engine_entry_to_menu_item(entry: &EngineDirEntry) -> DirectoryItem {
|
||||
let name: String = entry.file_name().to_string();
|
||||
DirectoryItem {
|
||||
name: name.clone(),
|
||||
directory_type: match entry.file_type {
|
||||
EngineFileType::Directory => DirectoryType::Directory,
|
||||
EngineFileType::File => {
|
||||
if is_binary_file(&name) {
|
||||
DirectoryType::OtherFile
|
||||
} else {
|
||||
DirectoryType::TextFile
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "directory_fetcher_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,102 @@
|
||||
use super::*;
|
||||
|
||||
fn create_directory_item(name: &str, directory_type: DirectoryType) -> DirectoryItem {
|
||||
DirectoryItem {
|
||||
name: name.to_string(),
|
||||
directory_type,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_comparison_total_order() {
|
||||
// Test that sort_menu_items produces consistent ordering that prevents panics
|
||||
// by verifying the expected Directory < TextFile < OtherFile hierarchy
|
||||
|
||||
// Test basic type ordering with different combinations
|
||||
let mut items1 = vec![
|
||||
create_directory_item("folder", DirectoryType::Directory),
|
||||
create_directory_item("text.txt", DirectoryType::TextFile),
|
||||
];
|
||||
sort_menu_items(&mut items1);
|
||||
assert_eq!(items1[0].directory_type, DirectoryType::Directory);
|
||||
assert_eq!(items1[1].directory_type, DirectoryType::TextFile);
|
||||
|
||||
let mut items2 = vec![
|
||||
create_directory_item("text.txt", DirectoryType::TextFile),
|
||||
create_directory_item("binary.exe", DirectoryType::OtherFile),
|
||||
];
|
||||
sort_menu_items(&mut items2);
|
||||
assert_eq!(items2[0].directory_type, DirectoryType::TextFile);
|
||||
assert_eq!(items2[1].directory_type, DirectoryType::OtherFile);
|
||||
|
||||
let mut items3 = vec![
|
||||
create_directory_item("folder", DirectoryType::Directory),
|
||||
create_directory_item("binary.exe", DirectoryType::OtherFile),
|
||||
];
|
||||
sort_menu_items(&mut items3);
|
||||
assert_eq!(items3[0].directory_type, DirectoryType::Directory);
|
||||
assert_eq!(items3[1].directory_type, DirectoryType::OtherFile);
|
||||
|
||||
// Test that sort_menu_items is consistent - calling it multiple times
|
||||
// on the same data should produce the same result
|
||||
let test_items = vec![
|
||||
create_directory_item("binary.exe", DirectoryType::OtherFile),
|
||||
create_directory_item("folder", DirectoryType::Directory),
|
||||
create_directory_item("text.txt", DirectoryType::TextFile),
|
||||
];
|
||||
|
||||
let mut items_copy1 = test_items.clone();
|
||||
let mut items_copy2 = test_items.clone();
|
||||
|
||||
sort_menu_items(&mut items_copy1);
|
||||
sort_menu_items(&mut items_copy2);
|
||||
|
||||
// Both sorts should produce identical results
|
||||
assert_eq!(items_copy1, items_copy2);
|
||||
|
||||
// Verify the expected ordering: Directory, TextFile, OtherFile
|
||||
assert_eq!(items_copy1[0].directory_type, DirectoryType::Directory);
|
||||
assert_eq!(items_copy1[1].directory_type, DirectoryType::TextFile);
|
||||
assert_eq!(items_copy1[2].directory_type, DirectoryType::OtherFile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_same_types_alphabetically() {
|
||||
let mut dirs = vec![
|
||||
create_directory_item("zebra", DirectoryType::Directory),
|
||||
create_directory_item("alpha", DirectoryType::Directory),
|
||||
create_directory_item("beta", DirectoryType::Directory),
|
||||
];
|
||||
sort_menu_items(&mut dirs);
|
||||
assert_eq!(dirs[0].name, "alpha");
|
||||
assert_eq!(dirs[1].name, "beta");
|
||||
assert_eq!(dirs[2].name, "zebra");
|
||||
|
||||
let mut texts = vec![
|
||||
create_directory_item("z.txt", DirectoryType::TextFile),
|
||||
create_directory_item("a.rs", DirectoryType::TextFile),
|
||||
create_directory_item("m.py", DirectoryType::TextFile),
|
||||
];
|
||||
sort_menu_items(&mut texts);
|
||||
assert_eq!(texts[0].name, "a.rs");
|
||||
assert_eq!(texts[1].name, "m.py");
|
||||
assert_eq!(texts[2].name, "z.txt");
|
||||
|
||||
let mut others = vec![
|
||||
create_directory_item("z.bin", DirectoryType::OtherFile),
|
||||
create_directory_item("a.exe", DirectoryType::OtherFile),
|
||||
create_directory_item("m.dll", DirectoryType::OtherFile),
|
||||
];
|
||||
sort_menu_items(&mut others);
|
||||
assert_eq!(others[0].name, "a.exe");
|
||||
assert_eq!(others[1].name, "m.dll");
|
||||
assert_eq!(others[2].name, "z.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_single_item() {
|
||||
let mut items = vec![create_directory_item("single", DirectoryType::Directory)];
|
||||
sort_menu_items(&mut items);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].name, "single");
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
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 std::path::PathBuf;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ChildView, Clipped, Container, CrossAxisAlignment, Element, Flex, MainAxisAlignment,
|
||||
MainAxisSize, ParentElement, Wrap,
|
||||
},
|
||||
AppContext, Entity, EntityId, FocusContext, ModelHandle, SingletonEntity, TypedActionView,
|
||||
View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use super::{
|
||||
display_chip::{DisplayChip, DisplayChipConfig, PromptDisplayChipEvent},
|
||||
git_line_changes_from_chips,
|
||||
prompt_type::PromptType,
|
||||
ChipResult, ContextChipKind,
|
||||
};
|
||||
|
||||
/// Enum introduced to abstract over the different row types we use for the prompt display,
|
||||
/// between the non-UDI and UDI cases.
|
||||
enum RowBuilder {
|
||||
Wrap(Wrap),
|
||||
Flex(Flex),
|
||||
}
|
||||
|
||||
impl RowBuilder {
|
||||
fn add_child(&mut self, child: Box<dyn Element>) {
|
||||
match self {
|
||||
RowBuilder::Wrap(w) => w.add_child(child),
|
||||
RowBuilder::Flex(f) => f.add_child(child),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(self) -> Box<dyn Element> {
|
||||
match self {
|
||||
RowBuilder::Wrap(w) => w.finish(),
|
||||
RowBuilder::Flex(f) => f.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A view for displaying the prompt.
|
||||
pub struct PromptDisplay {
|
||||
prompt: ModelHandle<PromptType>,
|
||||
display_chips: Vec<ViewHandle<DisplayChip>>,
|
||||
ai_input_model: ModelHandle<BlocklistAIInputModel>,
|
||||
ai_context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
terminal_view_id: EntityId,
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
session_context: Option<SessionContext>,
|
||||
current_repo_path: Option<PathBuf>,
|
||||
model_events: ModelHandle<ModelEventDispatcher>,
|
||||
|
||||
/// Whether the pane this prompt belongs to is currently focused.
|
||||
pane_is_focused: bool,
|
||||
|
||||
/// Whether this terminal is viewing a shared session.
|
||||
is_shared_session_viewer: bool,
|
||||
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
}
|
||||
|
||||
const PROMPT_CHIP_DISPLAY_ID: &str = "PromptChipDisplay";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PromptDisplayAction {
|
||||
SelectGitBranch { value: String },
|
||||
}
|
||||
|
||||
pub enum PromptDisplayEvent {
|
||||
OpenFile(String),
|
||||
OpenTextFileInCodeEditor(String),
|
||||
ToggleMenu {
|
||||
open: bool,
|
||||
},
|
||||
OpenCodeReview,
|
||||
OpenConversationHistory,
|
||||
OpenCommandPaletteFiles,
|
||||
RunAgentQuery(String),
|
||||
TryExecuteCommand(String),
|
||||
OpenAIDocument {
|
||||
document_id: AIDocumentId,
|
||||
document_version: AIDocumentVersion,
|
||||
},
|
||||
}
|
||||
|
||||
impl PromptDisplay {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
prompt: ModelHandle<PromptType>,
|
||||
ai_input_model: ModelHandle<BlocklistAIInputModel>,
|
||||
ai_context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
terminal_view_id: EntityId,
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
session_context: Option<SessionContext>,
|
||||
current_repo_path: Option<PathBuf>,
|
||||
model_events: ModelHandle<ModelEventDispatcher>,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
is_shared_session_viewer: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
ctx.observe(&prompt, |me, _, ctx| me.handle_prompt_change(ctx));
|
||||
|
||||
// Subscribe to AI input model changes to trigger re-render when input mode changes
|
||||
ctx.subscribe_to_model(&ai_input_model, |_me, _model, event, ctx| {
|
||||
match event {
|
||||
BlocklistAIInputEvent::InputTypeChanged { .. }
|
||||
| BlocklistAIInputEvent::LockChanged { .. } => {
|
||||
// Trigger re-render to update chip visibility based on new input mode
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe todo list updates to refresh the todo list chip visibility
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
Self {
|
||||
prompt,
|
||||
display_chips: vec![],
|
||||
ai_input_model,
|
||||
ai_context_model,
|
||||
terminal_view_id,
|
||||
menu_positioning_provider,
|
||||
session_context,
|
||||
current_repo_path,
|
||||
model_events,
|
||||
agent_view_controller,
|
||||
pane_is_focused: true,
|
||||
is_shared_session_viewer,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_open_chip_menu(&self, app: &AppContext) -> bool {
|
||||
self.display_chips
|
||||
.iter()
|
||||
.any(|chip| chip.as_ref(app).display_chip_kind().has_open_menu())
|
||||
}
|
||||
|
||||
fn check_if_chip_values_have_changed(
|
||||
&mut self,
|
||||
new_chips: &[ChipResult],
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
self.display_chips.len() != new_chips.len()
|
||||
|| 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.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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_prompt_change(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let new_chips = self.collect_chips(ctx);
|
||||
|
||||
let should_update = self.check_if_chip_values_have_changed(&new_chips, ctx);
|
||||
|
||||
if should_update {
|
||||
self.reset_chips(&new_chips, ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Collects the current chips from the prompt model, filtering out chips with no value.
|
||||
fn collect_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
|
||||
self.prompt
|
||||
.as_ref(ctx)
|
||||
.chips(ctx)
|
||||
.into_iter()
|
||||
.filter(|chip| chip.value.is_some())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reset_chips(&mut self, new_chips: &[ChipResult], ctx: &mut ViewContext<Self>) {
|
||||
let git_line_changes_info = git_line_changes_from_chips(new_chips);
|
||||
|
||||
self.display_chips.clear();
|
||||
let mut display_chips = new_chips.iter().peekable();
|
||||
while let Some(chip_result) = display_chips.next() {
|
||||
let next_chip_kind = display_chips
|
||||
.peek()
|
||||
.map(|chip_result| chip_result.kind.clone());
|
||||
|
||||
let is_shared_session_viewer = self.is_shared_session_viewer;
|
||||
|
||||
let view_handle = ctx.add_typed_action_view(|ctx| {
|
||||
let mut chip = DisplayChip::new(
|
||||
ctx,
|
||||
chip_result.clone(),
|
||||
next_chip_kind,
|
||||
DisplayChipConfig {
|
||||
ai_input_model: self.ai_input_model.clone(),
|
||||
ai_context_model: self.ai_context_model.clone(),
|
||||
terminal_view_id: self.terminal_view_id,
|
||||
menu_positioning_provider: self.menu_positioning_provider.clone(),
|
||||
session_context: self.session_context.clone(),
|
||||
current_repo_path: self.current_repo_path.clone(),
|
||||
model_events: self.model_events.clone(),
|
||||
is_shared_session_viewer,
|
||||
agent_view_controller: self.agent_view_controller.clone(),
|
||||
ambient_agent_view_model: None,
|
||||
},
|
||||
);
|
||||
chip.maybe_set_git_line_changes_info(git_line_changes_info.clone());
|
||||
chip
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&view_handle, move |_, _, event, ctx| match event {
|
||||
PromptDisplayChipEvent::OpenFile(value) => {
|
||||
ctx.emit(PromptDisplayEvent::OpenFile(value.clone()));
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::OpenTextFileInCodeEditor(value) => {
|
||||
ctx.emit(PromptDisplayEvent::OpenTextFileInCodeEditor(value.clone()));
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::ToggleMenu { open } => {
|
||||
ctx.emit(PromptDisplayEvent::ToggleMenu { open: *open });
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::OpenCodeReview => {
|
||||
ctx.emit(PromptDisplayEvent::OpenCodeReview);
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::OpenConversationHistory => {
|
||||
ctx.emit(PromptDisplayEvent::OpenConversationHistory);
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::OpenCommandPaletteFiles => {
|
||||
ctx.emit(PromptDisplayEvent::OpenCommandPaletteFiles);
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::RunAgentQuery(query) => {
|
||||
ctx.emit(PromptDisplayEvent::RunAgentQuery(query.clone()));
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::TryExecuteCommand(cmd) => {
|
||||
ctx.emit(PromptDisplayEvent::TryExecuteCommand(cmd.clone()));
|
||||
ctx.notify();
|
||||
}
|
||||
PromptDisplayChipEvent::OpenAIDocument {
|
||||
document_id,
|
||||
document_version,
|
||||
} => {
|
||||
ctx.emit(PromptDisplayEvent::OpenAIDocument {
|
||||
document_id: *document_id,
|
||||
document_version: *document_version,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
self.display_chips.push(view_handle.clone());
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Update the session context and propagate it to all display chips
|
||||
pub fn update_session_context(
|
||||
&mut self,
|
||||
session_context: Option<SessionContext>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.session_context = session_context.clone();
|
||||
|
||||
// Update all existing display chips with the new session context
|
||||
for chip_view in &self.display_chips {
|
||||
chip_view.update(ctx, |chip, chip_ctx| {
|
||||
chip.update_session_context(session_context.clone(), chip_ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Update whether this terminal is viewing a shared session
|
||||
pub fn update_shared_session_viewer_status(
|
||||
&mut self,
|
||||
is_viewer: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if self.is_shared_session_viewer != is_viewer {
|
||||
self.is_shared_session_viewer = is_viewer;
|
||||
|
||||
// Re-render chips to show/hide the shared session viewer-specific chips
|
||||
let new_chips = self.collect_chips(ctx);
|
||||
self.reset_chips(&new_chips, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// The current prompt text.
|
||||
pub fn text(&self, ctx: &AppContext) -> String {
|
||||
self.prompt.as_ref(ctx).prompt_as_string(ctx)
|
||||
}
|
||||
|
||||
#[cfg(feature = "integration_tests")]
|
||||
pub fn git_branch(&self, ctx: &AppContext) -> Option<String> {
|
||||
self.prompt.read(ctx, |prompt, ctx| {
|
||||
prompt
|
||||
.chips(ctx)
|
||||
.iter()
|
||||
.find(|chip_result| matches!(chip_result.kind, ContextChipKind::ShellGitBranch))
|
||||
.and_then(|chip_result| chip_result.value.as_ref().map(|v| v.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn close_all_chip_menus(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
for chip_view in &self.display_chips {
|
||||
chip_view.update(ctx, |chip, chip_ctx| {
|
||||
chip.handle_action(&DisplayChipAction::CloseMenu, chip_ctx);
|
||||
});
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Update the current repository path and rebuild chips.
|
||||
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);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for PromptDisplay {
|
||||
type Event = PromptDisplayEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for PromptDisplay {
|
||||
type Action = PromptDisplayAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
PromptDisplayAction::SelectGitBranch { value } => {
|
||||
ctx.emit(PromptDisplayEvent::TryExecuteCommand(
|
||||
format_git_branch_command(value),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for PromptDisplay {
|
||||
fn ui_name() -> &'static str {
|
||||
"PromptDisplay"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
// Try to focus any open menu in the display chips
|
||||
for chip_view in &self.display_chips {
|
||||
let menu_focused =
|
||||
chip_view.update(ctx, |chip, chip_ctx| chip.try_focus_open_menu(chip_ctx));
|
||||
if menu_focused {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let should_render_udi_chips = InputSettings::as_ref(app)
|
||||
.is_universal_developer_input_enabled(app)
|
||||
|| FeatureFlag::AgentView.is_enabled();
|
||||
let mut row = if should_render_udi_chips {
|
||||
RowBuilder::Wrap(
|
||||
Wrap::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Start)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_run_spacing(super::spacing::UDI_ROW_RUN_SPACING),
|
||||
)
|
||||
} else {
|
||||
RowBuilder::Flex(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_constrain_horizontal_bounds_to_parent(true)
|
||||
.with_main_axis_size(MainAxisSize::Min),
|
||||
)
|
||||
};
|
||||
|
||||
self.display_chips.iter().for_each(|display_chip| {
|
||||
let chip = display_chip.as_ref(app);
|
||||
// AgentPlanAndTodoList is only shown in the agent input footer
|
||||
if matches!(chip.chip_kind(), ContextChipKind::AgentPlanAndTodoList) {
|
||||
return;
|
||||
}
|
||||
if chip.should_render(app) {
|
||||
row.add_child(ChildView::new(display_chip).finish());
|
||||
}
|
||||
});
|
||||
|
||||
// This is a hack to apply horizontal clipping without vertical clipping (for padding).
|
||||
Container::new(
|
||||
Clipped::new(
|
||||
Container::new(row.finish())
|
||||
.with_vertical_margin(4.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_margin(-4.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
use super::{truncate_from_beginning, GitLineChanges};
|
||||
use crate::context_chips::{github_pr_display_text_from_url, ContextChipKind};
|
||||
|
||||
#[test]
|
||||
fn test_github_pr_display_text_from_url() {
|
||||
assert_eq!(
|
||||
github_pr_display_text_from_url("https://github.com/warp/warp/pull/123"),
|
||||
Some("PR #123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_pr_display_text_from_url_rejects_non_pr_urls() {
|
||||
assert_eq!(
|
||||
github_pr_display_text_from_url("https://github.com/warp/warp/issues/123"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
github_pr_display_text_from_url("https://github.com/warp/warp/pull/not-a-number"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_pr_chip_display_value_formats_url() {
|
||||
let value =
|
||||
crate::context_chips::ChipValue::Text("https://github.com/warp/warp/pull/456".to_string());
|
||||
assert_eq!(
|
||||
ContextChipKind::GithubPullRequest.display_value(&value),
|
||||
"PR #456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_pr_chip_display_value_falls_back_to_raw_value() {
|
||||
let value = crate::context_chips::ChipValue::Text("https://example.com/not-a-pr".to_string());
|
||||
assert_eq!(
|
||||
ContextChipKind::GithubPullRequest.display_value(&value),
|
||||
"https://example.com/not-a-pr"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_both_additions_and_deletions() {
|
||||
let input = " 3 files changed, 5 insertions(+), 2 deletions(-)";
|
||||
let result = GitLineChanges::parse_from_git_output(input).unwrap();
|
||||
|
||||
assert_eq!(result.files_changed, 3);
|
||||
assert_eq!(result.lines_added, 5);
|
||||
assert_eq!(result.lines_removed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_only_additions() {
|
||||
let input = " 2 files changed, 10 insertions(+)";
|
||||
let result = GitLineChanges::parse_from_git_output(input).unwrap();
|
||||
|
||||
assert_eq!(result.files_changed, 2);
|
||||
assert_eq!(result.lines_added, 10);
|
||||
assert_eq!(result.lines_removed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_only_deletions() {
|
||||
let input = " 1 file changed, 7 deletions(-)";
|
||||
let result = GitLineChanges::parse_from_git_output(input).unwrap();
|
||||
|
||||
assert_eq!(result.files_changed, 1);
|
||||
assert_eq!(result.lines_added, 0);
|
||||
assert_eq!(result.lines_removed, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_single_values() {
|
||||
// Test singular forms (1 file, 1 insertion, 1 deletion)
|
||||
let input = " 1 file changed, 1 insertion(+), 1 deletion(-)";
|
||||
let result = GitLineChanges::parse_from_git_output(input).unwrap();
|
||||
|
||||
assert_eq!(result.files_changed, 1);
|
||||
assert_eq!(result.lines_added, 1);
|
||||
assert_eq!(result.lines_removed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_no_leading_spaces() {
|
||||
// Git output sometimes doesn't have leading spaces
|
||||
let input = "2 files changed, 3 insertions(+), 1 deletion(-)";
|
||||
let result = GitLineChanges::parse_from_git_output(input).unwrap();
|
||||
|
||||
assert_eq!(result.files_changed, 2);
|
||||
assert_eq!(result.lines_added, 3);
|
||||
assert_eq!(result.lines_removed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_extra_whitespace() {
|
||||
// Test with extra whitespace and tabs
|
||||
let input = "\t 1 file changed, 5 insertions(+), \t 3 deletions(-) ";
|
||||
let result = GitLineChanges::parse_from_git_output(input).unwrap();
|
||||
|
||||
assert_eq!(result.files_changed, 1);
|
||||
assert_eq!(result.lines_added, 5);
|
||||
assert_eq!(result.lines_removed, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_empty_string() {
|
||||
let input = "";
|
||||
let result = GitLineChanges::parse_from_git_output(input);
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_whitespace_only() {
|
||||
let input = " \t\n ";
|
||||
let result = GitLineChanges::parse_from_git_output(input);
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_invalid_format() {
|
||||
let input = "This is not a valid git diff --shortstat output";
|
||||
let result = GitLineChanges::parse_from_git_output(input).unwrap();
|
||||
|
||||
// Should parse but find no valid numbers
|
||||
assert_eq!(result.files_changed, 0);
|
||||
assert_eq!(result.lines_added, 0);
|
||||
assert_eq!(result.lines_removed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_partial_matches() {
|
||||
// Test when only some parts match the expected pattern
|
||||
let input = " 2 files changed, some insertions, 3 deletions(-)";
|
||||
let result = GitLineChanges::parse_from_git_output(input).unwrap();
|
||||
|
||||
assert_eq!(result.files_changed, 2);
|
||||
assert_eq!(result.lines_added, 0); // "some" is not a number
|
||||
assert_eq!(result.lines_removed, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_from_git_output_zero_changes() {
|
||||
// Edge case: explicit zero values (unlikely from git but good to test)
|
||||
let input = " 0 files changed, 0 insertions(+), 0 deletions(-)";
|
||||
let result = GitLineChanges::parse_from_git_output(input).unwrap();
|
||||
|
||||
assert_eq!(result.files_changed, 0);
|
||||
assert_eq!(result.lines_added, 0);
|
||||
assert_eq!(result.lines_removed, 0);
|
||||
}
|
||||
|
||||
// Tests for truncate_from_beginning function
|
||||
// These tests ensure the function properly handles Unicode characters and doesn't panic
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_ascii_short_text() {
|
||||
let text = "hello";
|
||||
let result = truncate_from_beginning(text, 10);
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_ascii_exact_length() {
|
||||
let text = "hello";
|
||||
let result = truncate_from_beginning(text, 5);
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_ascii_truncation() {
|
||||
let text = "hello world";
|
||||
let result = truncate_from_beginning(text, 10);
|
||||
assert_eq!(result, "…llo world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_unicode_multibyte_chars() {
|
||||
// Test with multibyte Unicode characters (café has é which is 2 bytes in UTF-8)
|
||||
let text = "café";
|
||||
let result = truncate_from_beginning(text, 3);
|
||||
assert_eq!(result, "…fé");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_emoji() {
|
||||
// Test with emoji characters (each emoji is multiple bytes)
|
||||
let text = "hello🚀world";
|
||||
let result = truncate_from_beginning(text, 10);
|
||||
assert_eq!(result, "…llo🚀world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_mixed_unicode() {
|
||||
// Test with mixed ASCII, multibyte chars, and emoji
|
||||
let text = "café🚀test";
|
||||
let result = truncate_from_beginning(text, 8);
|
||||
assert_eq!(result, "…fé🚀test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_chinese_characters() {
|
||||
// Test with Chinese characters (each is typically 3 bytes in UTF-8)
|
||||
let text = "世界你好世界";
|
||||
let result = truncate_from_beginning(text, 5);
|
||||
assert_eq!(result, "…你好世界");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_cyrillic_characters() {
|
||||
// Test with Cyrillic characters
|
||||
let text = "Привет";
|
||||
let result = truncate_from_beginning(text, 4);
|
||||
assert_eq!(result, "…вет");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_max_length_one() {
|
||||
// Edge case: max_length = 1 should just return ellipsis
|
||||
let text = "hello";
|
||||
let result = truncate_from_beginning(text, 1);
|
||||
assert_eq!(result, "…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_max_length_zero() {
|
||||
// Edge case: max_length = 0 should return empty string
|
||||
let text = "hello";
|
||||
let result = truncate_from_beginning(text, 0);
|
||||
assert_eq!(result, "…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_empty_string() {
|
||||
// Edge case: empty string
|
||||
let text = "";
|
||||
let result = truncate_from_beginning(text, 5);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_single_unicode_char() {
|
||||
// Test with single Unicode character that's longer than max_length
|
||||
let text = "🚀";
|
||||
let result = truncate_from_beginning(text, 1);
|
||||
assert_eq!(result, "🚀"); // Should not truncate since it's already 1 character
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_complex_emoji() {
|
||||
// Test with complex emoji sequences (flags, skin tones, etc.)
|
||||
// Note: chars()-based truncation doesn't respect grapheme cluster boundaries,
|
||||
// so complex emoji like 👨👩👧👦 (7 chars with ZWJs) can be split mid-sequence.
|
||||
// We pick max_length=5 to land on a clean emoji boundary (👦, not a ZWJ).
|
||||
let text = "🚀🇺🇸🏳️🌈👨👩👧👦123";
|
||||
let result = truncate_from_beginning(text, 5);
|
||||
assert_eq!(result, "…👦123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_long_path() {
|
||||
// Test with a realistic scenario: long file path
|
||||
let text = "/home/user/projects/my-awesome-project/src/components/display_chip.rs";
|
||||
let result = truncate_from_beginning(text, 40);
|
||||
assert_eq!(result, "…-project/src/components/display_chip.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_windows_path() {
|
||||
// Test with Windows path containing Unicode
|
||||
let text = "C:\\Users\\用户\\Documents\\项目\\test.txt";
|
||||
let result = truncate_from_beginning(text, 20);
|
||||
assert_eq!(result, "…cuments\\项目\\test.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_from_beginning_preserves_char_boundaries() {
|
||||
// This is the critical test - ensure we never panic on char boundaries
|
||||
// Test various lengths to ensure we never hit a bad boundary
|
||||
let text = "café🚀世界test";
|
||||
|
||||
// Try every possible max_length to ensure no panics
|
||||
for max_len in 1..=text.chars().count() + 5 {
|
||||
let result = truncate_from_beginning(text, max_len);
|
||||
// Verify the result is valid UTF-8 (won't panic if it is)
|
||||
assert!(
|
||||
result.chars().count() <= max_len || result.chars().count() == text.chars().count()
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
use std::sync::mpsc;
|
||||
#[cfg(not(test))]
|
||||
use std::sync::OnceLock;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Write as _},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use chrono::{Local, SecondsFormat};
|
||||
#[cfg(test)]
|
||||
use parking_lot::Mutex;
|
||||
use warp_completer::completer::{CommandExitStatus, CommandOutput};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
use super::ContextChipKind;
|
||||
|
||||
const EMPTY_VALUE: &str = "<empty>";
|
||||
const MISSING_VALUE: &str = "<none>";
|
||||
|
||||
pub(crate) struct ChipCommandLogEntry<'a> {
|
||||
pub chip_kind: &'a ContextChipKind,
|
||||
pub chip_title: &'a str,
|
||||
pub phase: PromptChipExecutionPhase,
|
||||
pub shell_type: ShellType,
|
||||
pub working_directory: Option<&'a str>,
|
||||
pub command: &'a str,
|
||||
pub output: Option<&'a CommandOutput>,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PromptChipExecutionPhase {
|
||||
Value,
|
||||
OnClick,
|
||||
}
|
||||
|
||||
impl PromptChipExecutionPhase {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Value => "value",
|
||||
Self::OnClick => "on_click",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum PromptChipLogger {
|
||||
Disabled,
|
||||
Runtime {
|
||||
sender: mpsc::Sender<String>,
|
||||
},
|
||||
#[cfg(test)]
|
||||
TestBuffer {
|
||||
entries: Arc<Mutex<Vec<String>>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for PromptChipLogger {
|
||||
fn default() -> Self {
|
||||
Self::shared()
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptChipLogger {
|
||||
pub(crate) fn shared() -> Self {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(test)] {
|
||||
Self::Disabled
|
||||
} else {
|
||||
static SHARED_LOGGER: OnceLock<PromptChipLogger> = OnceLock::new();
|
||||
SHARED_LOGGER.get_or_init(Self::init_runtime).clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_test_buffer(entries: Arc<Mutex<Vec<String>>>) -> Self {
|
||||
Self::TestBuffer { entries }
|
||||
}
|
||||
|
||||
pub(crate) fn log_shell_command(&self, entry: &ChipCommandLogEntry<'_>) {
|
||||
let formatted = format_log_entry(entry);
|
||||
|
||||
match self {
|
||||
Self::Disabled => {}
|
||||
Self::Runtime { sender } => {
|
||||
let _ = sender.send(formatted);
|
||||
}
|
||||
#[cfg(test)]
|
||||
Self::TestBuffer { entries } => {
|
||||
entries.lock().push(formatted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn init_runtime() -> Self {
|
||||
if !warp_core::channel::ChannelState::enable_debug_features() {
|
||||
return Self::Disabled;
|
||||
}
|
||||
|
||||
let log_path = match log_file_path() {
|
||||
Ok(log_path) => log_path,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to determine prompt chip log file path: {err:#}");
|
||||
return Self::Disabled;
|
||||
}
|
||||
};
|
||||
|
||||
match spawn_log_writer(log_path.clone()) {
|
||||
Ok(sender) => Self::Runtime { sender },
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to initialize prompt chip log writer at {}: {err:#}",
|
||||
log_path.display()
|
||||
);
|
||||
Self::Disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
fn init_runtime() -> Self {
|
||||
Self::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) fn log_file_path() -> anyhow::Result<PathBuf> {
|
||||
let log_directory = warp_logging::log_directory()?;
|
||||
let channel_logfile_name = warp_core::channel::ChannelState::logfile_name();
|
||||
Ok(log_directory.join(prompt_chip_log_filename(&channel_logfile_name)))
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn spawn_log_writer(log_path: PathBuf) -> io::Result<mpsc::Sender<String>> {
|
||||
if let Some(parent) = log_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&log_path)?;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("prompt-chip-log-writer".to_string())
|
||||
.spawn(move || write_log_entries(file, rx, log_path))
|
||||
.map_err(io::Error::other)?;
|
||||
|
||||
Ok(tx)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn write_log_entries(mut file: File, rx: mpsc::Receiver<String>, log_path: PathBuf) {
|
||||
while let Ok(entry) = rx.recv() {
|
||||
if let Err(err) = file.write_all(entry.as_bytes()).and_then(|_| file.flush()) {
|
||||
log::error!(
|
||||
"Failed to write prompt chip log entry to {}: {err:#}",
|
||||
log_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_chip_log_filename(channel_logfile_name: &str) -> String {
|
||||
let channel_logfile_stem = channel_logfile_name
|
||||
.strip_suffix(".log")
|
||||
.unwrap_or(channel_logfile_name);
|
||||
format!("{channel_logfile_stem}.prompt_chips.log")
|
||||
}
|
||||
|
||||
fn format_log_entry(entry: &ChipCommandLogEntry<'_>) -> String {
|
||||
let timestamp = Local::now().to_rfc3339_opts(SecondsFormat::Millis, false);
|
||||
let status = if entry.timed_out {
|
||||
"timed_out"
|
||||
} else if entry
|
||||
.output
|
||||
.is_some_and(|output| output.status == CommandExitStatus::Success)
|
||||
{
|
||||
"success"
|
||||
} else {
|
||||
"failure"
|
||||
};
|
||||
let exit_code = entry
|
||||
.output
|
||||
.and_then(CommandOutput::exit_code)
|
||||
.map(|exit_code| exit_code.to_string())
|
||||
.unwrap_or_else(|| MISSING_VALUE.to_string());
|
||||
let stdout = entry
|
||||
.output
|
||||
.map_or(&[][..], |output| output.stdout.as_slice());
|
||||
let stderr = entry
|
||||
.output
|
||||
.map_or(&[][..], |output| output.stderr.as_slice());
|
||||
|
||||
format!(
|
||||
"\
|
||||
===== PROMPT CHIP EXECUTION BEGIN =====
|
||||
timestamp: {timestamp}
|
||||
chip_kind: {:?}
|
||||
chip_title: {}
|
||||
phase: {}
|
||||
shell_type: {:?}
|
||||
working_directory: {}
|
||||
status: {status}
|
||||
timed_out: {}
|
||||
exit_code: {exit_code}
|
||||
{}
|
||||
{}
|
||||
{}
|
||||
===== PROMPT CHIP EXECUTION END =====
|
||||
|
||||
",
|
||||
entry.chip_kind,
|
||||
entry.chip_title,
|
||||
entry.phase.as_str(),
|
||||
entry.shell_type,
|
||||
format_scalar_field(entry.working_directory),
|
||||
entry.timed_out,
|
||||
format_text_block("command", "COMMAND", entry.command),
|
||||
format_bytes_block("stdout", "STDOUT", stdout),
|
||||
format_bytes_block("stderr", "STDERR", stderr),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_scalar_field(value: Option<&str>) -> &str {
|
||||
value.unwrap_or(MISSING_VALUE)
|
||||
}
|
||||
|
||||
fn format_text_block(label: &str, marker: &str, content: &str) -> String {
|
||||
format_block(label, marker, content)
|
||||
}
|
||||
|
||||
fn format_bytes_block(label: &str, marker: &str, content: &[u8]) -> String {
|
||||
let content = if content.is_empty() {
|
||||
EMPTY_VALUE.to_string()
|
||||
} else {
|
||||
String::from_utf8_lossy(content).into_owned()
|
||||
};
|
||||
|
||||
format_block(label, marker, &content)
|
||||
}
|
||||
|
||||
fn format_block(label: &str, marker: &str, content: &str) -> String {
|
||||
let content = if content.is_empty() {
|
||||
EMPTY_VALUE
|
||||
} else {
|
||||
content
|
||||
};
|
||||
let mut output = String::new();
|
||||
output.push_str(label);
|
||||
output.push_str(":\n<<<");
|
||||
output.push_str(marker);
|
||||
output.push('\n');
|
||||
output.push_str(content);
|
||||
if !content.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(">>>");
|
||||
output.push_str(marker);
|
||||
output.push('\n');
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "logging_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::*;
|
||||
use warp_completer::completer::{CommandExitStatus, CommandOutput};
|
||||
|
||||
#[test]
|
||||
fn test_prompt_chip_log_filename_uses_channel_logfile_stem() {
|
||||
assert_eq!(
|
||||
prompt_chip_log_filename("warp_dev.log"),
|
||||
"warp_dev.prompt_chips.log"
|
||||
);
|
||||
assert_eq!(
|
||||
prompt_chip_log_filename("warp_local"),
|
||||
"warp_local.prompt_chips.log"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_uses_explicit_empty_and_missing_markers() {
|
||||
let entry = format_log_entry(&ChipCommandLogEntry {
|
||||
chip_kind: &ContextChipKind::GithubPullRequest,
|
||||
chip_title: "GitHub Pull Request",
|
||||
phase: PromptChipExecutionPhase::Value,
|
||||
shell_type: ShellType::Zsh,
|
||||
working_directory: None,
|
||||
command: "",
|
||||
output: None,
|
||||
timed_out: true,
|
||||
});
|
||||
|
||||
assert!(entry.contains("status: timed_out"));
|
||||
assert!(entry.contains("working_directory: <none>"));
|
||||
assert!(entry.contains("exit_code: <none>"));
|
||||
assert!(entry.contains("command:\n<<<COMMAND\n<empty>\n>>>COMMAND"));
|
||||
assert!(entry.contains("stdout:\n<<<STDOUT\n<empty>\n>>>STDOUT"));
|
||||
assert!(entry.contains("stderr:\n<<<STDERR\n<empty>\n>>>STDERR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_log_entry_preserves_stdout_and_stderr_sections() {
|
||||
let output = CommandOutput {
|
||||
stdout: b"https://github.com/warpdotdev/warp-internal/pull/123\n".to_vec(),
|
||||
stderr: b"warning output\n".to_vec(),
|
||||
status: CommandExitStatus::Success,
|
||||
exit_code: Some(warp_core::command::ExitCode::from(0)),
|
||||
};
|
||||
|
||||
let entry = format_log_entry(&ChipCommandLogEntry {
|
||||
chip_kind: &ContextChipKind::GithubPullRequest,
|
||||
chip_title: "GitHub Pull Request",
|
||||
phase: PromptChipExecutionPhase::OnClick,
|
||||
shell_type: ShellType::Zsh,
|
||||
working_directory: Some("/tmp/project"),
|
||||
command: "gh pr view --json url --jq .url",
|
||||
output: Some(&output),
|
||||
timed_out: false,
|
||||
});
|
||||
|
||||
assert!(entry.contains("phase: on_click"));
|
||||
assert!(entry.contains("status: success"));
|
||||
assert!(entry.contains("working_directory: /tmp/project"));
|
||||
assert!(entry.contains("command:\n<<<COMMAND\ngh pr view --json url --jq .url\n>>>COMMAND"));
|
||||
assert!(entry.contains(
|
||||
"stdout:\n<<<STDOUT\nhttps://github.com/warpdotdev/warp-internal/pull/123\n>>>STDOUT"
|
||||
));
|
||||
assert!(entry.contains("stderr:\n<<<STDERR\nwarning output\n>>>STDERR"));
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
// TODO: restrict what we make public here.
|
||||
mod builtins;
|
||||
pub mod context_chip;
|
||||
pub mod current_prompt;
|
||||
pub mod directory_fetcher;
|
||||
pub mod display;
|
||||
pub mod display_chip;
|
||||
pub mod display_menu;
|
||||
pub(crate) mod logging;
|
||||
pub mod node_version_popup;
|
||||
pub mod prompt;
|
||||
pub mod prompt_snapshot;
|
||||
pub mod prompt_type;
|
||||
pub mod renderer;
|
||||
pub mod spacing;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use context_chip::PromptGenerator;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smol_str::SmolStr;
|
||||
use warpui::{
|
||||
color::ColorU,
|
||||
elements::Text,
|
||||
fonts::{Properties, Weight},
|
||||
};
|
||||
|
||||
use crate::ui_components::{blended_colors, icons::Icon};
|
||||
use crate::{appearance::Appearance, features::FeatureFlag, themes::theme::PromptColors};
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use self::context_chip::{
|
||||
ChipAvailability, ChipDisabledReason, ChipRuntimeCapabilities, ExternalCommandsAvailability,
|
||||
};
|
||||
use self::{
|
||||
context_chip::{ChipFingerprintInput, ChipRuntimePolicy, ContextChip, RefreshConfig},
|
||||
renderer::RendererStyles,
|
||||
};
|
||||
|
||||
/// The value of a context chip. Most chips produce plain text, but some
|
||||
/// (like `GitDiffStats`) carry structured data to avoid string round-trips.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ChipValue {
|
||||
Text(String),
|
||||
GitDiffStats(display_chip::GitLineChanges),
|
||||
}
|
||||
|
||||
impl ChipValue {
|
||||
/// Returns the text representation, or `None` for non-text variants.
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
ChipValue::Text(s) => Some(s),
|
||||
ChipValue::GitDiffStats(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `GitLineChanges` payload, if this is the `GitDiffStats` variant.
|
||||
pub fn as_git_diff_stats(&self) -> Option<&display_chip::GitLineChanges> {
|
||||
match self {
|
||||
ChipValue::GitDiffStats(g) => Some(g),
|
||||
ChipValue::Text(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChipValue {
|
||||
fn default() -> Self {
|
||||
ChipValue::Text(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ChipValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ChipValue::Text(s) => f.write_str(s),
|
||||
ChipValue::GitDiffStats(g) => {
|
||||
write!(
|
||||
f,
|
||||
"{} • +{} -{}",
|
||||
g.files_changed, g.lines_added, g.lines_removed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ChipValue {
|
||||
fn from(s: String) -> Self {
|
||||
ChipValue::Text(s)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn github_pr_number_from_url(url: &str) -> Option<&str> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub(crate) fn github_pr_display_text_from_url(url: &str) -> Option<String> {
|
||||
github_pr_number_from_url(url).map(|number| format!("PR #{number}"))
|
||||
}
|
||||
|
||||
/// The refresh settings for the date context chip.
|
||||
/// unless the clock strikes midnight without the user running a command.
|
||||
const DATE_REFRESH_CONFIG: RefreshConfig = RefreshConfig::Periodically {
|
||||
interval: Duration::from_secs(30 * 60),
|
||||
};
|
||||
|
||||
/// The refresh settings for the time context chip.
|
||||
const TIME_REFRESH_CONFIG: RefreshConfig = RefreshConfig::Periodically {
|
||||
interval: Duration::from_secs(1),
|
||||
};
|
||||
|
||||
/// Refresh settings for Git context chips.
|
||||
const GIT_REFRESH_CONFIG: RefreshConfig =
|
||||
// TODO: Should we watch .git/HEAD instead? Needs to be relative to the current Git repo.
|
||||
RefreshConfig::Periodically {
|
||||
interval: Duration::from_secs(30),
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ChipResult {
|
||||
kind: ContextChipKind,
|
||||
value: Option<ChipValue>,
|
||||
on_click_values: Vec<String>,
|
||||
}
|
||||
|
||||
impl ChipResult {
|
||||
pub fn kind(&self) -> &ContextChipKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Option<&ChipValue> {
|
||||
self.value.as_ref()
|
||||
}
|
||||
|
||||
pub fn on_click_values(&self) -> &[String] {
|
||||
&self.on_click_values
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Clone,
|
||||
Debug,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Hash,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Type of prompt context chip.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum ContextChipKind {
|
||||
WorkingDirectory,
|
||||
Username,
|
||||
Hostname,
|
||||
Date,
|
||||
Time12,
|
||||
Time24,
|
||||
VirtualEnvironment,
|
||||
CondaEnvironment,
|
||||
NodeVersion,
|
||||
#[schemars(description = "A user-defined custom chip.")]
|
||||
Custom {
|
||||
title: String,
|
||||
},
|
||||
ShellGitBranch,
|
||||
GitDiffStats,
|
||||
GithubPullRequest,
|
||||
KubernetesContext,
|
||||
SvnBranch,
|
||||
SvnDirtyItems,
|
||||
// This is for backwards compatibility with the old "RemoteLogin" chip.
|
||||
// We originally had two different chips for different input types, this has since been consolidated.
|
||||
#[serde(alias = "RemoteLogin")]
|
||||
Ssh,
|
||||
Subshell,
|
||||
/// A chip that shows the plan and todo list for the current conversation.
|
||||
AgentPlanAndTodoList,
|
||||
}
|
||||
|
||||
impl ContextChipKind {
|
||||
pub fn to_chip(&self) -> Option<ContextChip> {
|
||||
match self {
|
||||
Self::WorkingDirectory => Some(ContextChip::builtin_with_runtime_policy(
|
||||
"Working Directory",
|
||||
builtins::working_directory,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
ChipRuntimePolicy::new(
|
||||
std::iter::empty::<&str>(),
|
||||
false,
|
||||
None,
|
||||
[
|
||||
ChipFingerprintInput::SessionId,
|
||||
ChipFingerprintInput::WorkingDirectory,
|
||||
],
|
||||
),
|
||||
)),
|
||||
Self::Username => Some(ContextChip::builtin_with_runtime_policy(
|
||||
"User",
|
||||
builtins::username,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
ChipRuntimePolicy::new(
|
||||
std::iter::empty::<&str>(),
|
||||
false,
|
||||
None,
|
||||
[ChipFingerprintInput::SessionId],
|
||||
),
|
||||
)),
|
||||
Self::Hostname => Some(ContextChip::builtin_with_runtime_policy(
|
||||
"Host",
|
||||
builtins::hostname,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
ChipRuntimePolicy::new(
|
||||
std::iter::empty::<&str>(),
|
||||
false,
|
||||
None,
|
||||
[ChipFingerprintInput::SessionId],
|
||||
),
|
||||
)),
|
||||
Self::VirtualEnvironment => Some(ContextChip::builtin_with_runtime_policy(
|
||||
"Python Virtualenv",
|
||||
builtins::virtual_environment,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
ChipRuntimePolicy::new(
|
||||
std::iter::empty::<&str>(),
|
||||
false,
|
||||
None,
|
||||
[
|
||||
ChipFingerprintInput::SessionId,
|
||||
ChipFingerprintInput::PythonVirtualenv,
|
||||
],
|
||||
),
|
||||
)),
|
||||
Self::CondaEnvironment => Some(ContextChip::builtin_with_runtime_policy(
|
||||
"Conda Environment",
|
||||
builtins::conda_environment,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
ChipRuntimePolicy::new(
|
||||
std::iter::empty::<&str>(),
|
||||
false,
|
||||
None,
|
||||
[
|
||||
ChipFingerprintInput::SessionId,
|
||||
ChipFingerprintInput::CondaEnvironment,
|
||||
],
|
||||
),
|
||||
)),
|
||||
Self::NodeVersion => Some(ContextChip::builtin_with_runtime_policy(
|
||||
"Node.js Version",
|
||||
builtins::node_version,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
ChipRuntimePolicy::new(
|
||||
std::iter::empty::<&str>(),
|
||||
false,
|
||||
None,
|
||||
[
|
||||
ChipFingerprintInput::SessionId,
|
||||
ChipFingerprintInput::NodeVersion,
|
||||
],
|
||||
),
|
||||
)),
|
||||
Self::Date => Some(ContextChip::builtin(
|
||||
"Date",
|
||||
builtins::date,
|
||||
DATE_REFRESH_CONFIG,
|
||||
)),
|
||||
Self::Time12 => Some(ContextChip::builtin(
|
||||
"Time (12-hour format)",
|
||||
builtins::time12,
|
||||
TIME_REFRESH_CONFIG,
|
||||
)),
|
||||
Self::Time24 => Some(ContextChip::builtin(
|
||||
"Time (24-hour format)",
|
||||
builtins::time24,
|
||||
TIME_REFRESH_CONFIG,
|
||||
)),
|
||||
Self::Custom { title } => {
|
||||
log::warn!("Tried to use custom chip {title}");
|
||||
None
|
||||
}
|
||||
Self::ShellGitBranch => Some(ContextChip::shell_builtin(
|
||||
"Git Branch",
|
||||
builtins::shell_git_branch(),
|
||||
Some(builtins::shell_other_git_branches()),
|
||||
GIT_REFRESH_CONFIG,
|
||||
)),
|
||||
Self::GitDiffStats => Some(
|
||||
ContextChip::shell_builtin(
|
||||
"Git Diff Stats",
|
||||
builtins::shell_git_line_changes(),
|
||||
None,
|
||||
GIT_REFRESH_CONFIG,
|
||||
)
|
||||
.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::KubernetesContext => Some(ContextChip::shell_builtin(
|
||||
"Kubernetes Context",
|
||||
builtins::kubernetes_current_context(),
|
||||
None,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
)),
|
||||
Self::SvnBranch => Some(ContextChip::shell_builtin(
|
||||
"Svn Branch",
|
||||
builtins::svn_branch_context(),
|
||||
None,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
)),
|
||||
Self::SvnDirtyItems => Some(ContextChip::shell_builtin(
|
||||
"Svn Uncommited File Count",
|
||||
builtins::svn_dirty_items(),
|
||||
None,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
)),
|
||||
Self::Ssh => Some(ContextChip::builtin(
|
||||
"Remote Login",
|
||||
builtins::ssh_session,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
)),
|
||||
Self::Subshell => Some(ContextChip::builtin(
|
||||
"subshell",
|
||||
builtins::subshell,
|
||||
RefreshConfig::OnDemandOnly,
|
||||
)),
|
||||
Self::AgentPlanAndTodoList => Some(ContextChip::builtin(
|
||||
"Agent Plan and Todo List",
|
||||
|_| Some(ChipValue::Text(String::new())),
|
||||
RefreshConfig::OnDemandOnly,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the context chip has a copyable value.
|
||||
pub fn is_copyable(&self) -> bool {
|
||||
!matches!(self, Self::AgentPlanAndTodoList)
|
||||
}
|
||||
|
||||
/// Returns a generator to be used for the first fetch of
|
||||
/// a periodic generator. Is mostly used to use the PreCmd value
|
||||
/// of git-branch for ShellGitBranch, while using a shell command
|
||||
/// for the periodic updates.
|
||||
pub fn initial_value_generator(&self) -> Option<PromptGenerator> {
|
||||
match self {
|
||||
Self::ShellGitBranch => Some(PromptGenerator::Contextual {
|
||||
from_context_fn: |context| {
|
||||
context
|
||||
.current_environment
|
||||
.git_branch()
|
||||
.map(|s| ChipValue::Text(s.to_string()))
|
||||
},
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// TODO: we might need to move this API to support custom chips.
|
||||
pub fn placeholder_value(&self) -> ChipValue {
|
||||
match self {
|
||||
Self::WorkingDirectory => ChipValue::Text("~/Desktop".to_string()),
|
||||
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::GitDiffStats => ChipValue::Text("3 • +10 -2".to_string()),
|
||||
Self::GithubPullRequest => ChipValue::Text("PR #123".to_string()),
|
||||
Self::VirtualEnvironment => ChipValue::Text("pyenv".to_string()),
|
||||
Self::CondaEnvironment => ChipValue::Text("condaenv".to_string()),
|
||||
Self::NodeVersion => ChipValue::Text("v18.17.0".to_string()),
|
||||
Self::Date => ChipValue::Text("July 12, 2023".to_string()),
|
||||
Self::Time12 => ChipValue::Text("03:48 pm".to_string()),
|
||||
Self::Time24 => ChipValue::Text("15:48".to_string()),
|
||||
Self::Custom { .. } => ChipValue::Text("custom chip".to_string()),
|
||||
Self::KubernetesContext => ChipValue::Text("kube-context".to_string()),
|
||||
Self::SvnBranch => ChipValue::Text("svn-feature-branch".to_string()),
|
||||
Self::SvnDirtyItems => ChipValue::Text("3".to_string()),
|
||||
Self::Ssh => ChipValue::Text("alice@127.0.0.1".to_string()),
|
||||
Self::Subshell => ChipValue::Text("bash".to_string()),
|
||||
Self::AgentPlanAndTodoList => ChipValue::Text("Plan and Todo List".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_styles(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
is_in_agent_view: bool,
|
||||
) -> RendererStyles {
|
||||
if is_in_agent_view {
|
||||
return RendererStyles::new(agent_view_chip_color(appearance), Properties::default());
|
||||
}
|
||||
let prompt_colors: PromptColors = appearance.theme().clone().into();
|
||||
|
||||
let color = match self {
|
||||
Self::WorkingDirectory => prompt_colors.input_prompt_pwd,
|
||||
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::GitDiffStats => prompt_colors.input_prompt_branch,
|
||||
Self::GithubPullRequest => prompt_colors.input_prompt_branch,
|
||||
Self::VirtualEnvironment => prompt_colors.input_prompt_virtual_env,
|
||||
Self::CondaEnvironment => prompt_colors.input_prompt_virtual_env,
|
||||
Self::NodeVersion => prompt_colors.input_prompt_virtual_env,
|
||||
Self::Date => prompt_colors.input_prompt_date,
|
||||
Self::Time12 => prompt_colors.input_prompt_time,
|
||||
Self::Time24 => prompt_colors.input_prompt_time,
|
||||
Self::KubernetesContext => prompt_colors.input_prompt_kubernetes,
|
||||
Self::SvnBranch => prompt_colors.input_prompt_branch,
|
||||
Self::SvnDirtyItems => prompt_colors.input_prompt_svn,
|
||||
Self::Ssh => prompt_colors.input_prompt_ssh,
|
||||
Self::Subshell => prompt_colors.input_prompt_subshell,
|
||||
Self::AgentPlanAndTodoList => prompt_colors.input_prompt_agent_mode_hint,
|
||||
Self::Custom { .. } => ColorU::new(255, 255, 255, 255),
|
||||
};
|
||||
|
||||
let font_properties = Properties::default().weight(Weight::Semibold);
|
||||
|
||||
RendererStyles::new(color, font_properties)
|
||||
}
|
||||
|
||||
/// The name of this context chip to use in telemetry, or `None` if it should not
|
||||
/// be reported at all.
|
||||
///
|
||||
/// This lets us measure which chips are being used without reporting private
|
||||
/// user-created chips.
|
||||
pub fn telemetry_name(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Custom { .. } => None,
|
||||
chip => Some(format!("{chip:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a value of this context chip for display.
|
||||
///
|
||||
/// This is temporary until chip prefixes/suffixes are user-configurable.
|
||||
/// Keep in sync with [`display::PromptDisplay`].
|
||||
pub fn display_value(&self, value: &ChipValue) -> String {
|
||||
let text = value.to_string();
|
||||
match self {
|
||||
Self::ShellGitBranch => format!("git:({text})"),
|
||||
Self::GithubPullRequest => github_pr_display_text_from_url(&text).unwrap_or(text),
|
||||
Self::KubernetesContext => format!("⎈ {text}"),
|
||||
Self::SvnBranch => format!("svn:({text})"),
|
||||
Self::SvnDirtyItems => format!("±{text}"),
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not a context chip should render given the current command
|
||||
/// in the input.
|
||||
pub fn should_render(&self, command: &str, aliases: &HashMap<SmolStr, String>) -> bool {
|
||||
match self {
|
||||
Self::KubernetesContext => {
|
||||
const KUBERNETES_COMMANDS: [&str; 20] = [
|
||||
"kubectl",
|
||||
"helm",
|
||||
"kubens",
|
||||
"kubectx",
|
||||
"oc",
|
||||
"istioctl",
|
||||
"kogito",
|
||||
"k9s",
|
||||
"helmfile",
|
||||
"flux",
|
||||
"fluxctl",
|
||||
"stern",
|
||||
"kubeseal",
|
||||
"skaffold",
|
||||
"kubent",
|
||||
"kubecolor",
|
||||
"cmctl",
|
||||
"sparkctl",
|
||||
"etcd",
|
||||
"fubectl",
|
||||
];
|
||||
|
||||
command.split_whitespace().next().is_some_and(|first_word| {
|
||||
KUBERNETES_COMMANDS.contains(&first_word)
|
||||
|| aliases.get(first_word).is_some_and(|expanded| {
|
||||
KUBERNETES_COMMANDS.contains(&expanded.as_str())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// All other chips unconditionally render
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn udi_icon(&self) -> Option<Icon> {
|
||||
match self {
|
||||
Self::WorkingDirectory => Some(Icon::Folder),
|
||||
Self::Username | Self::Ssh => Some(Icon::User),
|
||||
Self::Hostname => Some(Icon::Laptop),
|
||||
Self::Date => Some(Icon::CalendarDate),
|
||||
Self::Time12 | Self::Time24 => Some(Icon::Clock),
|
||||
Self::VirtualEnvironment | Self::CondaEnvironment | Self::Subshell => {
|
||||
Some(Icon::Terminal)
|
||||
}
|
||||
Self::NodeVersion => Some(Icon::NodeJS),
|
||||
Self::ShellGitBranch | Self::SvnBranch => Some(Icon::GitBranch),
|
||||
Self::GitDiffStats | Self::SvnDirtyItems => Some(Icon::File),
|
||||
Self::GithubPullRequest => Some(Icon::Github),
|
||||
Self::KubernetesContext => Some(Icon::Globe),
|
||||
Self::AgentPlanAndTodoList => Some(Icon::CheckSkinny),
|
||||
Self::Custom { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the set of chips that are available for use in the agent footer.
|
||||
pub fn agent_footer_available_chips() -> Vec<ContextChipKind> {
|
||||
let mut chips = available_chips();
|
||||
chips.push(ContextChipKind::AgentPlanAndTodoList);
|
||||
chips
|
||||
}
|
||||
|
||||
/// TODO: this needs to also fetch the custom chips from sqlite
|
||||
pub fn available_chips() -> Vec<ContextChipKind> {
|
||||
let mut chips = vec![
|
||||
ContextChipKind::WorkingDirectory,
|
||||
ContextChipKind::Username,
|
||||
ContextChipKind::Hostname,
|
||||
ContextChipKind::Ssh,
|
||||
ContextChipKind::ShellGitBranch,
|
||||
ContextChipKind::GitDiffStats,
|
||||
];
|
||||
if FeatureFlag::GithubPrPromptChip.is_enabled() {
|
||||
chips.push(ContextChipKind::GithubPullRequest);
|
||||
}
|
||||
chips.extend([
|
||||
ContextChipKind::Date,
|
||||
ContextChipKind::Time12,
|
||||
ContextChipKind::Time24,
|
||||
ContextChipKind::VirtualEnvironment,
|
||||
ContextChipKind::CondaEnvironment,
|
||||
ContextChipKind::NodeVersion,
|
||||
ContextChipKind::KubernetesContext,
|
||||
ContextChipKind::SvnBranch,
|
||||
ContextChipKind::SvnDirtyItems,
|
||||
]);
|
||||
chips
|
||||
}
|
||||
|
||||
/// Parses [`display_chip::GitLineChanges`] from the raw shell command output stored in a
|
||||
/// [`GitDiffStats`](ContextChipKind::GitDiffStats) chip's value.
|
||||
///
|
||||
/// Used as a fallback when `GitRepoStatusModel` is unavailable (e.g. remote sessions,
|
||||
/// local subshells).
|
||||
pub fn git_line_changes_from_chips(chips: &[ChipResult]) -> Option<display_chip::GitLineChanges> {
|
||||
chips.iter().find_map(|chip| {
|
||||
if matches!(chip.kind(), ContextChipKind::GitDiffStats) {
|
||||
chip.value().map(|value| match value {
|
||||
// Structured data from GitRepoStatusModel — use directly.
|
||||
ChipValue::GitDiffStats(g) => g.clone(),
|
||||
// Raw shell command output (remote sessions) — parse.
|
||||
ChipValue::Text(raw) => display_chip::GitLineChanges::parse_from_git_output(raw)
|
||||
.unwrap_or(display_chip::GitLineChanges {
|
||||
files_changed: 0,
|
||||
lines_added: 0,
|
||||
lines_removed: 0,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Formats given context chips as an unstylized string.
|
||||
/// Compared to displaying individual chips, certain chips when combined are displayed differently.
|
||||
pub fn chips_to_string(chips: impl Iterator<Item = ChipResult>) -> String {
|
||||
let mut prompt = String::new();
|
||||
let mut visible_chips = chips
|
||||
.into_iter()
|
||||
.filter_map(|chip_result| Some((chip_result.kind, chip_result.value?)))
|
||||
.peekable();
|
||||
while let Some((chip_kind, current_value)) = visible_chips.next() {
|
||||
// This is temporary, until we design more generic chip formatting.
|
||||
let next_chip_kind = visible_chips.peek().map(|(next_kind, _)| next_kind);
|
||||
let chip_display_value = chip_kind.display_value(¤t_value);
|
||||
prompt.push_str(&chip_display_value);
|
||||
match (chip_kind, next_chip_kind) {
|
||||
// Omit the space between adjacent Svn chips.
|
||||
(ContextChipKind::SvnBranch, Some(ContextChipKind::SvnDirtyItems)) => (),
|
||||
(_, Some(_)) => {
|
||||
// Add padding after non-empty chips.
|
||||
if !chip_display_value.is_empty() {
|
||||
prompt.push(' ');
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
prompt
|
||||
}
|
||||
|
||||
pub(crate) fn agent_view_chip_color(appearance: &Appearance) -> ColorU {
|
||||
let theme = appearance.theme();
|
||||
theme
|
||||
.sub_text_color(blended_colors::neutral_1(theme).into())
|
||||
.into_solid()
|
||||
}
|
||||
|
||||
/// Helper function that adds specific styling to chips' text element.
|
||||
/// Keeps chips in both editor and input in sync.
|
||||
/// Keep in sync with [`ContextChipKind::display_value`]
|
||||
pub fn render_text_from_kind(
|
||||
text: &mut Text,
|
||||
kind: ContextChipKind,
|
||||
value: String,
|
||||
is_in_agent_view: bool,
|
||||
appearance: &Appearance,
|
||||
) {
|
||||
let styles = kind.default_styles(appearance, is_in_agent_view);
|
||||
let prompt_colors: PromptColors = appearance.theme().clone().into();
|
||||
|
||||
// Keep in sync with `ContextChipKind::display_value`
|
||||
match kind {
|
||||
ContextChipKind::ShellGitBranch => {
|
||||
text.add_text_with_highlights(
|
||||
"git:(",
|
||||
if is_in_agent_view {
|
||||
styles.value_color
|
||||
} else {
|
||||
prompt_colors.input_prompt_git
|
||||
},
|
||||
styles.font_properties,
|
||||
);
|
||||
}
|
||||
ContextChipKind::SvnBranch => {
|
||||
text.add_text_with_highlights(
|
||||
"svn:(",
|
||||
if is_in_agent_view {
|
||||
styles.value_color
|
||||
} else {
|
||||
prompt_colors.input_prompt_svn
|
||||
},
|
||||
styles.font_properties,
|
||||
);
|
||||
}
|
||||
ContextChipKind::SvnDirtyItems => {
|
||||
text.add_text_with_highlights(
|
||||
"±",
|
||||
if is_in_agent_view {
|
||||
styles.value_color
|
||||
} else {
|
||||
prompt_colors.input_prompt_svn
|
||||
},
|
||||
styles.font_properties,
|
||||
);
|
||||
}
|
||||
ContextChipKind::KubernetesContext => {
|
||||
text.add_text_with_highlights(
|
||||
"⎈ ",
|
||||
if is_in_agent_view {
|
||||
styles.value_color
|
||||
} else {
|
||||
prompt_colors.input_prompt_kubernetes
|
||||
},
|
||||
if is_in_agent_view {
|
||||
styles.font_properties
|
||||
} else {
|
||||
Properties::default().weight(Weight::Thin)
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
text.add_text_with_highlights(value, styles.value_color, styles.font_properties);
|
||||
|
||||
match kind {
|
||||
ContextChipKind::ShellGitBranch => {
|
||||
text.add_text_with_highlights(
|
||||
")",
|
||||
if is_in_agent_view {
|
||||
styles.value_color
|
||||
} else {
|
||||
prompt_colors.input_prompt_git
|
||||
},
|
||||
styles.font_properties,
|
||||
);
|
||||
}
|
||||
ContextChipKind::SvnBranch => {
|
||||
text.add_text_with_highlights(
|
||||
")",
|
||||
if is_in_agent_view {
|
||||
styles.value_color
|
||||
} else {
|
||||
prompt_colors.input_prompt_svn
|
||||
},
|
||||
styles.font_properties,
|
||||
);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
ChildView, ClippedScrollStateHandle, ClippedScrollable, Dismiss, ParentElement, ScrollbarWidth,
|
||||
};
|
||||
use warpui::fonts::FamilyId;
|
||||
use warpui::{
|
||||
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 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::view_components::action_button::{ActionButton, SecondaryTheme};
|
||||
|
||||
const MENU_WIDTH: f32 = 300.0;
|
||||
const MENU_MAX_HEIGHT: f32 = 260.0;
|
||||
|
||||
pub struct NodeVersionPopupView {
|
||||
install_button: ViewHandle<ActionButton>,
|
||||
install_latest_node_button: ViewHandle<ActionButton>,
|
||||
has_nvm: bool,
|
||||
versions: Vec<String>,
|
||||
current_version: Option<String>,
|
||||
versions_menu: Option<ViewHandle<Menu<NodeVersionPopupAction>>>,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NodeVersionPopupAction {
|
||||
ClosePopup,
|
||||
InstallNvm,
|
||||
InstallLatestNodeVersion,
|
||||
SelectVersion { version: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NodeVersionPopupEvent {
|
||||
Close,
|
||||
InstallNvm,
|
||||
InstallLatestNodeVersion,
|
||||
SelectVersion { version: String },
|
||||
}
|
||||
|
||||
struct Styles {
|
||||
ui_font_family: FamilyId,
|
||||
background: Fill,
|
||||
main_text_color: ColorU,
|
||||
secondary_text_color: ColorU,
|
||||
tertiary_text_color: ColorU,
|
||||
detail_font_size: f32,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
NodeVersionPopupAction::ClosePopup,
|
||||
id!(NodeVersionPopupView::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
impl NodeVersionPopupView {
|
||||
pub fn new(
|
||||
current_version: Option<String>,
|
||||
model_events: &ModelHandle<ModelEventDispatcher>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let install_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Install nvm", SecondaryTheme)
|
||||
.with_icon(icons::Icon::Terminal)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(NodeVersionPopupAction::InstallNvm);
|
||||
})
|
||||
});
|
||||
let install_latest_node_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("nvm install node", SecondaryTheme)
|
||||
.with_icon(icons::Icon::Terminal)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(NodeVersionPopupAction::InstallLatestNodeVersion);
|
||||
})
|
||||
});
|
||||
let has_nvm = detect_nvm_installed();
|
||||
let versions = if has_nvm {
|
||||
list_nvm_versions()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let versions_menu = if has_nvm {
|
||||
let menu_handle = ctx.add_typed_action_view(|ctx| {
|
||||
let mut menu = Menu::new().with_width(MENU_WIDTH);
|
||||
menu.set_items(Self::menu_items(&versions, current_version.as_deref()), ctx);
|
||||
let selected_index =
|
||||
get_selected_version_index(&versions, current_version.as_deref());
|
||||
menu.set_selected_by_index(selected_index, ctx);
|
||||
menu
|
||||
});
|
||||
ctx.subscribe_to_view(&menu_handle, |_, _, event, ctx| {
|
||||
if let MenuEvent::Close { .. } = event {
|
||||
ctx.emit(NodeVersionPopupEvent::Close);
|
||||
}
|
||||
});
|
||||
Some(menu_handle)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Subscribe to command execution events to refresh
|
||||
// when nvm is installed or a node version is installed
|
||||
ctx.subscribe_to_model(model_events, |me, _model, event, ctx| match event {
|
||||
ModelEvent::ExecutedInBandCommand(_) | ModelEvent::AfterBlockCompleted(_) => {
|
||||
me.refresh(ctx);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
Self {
|
||||
install_button,
|
||||
install_latest_node_button,
|
||||
has_nvm,
|
||||
versions,
|
||||
current_version,
|
||||
versions_menu,
|
||||
scroll_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn styles(&self, appearance: &Appearance) -> Styles {
|
||||
let theme = appearance.theme();
|
||||
let background = theme.surface_2();
|
||||
let main_text_color = blended_colors::text_main(theme, background);
|
||||
let secondary_text_color = blended_colors::text_sub(theme, background);
|
||||
let tertiary_text_color = theme.hint_text_color(background).into_solid();
|
||||
let detail_font_size = appearance.ui_font_size();
|
||||
let ui_font_family = appearance.ui_font_family();
|
||||
|
||||
Styles {
|
||||
ui_font_family,
|
||||
background,
|
||||
main_text_color,
|
||||
secondary_text_color,
|
||||
tertiary_text_color,
|
||||
detail_font_size,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_install_nvm_empty_state(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let styles = self.styles(appearance);
|
||||
|
||||
let mut col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max);
|
||||
|
||||
col.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
icons::Icon::NodeJS
|
||||
.to_warpui_icon(styles.tertiary_text_color.into())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(24.)
|
||||
.with_height(24.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(12.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
col.add_child(
|
||||
Text::new(
|
||||
"Install nvm to enable version switching",
|
||||
styles.ui_font_family,
|
||||
styles.detail_font_size + 2.,
|
||||
)
|
||||
.with_style(Properties::default())
|
||||
.with_color(styles.secondary_text_color)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
col.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
"This menu helps you switch between Node.js versions — but it requires nvm to be installed.",
|
||||
styles.ui_font_family,
|
||||
styles.detail_font_size,
|
||||
)
|
||||
.with_color(styles.tertiary_text_color)
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(6.)
|
||||
.with_margin_bottom(12.)
|
||||
.with_horizontal_padding(24.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
col.add_child(ChildView::new(&self.install_button).finish());
|
||||
|
||||
ConstrainedBox::new(col.finish())
|
||||
.with_max_width(MENU_WIDTH)
|
||||
.with_max_height(MENU_MAX_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_install_latest_node_version_empty_state(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let styles = self.styles(appearance);
|
||||
|
||||
let mut col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max);
|
||||
|
||||
col.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
icons::Icon::NodeJS
|
||||
.to_warpui_icon(styles.tertiary_text_color.into())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(24.)
|
||||
.with_height(24.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(12.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Heading
|
||||
col.add_child(
|
||||
Text::new(
|
||||
"No node versions installed",
|
||||
styles.ui_font_family,
|
||||
styles.detail_font_size + 2.,
|
||||
)
|
||||
.with_style(Properties::default())
|
||||
.with_color(styles.secondary_text_color)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Subheading
|
||||
col.add_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
"Try installing versions with nvm",
|
||||
styles.ui_font_family,
|
||||
styles.detail_font_size,
|
||||
)
|
||||
.with_color(styles.tertiary_text_color)
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(6.)
|
||||
.with_margin_bottom(12.)
|
||||
.with_horizontal_padding(24.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Button
|
||||
col.add_child(ChildView::new(&self.install_latest_node_button).finish());
|
||||
|
||||
ConstrainedBox::new(col.finish())
|
||||
.with_max_width(MENU_WIDTH)
|
||||
.with_max_height(MENU_MAX_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_node_version_selector(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let styles = self.styles(appearance);
|
||||
|
||||
let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
col.add_child(
|
||||
Container::new(
|
||||
Text::new("Installed", styles.ui_font_family, styles.detail_font_size)
|
||||
.with_style(Properties::default())
|
||||
.with_color(styles.secondary_text_color)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(12.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(menu) = &self.versions_menu {
|
||||
col.add_child(ChildView::new(menu).finish());
|
||||
}
|
||||
|
||||
Container::new(col.finish()).with_padding_top(8.).finish()
|
||||
}
|
||||
|
||||
fn menu_items(
|
||||
versions: &[String],
|
||||
current_version: Option<&str>,
|
||||
) -> Vec<menu::MenuItem<NodeVersionPopupAction>> {
|
||||
versions
|
||||
.iter()
|
||||
.map(|ver| {
|
||||
let mut fields = MenuItemFields::new(ver).with_on_select_action(
|
||||
NodeVersionPopupAction::SelectVersion {
|
||||
version: ver.clone(),
|
||||
},
|
||||
);
|
||||
if is_current_version(ver, current_version) {
|
||||
fields = fields.with_icon(icons::Icon::Check);
|
||||
} else {
|
||||
fields = fields.with_indent();
|
||||
}
|
||||
menu::MenuItem::Item(fields)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.has_nvm = detect_nvm_installed();
|
||||
|
||||
self.versions = if self.has_nvm {
|
||||
list_nvm_versions()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
if let Some(menu) = &self.versions_menu {
|
||||
menu.update(ctx, |menu, ctx| {
|
||||
menu.set_items(
|
||||
Self::menu_items(&self.versions, self.current_version.as_deref()),
|
||||
ctx,
|
||||
);
|
||||
let selected_index =
|
||||
get_selected_version_index(&self.versions, self.current_version.as_deref());
|
||||
menu.set_selected_by_index(selected_index, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl View for NodeVersionPopupView {
|
||||
fn ui_name() -> &'static str {
|
||||
"NodeVersionPopup"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let styles = self.styles(appearance);
|
||||
|
||||
let content = if !self.versions.is_empty() {
|
||||
self.render_node_version_selector(app)
|
||||
} else if self.has_nvm {
|
||||
self.render_install_latest_node_version_empty_state(app)
|
||||
} else {
|
||||
self.render_install_nvm_empty_state(app)
|
||||
};
|
||||
|
||||
let scrollable = ClippedScrollable::vertical(
|
||||
self.scroll_state.clone(),
|
||||
content,
|
||||
ScrollbarWidth::Auto,
|
||||
appearance.theme().nonactive_ui_detail().into(),
|
||||
appearance.theme().active_ui_detail().into(),
|
||||
warpui::elements::Fill::None,
|
||||
)
|
||||
.with_overlayed_scrollbar()
|
||||
.finish();
|
||||
|
||||
Dismiss::new(
|
||||
ConstrainedBox::new(
|
||||
Container::new(scrollable)
|
||||
.with_background(styles.background)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MENU_WIDTH)
|
||||
.with_max_height(MENU_MAX_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.prevent_interaction_with_other_elements()
|
||||
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(NodeVersionPopupAction::ClosePopup))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NodeVersionPopupView {
|
||||
type Event = NodeVersionPopupEvent;
|
||||
}
|
||||
|
||||
impl NodeVersionPopupView {
|
||||
pub fn focus_content(&self, ctx: &mut ViewContext<Self>) {
|
||||
// Focus menu if present to allow keyboard navigation
|
||||
if let Some(menu) = &self.versions_menu {
|
||||
ctx.focus(menu);
|
||||
} else {
|
||||
ctx.focus_self();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for NodeVersionPopupView {
|
||||
type Action = NodeVersionPopupAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
NodeVersionPopupAction::ClosePopup => ctx.emit(NodeVersionPopupEvent::Close),
|
||||
NodeVersionPopupAction::InstallNvm => ctx.emit(NodeVersionPopupEvent::InstallNvm),
|
||||
NodeVersionPopupAction::InstallLatestNodeVersion => {
|
||||
ctx.emit(NodeVersionPopupEvent::InstallLatestNodeVersion)
|
||||
}
|
||||
NodeVersionPopupAction::SelectVersion { version } => {
|
||||
ctx.emit(NodeVersionPopupEvent::SelectVersion {
|
||||
version: version.clone(),
|
||||
});
|
||||
ctx.emit(NodeVersionPopupEvent::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-OS detection of nvm availability
|
||||
fn detect_nvm_installed() -> bool {
|
||||
use std::env;
|
||||
|
||||
// Helper: check if an executable exists in PATH
|
||||
fn in_path(candidate: &str) -> bool {
|
||||
if let Ok(path_var) = env::var("PATH") {
|
||||
for dir in env::split_paths(&path_var) {
|
||||
let mut p = dir.clone();
|
||||
p.push(candidate);
|
||||
if p.is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// 1) Windows nvm-windows
|
||||
#[cfg(windows)]
|
||||
{
|
||||
env::var("PATH").is_ok_and(|path_var| path_var.contains("%NVM_HOME%"))
|
||||
|| env::var("NVM_HOME").is_ok()
|
||||
}
|
||||
|
||||
// 2) POSIX shells: nvm is typically a shell function; detect via standard install locations
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
use std::path::Path;
|
||||
|
||||
// NVM_DIR env var with nvm.sh present
|
||||
if let Ok(nvm_dir) = env::var("NVM_DIR") {
|
||||
let nvm_sh = Path::new(&nvm_dir).join("nvm.sh");
|
||||
if nvm_sh.is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Default NVM_DIR ~/.nvm/nvm.sh
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let nvm_sh = home.join(".nvm").join("nvm.sh");
|
||||
if nvm_sh.is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Homebrew locations
|
||||
let brew_paths: &[&str] = &["/opt/homebrew/opt/nvm", "/usr/local/opt/nvm"];
|
||||
for base in brew_paths {
|
||||
let nvm_sh = Path::new(base).join("nvm.sh");
|
||||
if nvm_sh.is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fish plugin-based installs
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let fish_nvm = home
|
||||
.join(".config")
|
||||
.join("fish")
|
||||
.join("functions")
|
||||
.join("nvm.fish");
|
||||
if fish_nvm.is_file() {
|
||||
return true;
|
||||
}
|
||||
// macOS possible alt path for fish conf sometimes under Library
|
||||
let fish_alt = home
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("fish")
|
||||
.join("functions")
|
||||
.join("nvm.fish");
|
||||
if fish_alt.is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If an `nvm` shim exists on PATH (rare on unix because it's a function), still check
|
||||
if in_path("nvm") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(nvm_home) = env::var("NVM_HOME") {
|
||||
let base = Path::new(&nvm_home);
|
||||
if let Ok(read_dir) = std::fs::read_dir(base) {
|
||||
for entry in read_dir.flatten() {
|
||||
if let Ok(ft) = entry.file_type() {
|
||||
if ft.is_dir() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
// nvm-windows typically uses folder names like v18.19.1 or 18.19.1
|
||||
if name
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c == 'v' || c.is_ascii_digit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Prefer $NVM_DIR/versions/node
|
||||
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
|
||||
if let Ok(nvm_dir) = env::var("NVM_DIR") {
|
||||
candidates.push(Path::new(&nvm_dir).join("versions").join("node"));
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.push(home.join(".nvm").join("versions").join("node"));
|
||||
}
|
||||
|
||||
for base in candidates {
|
||||
if let Ok(read_dir) = std::fs::read_dir(&base) {
|
||||
for entry in read_dir.flatten() {
|
||||
if let Ok(ft) = entry.file_type() {
|
||||
if ft.is_dir() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort descending so the latest version is first
|
||||
out.sort_by(|a, b| b.cmp(a));
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
fn normalize_version(ver: &str) -> String {
|
||||
ver.trim()
|
||||
.strip_prefix('v')
|
||||
.unwrap_or(ver.trim())
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_current_version(candidate: &str, current: Option<&str>) -> bool {
|
||||
current.is_some_and(|cur| normalize_version(candidate) == normalize_version(cur))
|
||||
}
|
||||
|
||||
fn get_selected_version_index(versions: &[String], current_version: Option<&str>) -> usize {
|
||||
versions
|
||||
.iter()
|
||||
.position(|v| is_current_version(v, current_version))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
use crate::{
|
||||
settings::{
|
||||
AISettings, AISettingsChangedEvent, InputSettings, InputSettingsChangedEvent,
|
||||
WarpPromptSeparator,
|
||||
},
|
||||
terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent},
|
||||
};
|
||||
|
||||
pub use super::ContextChipKind;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Setting as _;
|
||||
use warpui::{Entity, GetSingletonModelHandle, ModelContext, SingletonEntity, UpdateModel};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "prompt_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "Configuration for a prompt chip.")]
|
||||
pub struct ChipConfig {
|
||||
// TODO in the future
|
||||
}
|
||||
|
||||
/// PromptChip holds the configuration of the specific chip in the prompt that the user set.
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "A chip in the prompt with its configuration.")]
|
||||
pub struct PromptChip {
|
||||
#[schemars(description = "The type of context chip.")]
|
||||
chip: ContextChipKind,
|
||||
#[schemars(description = "Configuration options for this chip.")]
|
||||
config: ChipConfig,
|
||||
}
|
||||
|
||||
impl PromptChip {
|
||||
fn new(chip: ContextChipKind, config: ChipConfig) -> Self {
|
||||
Self { chip, config }
|
||||
}
|
||||
|
||||
pub fn chip(&self) -> &ContextChipKind {
|
||||
&self.chip
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize prompt chips, silently dropping any with unrecognized chip kinds.
|
||||
/// This ensures saved prompt configs remain intact when chip kinds are removed.
|
||||
fn deserialize_prompt_chips<'de, D>(deserializer: D) -> Result<Vec<PromptChip>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let values: Vec<serde_json::Value> = serde::Deserialize::deserialize(deserializer)?;
|
||||
Ok(values
|
||||
.into_iter()
|
||||
.filter_map(|value| serde_json::from_value::<PromptChip>(value).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Serializable configuration for the current prompt.
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "Prompt layout configuration.")]
|
||||
pub struct PromptConfiguration {
|
||||
#[serde(default, deserialize_with = "deserialize_prompt_chips")]
|
||||
#[schemars(description = "Ordered list of chips to display in the prompt.")]
|
||||
chips: Vec<PromptChip>,
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Whether git diff stats have been separated into their own chip.")]
|
||||
did_separate_git_diff_stats: bool,
|
||||
|
||||
#[schemars(description = "Whether the prompt is displayed on the same line as the input.")]
|
||||
same_line_prompt_enabled: bool,
|
||||
/// The separator to use as a trailing character at the end of Warp prompt, if any.
|
||||
#[schemars(description = "Trailing separator character for the prompt.")]
|
||||
separator: WarpPromptSeparator,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Whether using the default or a custom prompt layout.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum PromptSelection {
|
||||
#[default]
|
||||
#[schemars(description = "Use the default prompt.")]
|
||||
Default,
|
||||
#[schemars(description = "Use a custom prompt chip selection.")]
|
||||
CustomChipSelection(PromptConfiguration),
|
||||
}
|
||||
|
||||
impl From<PromptConfiguration> for PromptSelection {
|
||||
fn from(config: PromptConfiguration) -> Self {
|
||||
Self::CustomChipSelection(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSelection {
|
||||
pub fn same_line_prompt_enabled(&self) -> bool {
|
||||
match self {
|
||||
PromptSelection::Default => false,
|
||||
PromptSelection::CustomChipSelection(config) => config.same_line_prompt_enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn separator(&self) -> WarpPromptSeparator {
|
||||
match self {
|
||||
PromptSelection::Default => WarpPromptSeparator::None,
|
||||
PromptSelection::CustomChipSelection(config) => config.separator(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt is the singleton entity that stores the selected prompt configuration.
|
||||
pub struct Prompt {
|
||||
config: PromptConfiguration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PromptEvent {
|
||||
/// The prompt configuration changed.
|
||||
Changed,
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
/// Creates a global singleton [`Prompt`] that responds to settings changes.
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let session_settings = SessionSettings::handle(ctx);
|
||||
ctx.subscribe_to_model(&session_settings, Self::handle_session_settings_change);
|
||||
let ai_settings = AISettings::handle(ctx);
|
||||
ctx.subscribe_to_model(&ai_settings, Self::handle_ai_settings_change);
|
||||
let input_settings = InputSettings::handle(ctx);
|
||||
ctx.subscribe_to_model(&input_settings, Self::handle_input_settings_change);
|
||||
|
||||
let initial_config = Self::from_user_settings(ctx);
|
||||
Self {
|
||||
config: initial_config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update<I>(
|
||||
&mut self,
|
||||
chips: I,
|
||||
same_line_prompt_enabled: bool,
|
||||
separator: WarpPromptSeparator,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
I: IntoIterator<Item = ContextChipKind>,
|
||||
{
|
||||
let config = PromptConfiguration::from_chips(chips, same_line_prompt_enabled, separator);
|
||||
// Eagerly set the new config - it will be re-updated when the settings change propagates.
|
||||
self.config = config.clone();
|
||||
SessionSettings::handle(ctx).update(ctx, |session_settings, ctx| {
|
||||
session_settings.honor_ps1.set_value(false, ctx)?;
|
||||
session_settings
|
||||
.saved_prompt
|
||||
.set_value(PromptSelection::CustomChipSelection(config), ctx)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Reset to the default Warp prompt.
|
||||
pub fn reset<C: UpdateModel + GetSingletonModelHandle>(
|
||||
&mut self,
|
||||
ctx: &mut C,
|
||||
) -> anyhow::Result<()> {
|
||||
// Note that because settings are being updated, `handle_session_settings_change` will be called
|
||||
// and will set the new prompt value
|
||||
let session_settings = SessionSettings::handle(ctx);
|
||||
session_settings.update(ctx, |session_settings, ctx| {
|
||||
session_settings
|
||||
.saved_prompt
|
||||
.set_value(PromptSelection::Default, ctx)?;
|
||||
session_settings.honor_ps1.set_value(false, ctx)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the [`PromptConfiguration`] from current user settings.
|
||||
fn from_user_settings(ctx: &mut ModelContext<Self>) -> PromptConfiguration {
|
||||
let session_settings = SessionSettings::handle(ctx);
|
||||
let settings = session_settings.as_ref(ctx);
|
||||
match settings.saved_prompt.clone() {
|
||||
PromptSelection::Default => {
|
||||
let suppress_pr = settings.github_pr_chip_default_validation.is_suppressed();
|
||||
PromptConfiguration::default_prompt_with_pr_chip_suppressed(suppress_pr)
|
||||
}
|
||||
PromptSelection::CustomChipSelection(config) => config.normalize_custom_prompt_config(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock an empty prompt.
|
||||
#[cfg(test)]
|
||||
pub fn mock() -> Self {
|
||||
Self {
|
||||
config: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock a prompt with the given chips.
|
||||
#[cfg(test)]
|
||||
pub fn mock_with(
|
||||
chips: impl IntoIterator<Item = ContextChipKind>,
|
||||
same_line_prompt_enabled: bool,
|
||||
separator: WarpPromptSeparator,
|
||||
) -> Self {
|
||||
Self {
|
||||
config: PromptConfiguration::from_chips(chips, same_line_prompt_enabled, separator),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wehther same line prompt is enabled for Warp prompt.
|
||||
pub fn same_line_prompt_enabled(&self) -> bool {
|
||||
self.config.same_line_prompt_enabled
|
||||
}
|
||||
|
||||
/// The separator to be used for the Warp prompt.
|
||||
pub fn separator(&self) -> WarpPromptSeparator {
|
||||
self.config.separator
|
||||
}
|
||||
|
||||
/// The chips included in the prompt, in order from left to right.
|
||||
pub fn chip_kinds(&self) -> Vec<ContextChipKind> {
|
||||
self.config.chip_kinds()
|
||||
}
|
||||
|
||||
/// Updates the in-memory prompt configuration to reflect a settings change.
|
||||
fn handle_session_settings_change(
|
||||
&mut self,
|
||||
event: &SessionSettingsChangedEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if matches!(
|
||||
event,
|
||||
SessionSettingsChangedEvent::SavedPrompt { .. }
|
||||
| SessionSettingsChangedEvent::GithubPrChipDefaultValidation { .. }
|
||||
) {
|
||||
log::debug!("Loading new prompt configuration");
|
||||
self.config = Self::from_user_settings(ctx);
|
||||
ctx.emit(PromptEvent::Changed);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_input_settings_change(
|
||||
&mut self,
|
||||
event: &InputSettingsChangedEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if matches!(event, InputSettingsChangedEvent::InputBoxTypeSetting { .. }) {
|
||||
self.config = Self::from_user_settings(ctx);
|
||||
ctx.emit(PromptEvent::Changed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the in-memory prompt configuration to reflect an AI settings change.
|
||||
fn handle_ai_settings_change(
|
||||
&mut self,
|
||||
event: &AISettingsChangedEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let AISettingsChangedEvent::IsAnyAIEnabled { .. } = event {
|
||||
log::debug!("Loading new prompt configuration");
|
||||
self.config = Self::from_user_settings(ctx);
|
||||
ctx.emit(PromptEvent::Changed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the in-memory prompt configuration to reflect an AI input model change.
|
||||
fn handle_ai_input_model_change(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
log::debug!("Loading new prompt configuration due to AI input model change");
|
||||
self.config = Self::from_user_settings(ctx);
|
||||
ctx.emit(PromptEvent::Changed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Prompt {
|
||||
type Event = PromptEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for Prompt {}
|
||||
|
||||
impl PromptConfiguration {
|
||||
/// The default Warp prompt, synthesized from legacy prompt settings.
|
||||
/// The order of chips is important and would affect a lot of users if rearranged.
|
||||
pub fn default_prompt() -> Self {
|
||||
Self::default_prompt_with_pr_chip_suppressed(false)
|
||||
}
|
||||
|
||||
pub fn default_prompt_with_pr_chip_suppressed(suppress_pr_chip: bool) -> Self {
|
||||
use crate::features::FeatureFlag;
|
||||
|
||||
let mut chips = vec![
|
||||
ContextChipKind::CondaEnvironment,
|
||||
ContextChipKind::VirtualEnvironment,
|
||||
ContextChipKind::Ssh,
|
||||
ContextChipKind::Subshell,
|
||||
ContextChipKind::NodeVersion,
|
||||
ContextChipKind::WorkingDirectory,
|
||||
ContextChipKind::ShellGitBranch,
|
||||
ContextChipKind::GitDiffStats,
|
||||
ContextChipKind::KubernetesContext,
|
||||
];
|
||||
if FeatureFlag::GithubPrPromptChip.is_enabled() && !suppress_pr_chip {
|
||||
chips.push(ContextChipKind::GithubPullRequest);
|
||||
}
|
||||
|
||||
Self::from_chips(chips, false, WarpPromptSeparator::None)
|
||||
}
|
||||
|
||||
pub fn from_chips(
|
||||
chips: impl IntoIterator<Item = ContextChipKind>,
|
||||
same_line_prompt_enabled: bool,
|
||||
separator: WarpPromptSeparator,
|
||||
) -> Self {
|
||||
Self {
|
||||
chips: chips
|
||||
.into_iter()
|
||||
.map(|chip| PromptChip::new(chip, Default::default()))
|
||||
.collect(),
|
||||
did_separate_git_diff_stats: true,
|
||||
same_line_prompt_enabled,
|
||||
separator,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chip_kinds(&self) -> Vec<ContextChipKind> {
|
||||
self.chips
|
||||
.iter()
|
||||
.map(|chip| chip.chip.clone())
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
/// Normalizes custom prompt configs after deserialization.
|
||||
///
|
||||
/// `ShellGitBranch` originally rendered both the branch selector and git diff stats.
|
||||
/// To preserve the previous behavior, we insert `GitDiffStats` immediately after `ShellGitBranch`.
|
||||
///
|
||||
/// This is gated by `did_separate_git_diff_stats` so we do not re-insert `GitDiffStats` after a
|
||||
/// user intentionally removes it and saves their custom prompt.
|
||||
fn normalize_custom_prompt_config(mut self) -> Self {
|
||||
if !self.did_separate_git_diff_stats {
|
||||
let already_has_git_diff_stats = self
|
||||
.chips
|
||||
.iter()
|
||||
.any(|chip| chip.chip == ContextChipKind::GitDiffStats);
|
||||
if !already_has_git_diff_stats {
|
||||
let shell_git_branch_index = self
|
||||
.chips
|
||||
.iter()
|
||||
.position(|chip| chip.chip == ContextChipKind::ShellGitBranch);
|
||||
if let Some(index) = shell_git_branch_index {
|
||||
self.chips.insert(
|
||||
index + 1,
|
||||
PromptChip::new(ContextChipKind::GitDiffStats, Default::default()),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.did_separate_git_diff_stats = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn same_line_prompt_enabled(&self) -> bool {
|
||||
self.same_line_prompt_enabled
|
||||
}
|
||||
|
||||
pub fn separator(&self) -> WarpPromptSeparator {
|
||||
self.separator
|
||||
}
|
||||
|
||||
pub fn remove_chip(&mut self, chip: ContextChipKind) {
|
||||
self.chips.retain(|c| c.chip != chip);
|
||||
}
|
||||
|
||||
pub fn add_chip_to_end(&mut self, chip: ContextChipKind) {
|
||||
self.chips.push(PromptChip::new(chip, Default::default()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::context_chips::ContextChipKind;
|
||||
|
||||
use super::current_prompt::CurrentPrompt;
|
||||
use super::prompt::Prompt;
|
||||
use super::{chips_to_string, ChipResult, ChipValue};
|
||||
use crate::settings::WarpPromptSeparator;
|
||||
|
||||
/// Struct that holds a point in time snapshot of a prompt (chips are no longer interactive)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PromptSnapshot {
|
||||
chips: Vec<ChipResult>,
|
||||
|
||||
same_line_prompt_enabled: bool,
|
||||
/// The separator to use as a trailing character at the end of Warp prompt, if any.
|
||||
separator: WarpPromptSeparator,
|
||||
}
|
||||
|
||||
impl PromptSnapshot {
|
||||
pub fn from_current_prompt(current_prompt: &CurrentPrompt, ctx: &AppContext) -> Self {
|
||||
let prompt = Prompt::as_ref(ctx);
|
||||
let current_prompt_snapshot = current_prompt.snapshot();
|
||||
let current_prompt_on_click_snapshot = current_prompt.on_click_snapshot();
|
||||
|
||||
// Get base chip kinds from prompt configuration
|
||||
let all_chip_kinds = prompt.chip_kinds();
|
||||
|
||||
// Re-sort current prompt snapshot so that it matches the order of elements in prompt
|
||||
let chips = all_chip_kinds
|
||||
.iter()
|
||||
.map(|chip_kind| {
|
||||
let value = current_prompt_snapshot
|
||||
.get(chip_kind)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let on_click_values = current_prompt_on_click_snapshot
|
||||
.get(chip_kind)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
ChipResult {
|
||||
kind: chip_kind.clone(),
|
||||
value,
|
||||
on_click_values,
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
log::debug!("Current prompt snapshot: {chips:?}");
|
||||
Self {
|
||||
chips,
|
||||
same_line_prompt_enabled: current_prompt.same_line_prompt_enabled(),
|
||||
separator: current_prompt.separator(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_chips(
|
||||
chips: Vec<ChipResult>,
|
||||
same_line_prompt_enabled: bool,
|
||||
separator: WarpPromptSeparator,
|
||||
) -> Self {
|
||||
Self {
|
||||
chips,
|
||||
same_line_prompt_enabled,
|
||||
separator,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of the given chip, in this snapshot.
|
||||
pub fn chip_value(&self, chip: &ContextChipKind) -> Option<ChipValue> {
|
||||
self.chips.iter().find_map(|chip_result| {
|
||||
if chip_result.kind == *chip {
|
||||
chip_result.value.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn chips(&self) -> &Vec<ChipResult> {
|
||||
&self.chips
|
||||
}
|
||||
|
||||
pub(super) fn same_line_prompt_enabled(&self) -> bool {
|
||||
self.same_line_prompt_enabled
|
||||
}
|
||||
|
||||
pub(super) fn separator(&self) -> WarpPromptSeparator {
|
||||
self.separator
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PromptSnapshot {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&chips_to_string(self.chips.clone().into_iter()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use serde_json::Value;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use super::Prompt;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::settings::WarpPromptSeparator;
|
||||
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);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
}
|
||||
|
||||
#[test]
|
||||
// Legacy prompt configs do not have git diff stats, so it should be added after normalization.
|
||||
// `did_separate_git_diff_stats` is set to `false`.
|
||||
fn test_prompt_config_adds_git_diff_stats_for_legacy_config() {
|
||||
let config = PromptConfiguration::from_chips(
|
||||
[
|
||||
ContextChipKind::WorkingDirectory,
|
||||
ContextChipKind::ShellGitBranch,
|
||||
],
|
||||
false,
|
||||
WarpPromptSeparator::None,
|
||||
);
|
||||
let mut serialized = serde_json::to_value(config).expect("serialize prompt config");
|
||||
|
||||
let Value::Object(ref mut map) = serialized else {
|
||||
panic!("expected object");
|
||||
};
|
||||
map.remove("did_separate_git_diff_stats");
|
||||
|
||||
let legacy_config: PromptConfiguration =
|
||||
serde_json::from_value(serialized).expect("deserialize legacy config");
|
||||
let normalized = legacy_config.normalize_custom_prompt_config();
|
||||
|
||||
assert_eq!(
|
||||
normalized.chip_kinds(),
|
||||
vec![
|
||||
ContextChipKind::WorkingDirectory,
|
||||
ContextChipKind::ShellGitBranch,
|
||||
ContextChipKind::GitDiffStats,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
// Ensure that prompt configs don't re-insert git diff stats if they were explicitly removed.
|
||||
// `did_separate_git_diff_stats` is set to `true`.
|
||||
fn test_prompt_config_after_nomalization() {
|
||||
let config = PromptConfiguration::from_chips(
|
||||
[ContextChipKind::ShellGitBranch],
|
||||
false,
|
||||
WarpPromptSeparator::None,
|
||||
);
|
||||
let normalized = config.normalize_custom_prompt_config();
|
||||
|
||||
assert_eq!(
|
||||
normalized.chip_kinds(),
|
||||
vec![ContextChipKind::ShellGitBranch]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_settings() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let session_settings = SessionSettings::handle(&app);
|
||||
let default_prompt = PromptConfiguration::default_prompt();
|
||||
|
||||
let prompt = app.add_singleton_model(Prompt::new);
|
||||
|
||||
// First, the default prompt should be set.
|
||||
let current_prompt_chips = prompt.read(&app, |prompt, _| prompt.chip_kinds());
|
||||
assert_eq!(current_prompt_chips, default_prompt.chip_kinds());
|
||||
session_settings.read(&app, |settings, _| {
|
||||
assert_eq!(settings.saved_prompt.to_owned(), PromptSelection::Default)
|
||||
});
|
||||
|
||||
// Now, set a new prompt.
|
||||
let new_chips = [ContextChipKind::Ssh, ContextChipKind::WorkingDirectory];
|
||||
prompt.update(&mut app, |prompt, ctx| {
|
||||
prompt
|
||||
.update(new_chips.clone(), false, WarpPromptSeparator::None, ctx)
|
||||
.expect("Saving prompt failed")
|
||||
});
|
||||
|
||||
// The configuration should be updated both in-memory and in settings.
|
||||
let new_prompt_chips = prompt.read(&app, |prompt, _| prompt.chip_kinds());
|
||||
assert_eq!(
|
||||
new_prompt_chips,
|
||||
vec![ContextChipKind::Ssh, ContextChipKind::WorkingDirectory]
|
||||
);
|
||||
session_settings.read(&app, |settings, _| {
|
||||
assert_eq!(
|
||||
settings.saved_prompt.to_owned(),
|
||||
PromptConfiguration::from_chips(new_chips, false, WarpPromptSeparator::None).into()
|
||||
);
|
||||
});
|
||||
|
||||
// If we reset the prompt, settings are cleared.
|
||||
prompt.update(&mut app, |prompt, ctx| {
|
||||
prompt.reset(ctx).expect("Saving prompt failed");
|
||||
});
|
||||
let reset_prompt_chips = prompt.read(&app, |prompt, _| prompt.chip_kinds());
|
||||
assert_eq!(reset_prompt_chips, default_prompt.chip_kinds());
|
||||
session_settings.read(&app, |settings, _| {
|
||||
assert_eq!(settings.saved_prompt.to_owned(), PromptSelection::Default);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use warpui::{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,
|
||||
};
|
||||
|
||||
/// The type of warp prompt being used
|
||||
#[derive(Clone)]
|
||||
pub enum PromptType {
|
||||
/// A warp prompt that refreshes chip values on its own. Typical for local sessions.
|
||||
Dynamic { prompt: ModelHandle<CurrentPrompt> },
|
||||
/// A warp prompt that does not change unless explicitly overwritten. Used for viewers of shared sessions.
|
||||
Static { snapshot: PromptSnapshot },
|
||||
}
|
||||
|
||||
impl PromptType {
|
||||
pub fn new_dynamic_from_sessions(
|
||||
sessions: ModelHandle<Sessions>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let current_prompt = ctx.add_model(|ctx| CurrentPrompt::new(sessions, ctx));
|
||||
Self::new_dynamic(current_prompt, ctx)
|
||||
}
|
||||
|
||||
pub fn new_dynamic(
|
||||
current_prompt: ModelHandle<CurrentPrompt>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.observe(¤t_prompt, |_, _, ctx| ctx.notify());
|
||||
Self::Dynamic {
|
||||
prompt: current_prompt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_static(
|
||||
chips: Vec<ChipResult>,
|
||||
same_line_prompt_enabled: bool,
|
||||
separator: WarpPromptSeparator,
|
||||
) -> Self {
|
||||
PromptType::Static {
|
||||
snapshot: PromptSnapshot::from_chips(chips, same_line_prompt_enabled, separator),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns menu items for copying parts of the prompt given a prompt snapshot.
|
||||
pub fn copy_menu_items(
|
||||
&self,
|
||||
position: PromptPosition,
|
||||
ctx: &AppContext,
|
||||
) -> Vec<MenuItem<TerminalAction>> {
|
||||
self.chips(ctx)
|
||||
.into_iter()
|
||||
.filter_map(|chip_result| {
|
||||
if chip_result.value.is_some() && chip_result.kind.is_copyable() {
|
||||
if let Some(chip) = chip_result.kind.to_chip() {
|
||||
Some(
|
||||
MenuItemFields::new(format!("Copy {}", chip.title()))
|
||||
.with_on_select_action(TerminalAction::ContextMenu(
|
||||
ContextMenuAction::CopyPrompt {
|
||||
position,
|
||||
part: PromptPart::ContextChip(chip_result.kind),
|
||||
},
|
||||
))
|
||||
.into_item(),
|
||||
)
|
||||
} else {
|
||||
log::error!("Missing definition for chip: {:?}", chip_result.kind);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn latest_chip_value(
|
||||
&self,
|
||||
chip_kind: &ContextChipKind,
|
||||
ctx: &AppContext,
|
||||
) -> Option<ChipValue> {
|
||||
match self {
|
||||
Self::Dynamic { prompt } => prompt.as_ref(ctx).latest_chip_value(chip_kind).cloned(),
|
||||
Self::Static { snapshot } => snapshot.chip_value(chip_kind),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt_as_string(&self, ctx: &AppContext) -> String {
|
||||
match self {
|
||||
Self::Dynamic { prompt } => prompt.as_ref(ctx).prompt_as_string(ctx),
|
||||
Self::Static { snapshot } => snapshot.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self, ctx: &AppContext) -> PromptSnapshot {
|
||||
match self {
|
||||
Self::Dynamic { prompt } => {
|
||||
PromptSnapshot::from_current_prompt(prompt.as_ref(ctx), ctx)
|
||||
}
|
||||
Self::Static { snapshot } => snapshot.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
|
||||
self.snapshot(ctx).chips().clone()
|
||||
}
|
||||
|
||||
pub fn agent_view_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
|
||||
let chip_kinds = SessionSettings::as_ref(ctx)
|
||||
.agent_footer_chip_selection
|
||||
.all_chips();
|
||||
self.resolve_chip_kinds(chip_kinds, ctx)
|
||||
}
|
||||
|
||||
pub fn agent_view_left_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
|
||||
let chip_kinds = SessionSettings::as_ref(ctx)
|
||||
.agent_footer_chip_selection
|
||||
.left_chips();
|
||||
self.resolve_chip_kinds(chip_kinds, ctx)
|
||||
}
|
||||
|
||||
pub fn agent_view_right_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
|
||||
let chip_kinds = SessionSettings::as_ref(ctx)
|
||||
.agent_footer_chip_selection
|
||||
.right_chips();
|
||||
self.resolve_chip_kinds(chip_kinds, ctx)
|
||||
}
|
||||
|
||||
pub fn cli_agent_chips(&self, ctx: &AppContext) -> Vec<ChipResult> {
|
||||
let chip_kinds = SessionSettings::as_ref(ctx)
|
||||
.cli_agent_footer_chip_selection
|
||||
.all_chips();
|
||||
self.resolve_chip_kinds(chip_kinds, ctx)
|
||||
}
|
||||
|
||||
fn resolve_chip_kinds(
|
||||
&self,
|
||||
chip_kinds: Vec<ContextChipKind>,
|
||||
ctx: &AppContext,
|
||||
) -> Vec<ChipResult> {
|
||||
chip_kinds
|
||||
.into_iter()
|
||||
.filter_map(|chip_kind| match self {
|
||||
Self::Dynamic { prompt } => prompt.as_ref(ctx).latest_chip_result(&chip_kind),
|
||||
Self::Static { snapshot } => snapshot
|
||||
.chips()
|
||||
.iter()
|
||||
.find(|chip_result| chip_result.kind() == &chip_kind)
|
||||
.cloned(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether same line prompt is enabled for the Warp Prompt.
|
||||
pub fn same_line_prompt_enabled(&self, ctx: &AppContext) -> bool {
|
||||
match self {
|
||||
Self::Dynamic { prompt } => prompt.as_ref(ctx).same_line_prompt_enabled(),
|
||||
Self::Static { snapshot } => snapshot.same_line_prompt_enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The separator for the Warp prompt.
|
||||
pub fn separator(&self, ctx: &AppContext) -> WarpPromptSeparator {
|
||||
match self {
|
||||
Self::Dynamic { prompt } => prompt.as_ref(ctx).separator(),
|
||||
Self::Static { snapshot } => snapshot.separator(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for PromptType {
|
||||
type Event = ();
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! The renderer for a single context chip.
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
ConstrainedBox, DraggableState, Hoverable, MouseStateHandle, OffsetPositioning, ParentElement,
|
||||
ParentOffsetBounds, Stack,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::Action;
|
||||
use warpui::{
|
||||
elements::{Container, CrossAxisAlignment, Flex, Text},
|
||||
Element,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::icons;
|
||||
|
||||
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;
|
||||
|
||||
/// Styling consts.
|
||||
const CORNER_RADIUS_PIXELS: f32 = 4.;
|
||||
const ICON_MARGIN_RIGHT: f32 = 6.;
|
||||
const LABEL_MARGIN_BOTTOM: f32 = 6.;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RendererStyles {
|
||||
pub value_color: ColorU,
|
||||
pub font_properties: Properties,
|
||||
}
|
||||
|
||||
impl RendererStyles {
|
||||
pub fn new(value_color: ColorU, font_properties: Properties) -> Self {
|
||||
Self {
|
||||
value_color,
|
||||
font_properties,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum ChipDragState {
|
||||
Draggable { is_dragging: bool },
|
||||
Undraggable,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// State for rendering a single context chip.
|
||||
pub struct Renderer {
|
||||
kind: ContextChipKind,
|
||||
chip: ContextChip,
|
||||
value: ChipValue,
|
||||
styles: RendererStyles,
|
||||
draggable_state: DraggableState,
|
||||
tooltip_state_handle: MouseStateHandle,
|
||||
remove_button_state_handle: MouseStateHandle,
|
||||
is_disabled: bool,
|
||||
tooltip_override_text: Option<String>,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn new(
|
||||
kind: ContextChipKind,
|
||||
chip: ContextChip,
|
||||
value: ChipValue,
|
||||
styles: RendererStyles,
|
||||
availability: ChipAvailability,
|
||||
) -> Self {
|
||||
let is_disabled = !availability.is_enabled();
|
||||
let tooltip_override_text = availability.tooltip_override_text();
|
||||
Self {
|
||||
kind,
|
||||
chip,
|
||||
value,
|
||||
styles,
|
||||
draggable_state: Default::default(),
|
||||
tooltip_state_handle: Default::default(),
|
||||
remove_button_state_handle: Default::default(),
|
||||
is_disabled,
|
||||
tooltip_override_text,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_from_kind(
|
||||
chip_kind: ContextChipKind,
|
||||
availability: ChipAvailability,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Self> {
|
||||
Self::default_from_kind_with_agent_view(chip_kind, availability, false, appearance)
|
||||
}
|
||||
|
||||
pub fn default_from_kind_with_agent_view(
|
||||
chip_kind: ContextChipKind,
|
||||
availability: ChipAvailability,
|
||||
is_in_agent_view: bool,
|
||||
appearance: &Appearance,
|
||||
) -> Option<Self> {
|
||||
let chip = chip_kind.to_chip()?;
|
||||
let placeholder_value = chip_kind.placeholder_value();
|
||||
let styles = chip_kind.default_styles(appearance, is_in_agent_view);
|
||||
Some(Self::new(
|
||||
chip_kind,
|
||||
chip,
|
||||
placeholder_value,
|
||||
styles,
|
||||
availability,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn draggable_state(&self) -> DraggableState {
|
||||
self.draggable_state.clone()
|
||||
}
|
||||
|
||||
pub fn chip_kind(&self) -> &ContextChipKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn is_disabled(&self) -> bool {
|
||||
self.is_disabled
|
||||
}
|
||||
|
||||
fn render_remove_button<A: Action + Clone>(
|
||||
&self,
|
||||
action: A,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let icon_size = appearance.monospace_font_size();
|
||||
let button = Hoverable::new(self.remove_button_state_handle.clone(), |_| {
|
||||
ConstrainedBox::new(
|
||||
icons::Icon::X
|
||||
.to_warpui_icon(appearance.theme().ui_error_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.with_height(icon_size)
|
||||
.with_width(icon_size)
|
||||
.finish()
|
||||
});
|
||||
button
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_internal(
|
||||
&self,
|
||||
drag_state: ChipDragState,
|
||||
remove_button: Option<Box<dyn Element>>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let mut color = self.styles.value_color;
|
||||
if self.is_disabled {
|
||||
color.a = (color.a / 2).max(48);
|
||||
}
|
||||
let font_size = udi_font_size(appearance);
|
||||
|
||||
let mut content = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
|
||||
if let Some(icon) = self.kind.udi_icon() {
|
||||
content.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon.to_warpui_icon(Fill::Solid(color)).finish())
|
||||
.with_height(font_size)
|
||||
.with_width(font_size)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(spacing::UDI_CHIP_ICON_GAP)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let text = Text::new_inline(
|
||||
self.value.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(color)
|
||||
.with_line_height_ratio(appearance.line_height_ratio())
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish();
|
||||
content.add_child(text);
|
||||
|
||||
if let Some(remove_button) = remove_button {
|
||||
content.add_child(
|
||||
Container::new(remove_button)
|
||||
.with_margin_left(spacing::UDI_CHIP_ICON_GAP)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let container = chip_container(content.finish(), None, appearance);
|
||||
|
||||
let mut hoverable = Hoverable::new(self.tooltip_state_handle.clone(), |mouse_state| {
|
||||
if !mouse_state.is_hovered()
|
||||
|| matches!(drag_state, ChipDragState::Draggable { is_dragging: true })
|
||||
{
|
||||
return container.finish();
|
||||
}
|
||||
|
||||
let tooltip = appearance.ui_builder().tool_tip(
|
||||
self.tooltip_override_text
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.chip.title().to_string()),
|
||||
);
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(container.finish());
|
||||
stack.add_positioned_overlay_child(
|
||||
tooltip.build().finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., -2.5 * font_size),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
warpui::elements::ParentAnchor::Center,
|
||||
warpui::elements::ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
stack.finish()
|
||||
});
|
||||
|
||||
if matches!(drag_state, ChipDragState::Draggable { .. }) && !self.is_disabled {
|
||||
hoverable = hoverable.with_cursor(Cursor::OpenHand);
|
||||
}
|
||||
|
||||
hoverable.finish()
|
||||
}
|
||||
|
||||
pub fn render_unused(
|
||||
&self,
|
||||
drag_state: ChipDragState,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
self.render_internal(drag_state, None, appearance)
|
||||
}
|
||||
|
||||
pub fn render_used<A: Action + Clone>(
|
||||
&self,
|
||||
drag_state: ChipDragState,
|
||||
on_remove_action: A,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let remove_button =
|
||||
(!self.is_disabled).then(|| self.render_remove_button(on_remove_action, appearance));
|
||||
self.render_internal(drag_state, remove_button, appearance)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "renderer_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,59 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use warpui::fonts::Properties;
|
||||
|
||||
use crate::context_chips::{ChipAvailability, ChipDisabledReason, ContextChipKind};
|
||||
|
||||
use super::{Renderer, RendererStyles};
|
||||
|
||||
#[test]
|
||||
fn test_constructor_availability_updates_disabled_state_and_tooltip_override() {
|
||||
let kind = ContextChipKind::ShellGitBranch;
|
||||
let chip = kind.to_chip().expect("chip definition should exist");
|
||||
let renderer = Renderer::new(
|
||||
kind,
|
||||
chip,
|
||||
crate::context_chips::ChipValue::Text("main".to_string()),
|
||||
RendererStyles::new(
|
||||
ColorU {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: 255,
|
||||
},
|
||||
Properties::default(),
|
||||
),
|
||||
ChipAvailability::Disabled(ChipDisabledReason::RequiresExecutable {
|
||||
command: "gh".to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(renderer.is_disabled);
|
||||
assert_eq!(
|
||||
renderer.tooltip_override_text.as_deref(),
|
||||
Some("Requires the GitHub CLI")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constructor_availability_enabled_has_no_disabled_state_or_tooltip_override() {
|
||||
let kind = ContextChipKind::ShellGitBranch;
|
||||
let chip = kind.to_chip().expect("chip definition should exist");
|
||||
let renderer = Renderer::new(
|
||||
kind,
|
||||
chip,
|
||||
crate::context_chips::ChipValue::Text("main".to_string()),
|
||||
RendererStyles::new(
|
||||
ColorU {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: 255,
|
||||
},
|
||||
Properties::default(),
|
||||
),
|
||||
ChipAvailability::Enabled,
|
||||
);
|
||||
|
||||
assert!(!renderer.is_disabled);
|
||||
assert_eq!(renderer.tooltip_override_text, None);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -0,0 +1,20 @@
|
||||
// Shared spacing constants for context chips and related UDI elements.
|
||||
// Centralizing these values keeps spacing consistent across chips and makes
|
||||
// it easy to adjust the overall rhythm in one place.
|
||||
|
||||
/// Vertical padding inside of a chip
|
||||
pub const UDI_CHIP_VERTICAL_PADDING: f32 = 2.0;
|
||||
/// Horizontal padding inside of a chip
|
||||
pub const UDI_CHIP_HORIZONTAL_PADDING: f32 = 4.0;
|
||||
/// Space between icon and label inside a chip
|
||||
pub const UDI_CHIP_ICON_GAP: f32 = 4.0;
|
||||
/// Consistent margins surrounding all chips
|
||||
pub const UDI_CHIP_MARGIN: f32 = 8.0;
|
||||
/// Spacing between rows when chips wrap
|
||||
pub const UDI_ROW_RUN_SPACING: f32 = 8.0;
|
||||
/// Top padding factor for universal developer input prompt - less top padding
|
||||
pub const UDI_PROMPT_TOP_PADDING_FACTOR: f32 = 0.6;
|
||||
/// Bottom padding factor for universal developer input prompt - more bottom padding
|
||||
pub const UDI_PROMPT_BOTTOM_PADDING_FACTOR: f32 = 1.5;
|
||||
/// Bottom padding for classic prompt attach images
|
||||
pub const CLASSIC_PROMPT_ATTACH_IMAGES_BOTTOM_PADDING: f32 = 10.;
|
||||
Reference in New Issue
Block a user