feat: add fresh spawn profile picker

This commit is contained in:
2026-05-30 02:34:42 +09:00
parent ee7147b355
commit ba9e924c89
3 changed files with 282 additions and 57 deletions
+237 -54
View File
@@ -14,7 +14,7 @@
use std::ffi::OsString;
use std::io;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Duration;
use client::{SpawnConfig, spawn_pod};
@@ -96,22 +96,17 @@ pub async fn run(
profile: Option<String>,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let selected_profile = profile
.map(|selector| ProfileSelection {
label: selector.clone(),
selector,
is_default: false,
})
.or(defaults.default_profile);
let scope_origin = match selected_profile.as_ref() {
Some(profile) => ScopeOrigin::FromProfile(profile.label.clone()),
None => defaults.scope_origin,
};
let mut profile_choices = defaults.profile_choices;
let profile_index = initial_profile_index(
&mut profile_choices,
profile.as_deref(),
defaults.default_profile_index,
);
let mut form = Form {
cwd: defaults.cwd.clone(),
cascade_has_scope: defaults.cascade_has_scope,
scope_origin,
scope_origin: defaults.scope_origin,
name_cursor: defaults.default_name.chars().count(),
name: defaults.default_name,
message: None,
@@ -119,7 +114,8 @@ pub async fn run(
resume_from,
resume_by_pod_name: false,
resume_scope: None,
profile: selected_profile,
profile_choices,
profile_index,
};
let mut terminal = make_inline_terminal()?;
@@ -150,6 +146,8 @@ pub async fn run(
Some(Action::Right) => form.move_right(),
Some(Action::Home) => form.name_cursor = 0,
Some(Action::End) => form.name_cursor = form.name.chars().count(),
Some(Action::ProfileNext) => form.cycle_profile_next(),
Some(Action::ProfilePrev) => form.cycle_profile_prev(),
}
}
@@ -219,12 +217,13 @@ struct SpawnDefaults {
cascade_has_scope: bool,
scope_origin: ScopeOrigin,
default_name: String,
default_profile: Option<ProfileSelection>,
default_profile_index: usize,
profile_choices: Vec<ProfileChoice>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileSelection {
selector: String,
struct ProfileChoice {
selector: Option<String>,
label: String,
is_default: bool,
}
@@ -275,25 +274,80 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "pod".to_string());
let default_profile = default_profile_selection(&cwd);
let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd);
Ok(SpawnDefaults {
cwd,
cascade_has_scope,
scope_origin,
default_name,
default_profile,
default_profile_index,
profile_choices,
})
}
fn default_profile_selection(cwd: &std::path::Path) -> Option<ProfileSelection> {
let registry = ProfileDiscovery::for_cwd(cwd).discover().ok()?;
let entry = registry.default_entry().ok()?;
Some(ProfileSelection {
selector: entry.qualified_name(),
label: format!("{} (default)", entry.name),
is_default: true,
})
fn profile_choices_for_cwd(cwd: &Path) -> (Vec<ProfileChoice>, usize) {
let mut choices = vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}];
let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else {
return (choices, 0);
};
for entry in registry.entries() {
let mut label = entry.qualified_name();
if entry.is_default {
label.push_str(" (default)");
}
if let Some(description) = entry.description.as_deref() {
label.push_str("");
label.push_str(description);
}
choices.push(ProfileChoice {
selector: Some(entry.qualified_name()),
label,
is_default: entry.is_default,
});
}
let default_index = choices
.iter()
.position(|choice| choice.is_default)
.unwrap_or(0);
(choices, default_index)
}
fn initial_profile_index(
choices: &mut Vec<ProfileChoice>,
explicit_profile: Option<&str>,
default_index: usize,
) -> usize {
let Some(selector) = explicit_profile else {
return default_index.min(choices.len().saturating_sub(1));
};
if let Some(index) = choices
.iter()
.position(|choice| choice.selector.as_deref() == Some(selector))
{
return index;
}
choices.push(ProfileChoice {
selector: Some(selector.to_string()),
label: selector.to_string(),
is_default: false,
});
choices.len() - 1
}
fn default_profile_selection(cwd: &Path) -> Option<ProfileChoice> {
let (choices, default_index) = profile_choices_for_cwd(cwd);
choices
.get(default_index)
.filter(|choice| choice.selector.is_some())
.cloned()
}
fn user_manifest_path_for_spawn(
@@ -315,7 +369,12 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
resume_from: None,
resume_by_pod_name: true,
resume_scope: None,
profile: None,
profile_choices: vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}],
profile_index: 0,
}
}
@@ -339,6 +398,8 @@ enum Action {
Right,
Home,
End,
ProfileNext,
ProfilePrev,
}
fn poll_event() -> io::Result<Option<Action>> {
@@ -359,6 +420,8 @@ fn poll_event() -> io::Result<Option<Action>> {
KeyCode::Delete => Some(Action::Delete),
KeyCode::Left => Some(Action::Left),
KeyCode::Right => Some(Action::Right),
KeyCode::Up | KeyCode::BackTab => Some(Action::ProfilePrev),
KeyCode::Down | KeyCode::Tab => Some(Action::ProfileNext),
KeyCode::Home => Some(Action::Home),
KeyCode::End => Some(Action::End),
KeyCode::Char(c) if !ctrl && is_safe_name_char(c) => Some(Action::Char(c)),
@@ -389,10 +452,7 @@ async fn wait_for_ready(
let config = SpawnConfig {
pod_name: form.name.clone(),
profile: form
.profile
.as_ref()
.map(|profile| profile.selector.clone()),
profile: form.selected_profile_selector(),
overlay_toml: overlay_toml.to_string(),
cwd,
resume_from: form.resume_from,
@@ -469,7 +529,6 @@ enum ScopeOrigin {
FromUser,
FromProject,
CwdDefault,
FromProfile(String),
}
struct Form {
@@ -504,10 +563,11 @@ struct Form {
/// resume runs, and serialized into the overlay instead of cwd-default
/// scope so resume does not silently broaden access.
resume_scope: Option<ScopeConfig>,
/// Optional Nix profile passed to `insomnia-pod --profile` for fresh spawns.
/// This is not used for resume/attach flows because those must restore Pod
/// state rather than re-evaluate a profile source.
profile: Option<ProfileSelection>,
/// Optional Nix profile choices passed to `insomnia-pod --profile` for
/// fresh spawns. This is not used for resume/attach flows because those must
/// restore Pod state rather than re-evaluate a profile source.
profile_choices: Vec<ProfileChoice>,
profile_index: usize,
}
impl Form {
@@ -550,6 +610,37 @@ impl Form {
}
}
fn selected_profile(&self) -> Option<&ProfileChoice> {
self.profile_choices
.get(self.profile_index)
.filter(|choice| choice.selector.is_some())
}
fn selected_profile_selector(&self) -> Option<String> {
self.selected_profile()
.and_then(|choice| choice.selector.clone())
}
fn cycle_profile_next(&mut self) {
if self.profile_choices.is_empty() {
return;
}
self.profile_index = (self.profile_index + 1) % self.profile_choices.len();
self.message = None;
}
fn cycle_profile_prev(&mut self) {
if self.profile_choices.is_empty() {
return;
}
self.profile_index = if self.profile_index == 0 {
self.profile_choices.len() - 1
} else {
self.profile_index - 1
};
self.message = None;
}
fn char_offset_to_byte(&self, char_off: usize) -> usize {
self.name
.char_indices()
@@ -619,6 +710,18 @@ fn name_line(form: &Form) -> Line<'_> {
}
fn context_line(form: &Form) -> Line<'_> {
if let Some(profile) = form.profile_choices.get(form.profile_index) {
return Line::from(vec![
Span::raw(" "),
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
Span::styled(profile.label.as_str(), Style::default().fg(Color::Green)),
Span::styled(
" (tab/down to change)",
Style::default().fg(Color::DarkGray),
),
]);
}
match form.scope_origin {
ScopeOrigin::FromProject => Line::from(vec![
Span::raw(" "),
@@ -639,23 +742,14 @@ fn context_line(form: &Form) -> Line<'_> {
),
Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)),
]),
ScopeOrigin::FromProfile(ref label) => Line::from(vec![
Span::raw(" "),
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
Span::styled(label.as_str(), Style::default().fg(Color::Green)),
Span::styled(" (resolved by pod)", Style::default().fg(Color::DarkGray)),
]),
}
}
fn hint_line() -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled("[enter]", Style::default().fg(Color::Green)),
Span::raw(" spawn "),
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
Span::raw(" cancel"),
])
Line::from(vec![Span::styled(
" enter spawn · tab/down next profile · shift-tab/up prev · esc cancel",
Style::default().fg(Color::DarkGray),
)])
}
fn message_line(form: &Form) -> Line<'_> {
@@ -691,7 +785,12 @@ mod tests {
resume_from: None,
resume_by_pod_name: false,
resume_scope: None,
profile: None,
profile_choices: vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}],
profile_index: 0,
}
}
@@ -702,7 +801,12 @@ mod tests {
cascade_has_scope: true,
scope_origin: ScopeOrigin::FromProject,
default_name: "ignored".to_string(),
default_profile: None,
default_profile_index: 0,
profile_choices: vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}],
};
let f = form_for_pod_name("agent".to_string(), defaults);
@@ -821,11 +925,90 @@ coder = "profiles/coder.nix"
.unwrap();
let selected = default_profile_selection(&project).unwrap();
assert_eq!(selected.selector, "project:coder");
assert_eq!(selected.label, "coder (default)");
assert_eq!(selected.selector.as_deref(), Some("project:coder"));
assert_eq!(selected.label, "project:coder (default)");
assert!(selected.is_default);
}
#[test]
fn profile_choices_include_no_profile_source_labels_and_default_marker() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
let insomnia = project.join(".insomnia");
std::fs::create_dir_all(&insomnia).unwrap();
std::fs::write(
insomnia.join("manifest.toml"),
r#"
[profiles]
default = "coder"
[profiles.profile.coder]
path = "profiles/coder.nix"
description = "Project coder"
"#,
)
.unwrap();
let (choices, default_index) = profile_choices_for_cwd(&project);
assert_eq!(choices[0].selector, None);
assert_eq!(choices[0].label, "manifest cascade");
assert_eq!(default_index, 1);
assert_eq!(choices[1].selector.as_deref(), Some("project:coder"));
assert_eq!(choices[1].label, "project:coder (default) — Project coder");
}
#[test]
fn profile_cycle_selects_profiles_and_can_opt_out_of_default() {
let mut form = form("coder", true);
form.profile_choices = vec![
ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
},
ProfileChoice {
selector: Some("project:coder".to_string()),
label: "project:coder (default)".to_string(),
is_default: true,
},
ProfileChoice {
selector: Some("user:reviewer".to_string()),
label: "user:reviewer".to_string(),
is_default: false,
},
];
form.profile_index = 1;
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("project:coder")
);
form.cycle_profile_next();
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("user:reviewer")
);
form.cycle_profile_next();
assert_eq!(form.selected_profile_selector(), None);
form.cycle_profile_prev();
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("user:reviewer")
);
}
#[test]
fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() {
let mut choices = vec![ProfileChoice {
selector: None,
label: "manifest cascade".to_string(),
is_default: false,
}];
let selected = initial_profile_index(&mut choices, Some("coder"), 0);
assert_eq!(selected, 1);
assert_eq!(choices[1].selector.as_deref(), Some("coder"));
assert_eq!(choices[1].label, "coder");
}
#[test]
fn name_input_handles_insert_backspace_and_cursor() {
let mut f = form("", false);