use crate::app::Message;
use crate::theme;
use bombadil_core::model::TerminalChoice;
use bombadil_core::terminal::{ActivationSupport, TerminalKind};
use std::path::{Path, PathBuf};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TerminalOption {
Detected(TerminalKind),
Custom,
}
pub fn ordered(
mut available: Vec<TerminalKind>,
default: Option<&TerminalChoice>,
) -> Vec<TerminalKind> {
if let Some(TerminalChoice::Detected(default_kind)) = default
&& let Some(pos) = available.iter().position(|k| k == default_kind)
{
let kind = available.remove(pos);
available.insert(0, kind);
}
available
}
pub fn submenu(
available: Vec<TerminalKind>,
default: Option<&TerminalChoice>,
) -> Vec<TerminalOption> {
let mut entries: Vec<TerminalOption> = ordered(available, default)
.into_iter()
.map(TerminalOption::Detected)
.collect();
entries.push(TerminalOption::Custom);
entries
}
pub fn activation_warning(kind: TerminalKind) -> &'static str {
match kind.activation_support() {
ActivationSupport::Full => "",
ActivationSupport::PathOnly => {
" (environment on PATH, not re-asserted after your shell config)"
}
ActivationSupport::None => " (not activated -- no environment reaches this shell)",
}
}
pub fn terminal_name(kind: TerminalKind) -> &'static str {
use TerminalKind::*;
match kind {
WindowsTerminal => "Windows Terminal",
PowerShell => "PowerShell",
Cmd => "Command Prompt",
GitBash => "Git Bash",
Wsl => "WSL",
TerminalApp => "Terminal.app",
ITerm2 => "iTerm2",
GnomeTerminal => "GNOME Terminal",
Konsole => "Konsole",
Kitty => "kitty",
Alacritty => "Alacritty",
WezTerm => "WezTerm",
Foot => "foot",
Xterm => "xterm",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenPlan {
Open(TerminalChoice),
Detect,
Nothing,
}
pub fn one_press_plan(
default: Option<&TerminalChoice>,
detected: Option<&[TerminalKind]>,
) -> OpenPlan {
if let Some(choice) = default {
return OpenPlan::Open(choice.clone());
}
match detected {
None => OpenPlan::Detect,
Some([]) => OpenPlan::Nothing,
Some(kinds) => OpenPlan::Open(TerminalChoice::Detected(kinds[0])),
}
}
pub struct ContextMenu {
pub project_index: usize,
pub terminals: Vec<TerminalKind>,
}
pub struct ConfirmRecreate {
pub project_id: Uuid,
pub label: String,
pub venv_path: PathBuf,
}
pub fn recreate_confirmation(label: &str, venv_path: &Path) -> String {
format!(
"Recreate the environment for {label}?\n\n\
{} will be deleted, then rebuilt with uv sync.\n\n\
If the rebuild fails -- no network, a lock file that no longer resolves -- \
the old environment has already been deleted and cannot be restored.",
venv_path.display()
)
}
pub struct ConfirmPinChange {
pub project_id: Uuid,
pub label: String,
pub venv_path: PathBuf,
pub from: String,
pub to: String,
}
pub fn pin_change_confirmation(label: &str, venv_path: &Path, from: &str, to: &str) -> String {
format!(
"{label} is pinned to Python {to}, but its environment is on {from}.\n\n\
Syncing will delete {} and build a new one on {to}. Anything installed \
in it that is not in the lock file will be lost.\n\n\
If the rebuild fails -- no network, a lock file that no longer resolves -- \
the old environment has already been deleted and cannot be restored.",
venv_path.display()
)
}
pub fn confirm_pin_view<'a>(confirm: &ConfirmPinChange) -> iced::Element<'a, Message> {
iced::widget::container(
iced::widget::column![
iced::widget::text(pin_change_confirmation(
&confirm.label,
&confirm.venv_path,
&confirm.from,
&confirm.to,
))
.size(theme::BODY),
iced::widget::row![
iced::widget::button(iced::widget::text("Delete and rebuild").size(theme::BODY))
.on_press(Message::PinChangeConfirmed)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_danger),
iced::widget::button(iced::widget::text("Cancel").size(theme::BODY))
.on_press(Message::PinChangeCancelled)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.spacing(theme::SPACE_2),
]
.spacing(theme::SPACE_3),
)
.padding(theme::SPACE_3)
.style(theme::panel)
.into()
}
pub struct ConfirmEnvironmentRemoval {
pub project_id: Uuid,
pub label: String,
pub location: bombadil_core::model::VenvLocation,
pub venv_path: PathBuf,
}
pub fn environment_removal_confirmation(label: &str, venv_path: &Path) -> String {
format!(
"Remove this environment from {label}?\n\n\
{} stays on disk -- Bombadil stops keeping an account of it, and will \
not sync it or open a terminal on it again. Delete the directory \
yourself if you want the space back.",
venv_path.display()
)
}
pub fn confirm_environment_removal_view<'a>(
confirm: &ConfirmEnvironmentRemoval,
) -> iced::Element<'a, Message> {
iced::widget::container(
iced::widget::column![
iced::widget::text(environment_removal_confirmation(
&confirm.label,
&confirm.venv_path,
))
.size(theme::BODY),
iced::widget::row![
iced::widget::button(iced::widget::text("Remove").size(theme::BODY))
.on_press(Message::EnvironmentRemovalConfirmed)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_danger),
iced::widget::button(iced::widget::text("Cancel").size(theme::BODY))
.on_press(Message::EnvironmentRemovalCancelled)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.spacing(theme::SPACE_2),
]
.spacing(theme::SPACE_3),
)
.padding(theme::SPACE_3)
.style(theme::panel)
.into()
}
pub fn confirm_view<'a>(confirm: &ConfirmRecreate) -> iced::Element<'a, Message> {
iced::widget::container(
iced::widget::column![
iced::widget::text(recreate_confirmation(&confirm.label, &confirm.venv_path))
.size(theme::BODY),
iced::widget::row![
iced::widget::button(iced::widget::text("Delete and recreate").size(theme::BODY))
.on_press(Message::RecreateVenvConfirmed)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_danger),
iced::widget::button(iced::widget::text("Cancel").size(theme::BODY))
.on_press(Message::RecreateVenvCancelled)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.spacing(theme::SPACE_2),
]
.spacing(theme::SPACE_3),
)
.padding(theme::SPACE_3)
.style(theme::panel)
.into()
}
pub fn view<'a>(
menu: &ContextMenu,
label: &str,
default: Option<&TerminalChoice>,
) -> iced::Element<'a, Message> {
let index = menu.project_index;
let mut column = iced::widget::column![
iced::widget::container(
iced::widget::text(label.to_string())
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::LABEL)
.color(theme::SLATE)
)
.padding([theme::SPACE_1, theme::SPACE_3]),
theme::hairline_row(),
section_heading("Open terminal"),
]
.spacing(0);
for option in submenu(menu.terminals.clone(), default) {
match option {
TerminalOption::Detected(kind) => {
column = column.push(entry(
terminal_name(kind),
Some(activation_warning(kind)),
Message::OpenTerminalRequested(index, TerminalChoice::Detected(kind)),
));
}
TerminalOption::Custom => {
if let Some(TerminalChoice::Custom { template }) = default {
column = column.push(entry(
"Custom",
None,
Message::OpenTerminalRequested(
index,
TerminalChoice::Custom {
template: template.clone(),
},
),
));
}
}
}
}
column
.push(theme::hairline_row())
.push(entry("Sync", None, Message::SyncRequested(index)))
.push(entry(
"Open folder",
None,
Message::OpenFolderRequested(index),
))
.push(theme::hairline_row())
.push(entry(
"Recreate venv",
None,
Message::RecreateVenvRequested(index),
))
.push(entry(
"Remove from list",
None,
Message::RemoveProjectRequested(index),
))
.into()
}
fn section_heading<'a>(text: &'static str) -> iced::Element<'a, Message> {
iced::widget::container(
iced::widget::text(text)
.size(theme::LABEL)
.color(theme::SLATE),
)
.padding([theme::SPACE_1, theme::SPACE_3])
.into()
}
fn entry<'a>(
label: &'a str,
detail: Option<&'a str>,
message: Message,
) -> iced::Element<'a, Message> {
let mut content = iced::widget::column![iced::widget::text(label).size(theme::BODY)];
if let Some(detail) = detail.filter(|d| !d.is_empty()) {
content = content.push(
iced::widget::text(detail.trim())
.size(theme::LABEL)
.color(theme::SLATE),
);
}
iced::widget::button(content)
.on_press(message)
.width(iced::Length::Fill)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_bare(theme::PARCHMENT))
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_configured_default_terminal_is_moved_to_the_front() {
let available = vec![
TerminalKind::GnomeTerminal,
TerminalKind::Kitty,
TerminalKind::Xterm,
];
let default = TerminalChoice::Detected(TerminalKind::Xterm);
let got = ordered(available, Some(&default));
assert_eq!(got.first(), Some(&TerminalKind::Xterm), "got {got:?}");
assert_eq!(
got.len(),
3,
"reordering must not drop or duplicate an entry; got {got:?}"
);
assert!(got.contains(&TerminalKind::GnomeTerminal));
assert!(got.contains(&TerminalKind::Kitty));
}
#[test]
fn no_configured_default_leaves_detection_order_untouched() {
let available = vec![TerminalKind::GnomeTerminal, TerminalKind::Kitty];
let got = ordered(available.clone(), None);
assert_eq!(got, available);
}
#[test]
fn a_default_that_was_not_detected_changes_nothing() {
let available = vec![TerminalKind::GnomeTerminal, TerminalKind::Kitty];
let default = TerminalChoice::Detected(TerminalKind::Xterm);
let got = ordered(available.clone(), Some(&default));
assert_eq!(got, available);
}
#[test]
fn a_custom_default_does_not_touch_the_detected_order() {
let available = vec![TerminalKind::GnomeTerminal, TerminalKind::Kitty];
let default = TerminalChoice::Custom {
template: "myterm --cd {cwd} -e {shell}".into(),
};
let got = ordered(available.clone(), Some(&default));
assert_eq!(got, available);
}
#[test]
fn the_submenu_always_carries_a_custom_entry_last() {
let available = vec![TerminalKind::Kitty];
let entries = submenu(available, None);
assert_eq!(
entries,
vec![
TerminalOption::Detected(TerminalKind::Kitty),
TerminalOption::Custom
]
);
}
#[test]
fn full_activation_kinds_carry_no_warning() {
assert_eq!(activation_warning(TerminalKind::Kitty), "");
assert_eq!(activation_warning(TerminalKind::WindowsTerminal), "");
}
#[test]
fn path_only_and_none_are_labelled_with_different_warnings() {
let path_only = activation_warning(TerminalKind::PowerShell);
let none = activation_warning(TerminalKind::TerminalApp);
assert_ne!(path_only, "", "PathOnly must carry SOME warning");
assert_ne!(none, "", "None must carry SOME warning");
assert_ne!(
path_only, none,
"PathOnly and None must read as different problems, not the same one"
);
}
#[test]
fn every_path_only_kind_shares_the_same_warning_text() {
let cases = [
TerminalKind::PowerShell,
TerminalKind::Cmd,
TerminalKind::GitBash,
];
let first = activation_warning(cases[0]);
for kind in cases {
assert_eq!(activation_warning(kind), first, "{kind:?}");
}
}
#[test]
fn every_none_kind_shares_the_same_warning_text() {
let cases = [
TerminalKind::TerminalApp,
TerminalKind::ITerm2,
TerminalKind::Wsl,
];
let first = activation_warning(cases[0]);
for kind in cases {
assert_eq!(activation_warning(kind), first, "{kind:?}");
}
}
#[test]
fn a_fully_activating_terminal_carries_no_warning_line() {
assert_eq!(activation_warning(TerminalKind::Kitty), "");
assert!(
activation_warning(TerminalKind::TerminalApp).contains("not activated"),
"got {:?}",
activation_warning(TerminalKind::TerminalApp)
);
}
#[test]
fn a_configured_default_opens_immediately_without_probing() {
let default = TerminalChoice::Detected(TerminalKind::Konsole);
assert_eq!(
one_press_plan(Some(&default), None),
OpenPlan::Open(TerminalChoice::Detected(TerminalKind::Konsole))
);
}
#[test]
fn a_configured_default_is_not_overruled_by_what_is_installed() {
let default = TerminalChoice::Detected(TerminalKind::Konsole);
assert_eq!(
one_press_plan(
Some(&default),
Some(&[TerminalKind::Kitty, TerminalKind::Xterm])
),
OpenPlan::Open(TerminalChoice::Detected(TerminalKind::Konsole))
);
}
#[test]
fn nothing_configured_means_look_before_opening() {
assert_eq!(one_press_plan(None, None), OpenPlan::Detect);
}
#[test]
fn with_nothing_configured_the_first_detected_is_used() {
assert_eq!(
one_press_plan(None, Some(&[TerminalKind::Kitty, TerminalKind::Xterm])),
OpenPlan::Open(TerminalChoice::Detected(TerminalKind::Kitty))
);
}
#[test]
fn having_looked_and_found_nothing_is_not_the_same_as_not_having_looked() {
assert_eq!(one_press_plan(None, Some(&[])), OpenPlan::Nothing);
assert_ne!(one_press_plan(None, Some(&[])), one_press_plan(None, None));
}
#[test]
fn the_removal_confirmation_says_the_directory_is_not_deleted() {
let text = environment_removal_confirmation("api", Path::new("/envs/api-311"));
assert!(text.contains("/envs/api-311"), "got {text}");
assert!(
text.contains("stays on disk"),
"the user must be told the directory survives; got {text}"
);
assert!(text.contains("api"), "got {text}");
}
#[test]
fn the_pin_change_confirmation_names_both_versions_and_the_directory() {
let text = pin_change_confirmation(
"api",
Path::new("/home/t/.venvs/api-1f2e3d"),
"3.13.14",
"3.12",
);
assert!(text.contains("/home/t/.venvs/api-1f2e3d"), "got {text}");
assert!(text.contains("3.13.14"), "got {text}");
assert!(text.contains("3.12"), "got {text}");
assert!(
text.contains("deleted"),
"the user must be told the environment is deleted, not rebuilt; got {text}"
);
assert!(
text.contains("lock file will be lost"),
"the cost the user cannot see -- anything not in the lock file -- \
has to be stated; got {text}"
);
}
#[test]
fn the_recreate_confirmation_names_the_exact_directory_that_will_be_deleted() {
let text = recreate_confirmation("api", Path::new("/home/t/.venvs/api-1f2e3d"));
assert!(
text.contains("/home/t/.venvs/api-1f2e3d"),
"the resolved venv path must be stated verbatim; got {text}"
);
assert!(text.contains("api"), "got {text}");
}
#[test]
fn the_recreate_confirmation_states_that_a_failed_rebuild_leaves_nothing() {
let text = recreate_confirmation("api", Path::new("/p/api/.venv"));
assert!(
text.contains("deleted"),
"the destructive half must be named; got {text}"
);
assert!(
text.to_lowercase().contains("cannot be restored"),
"a failed rebuild leaving nothing must be stated; got {text}"
);
}
}