feat: use builtin profile by default
This commit is contained in:
+40
-155
@@ -1,29 +1,23 @@
|
||||
//! Inline-viewport "spawn Pod and attach" UX.
|
||||
//!
|
||||
//! Rendered at the user's current cursor position when `insomnia` is invoked
|
||||
//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml`
|
||||
//! to seed manifest defaults and `.insomnia/profiles.toml` to discover profile
|
||||
//! choices, prompts for the Pod's name, and on confirmation
|
||||
//! launches the `insomnia-pod` binary as an independent process with a freshly built
|
||||
//! overlay (name + cwd scope when no project manifest exists). Once
|
||||
//! the process reports its socket via the `INSOMNIA-READY` stderr line,
|
||||
//! the dialog hands control back so main can switch the terminal to
|
||||
//! alternate-screen mode.
|
||||
//! with no positional argument. Discovers `.insomnia/profiles.toml` profile
|
||||
//! choices plus bundled profiles, defaults to the builtin profile, prompts for
|
||||
//! the Pod's name, and on confirmation launches the `insomnia-pod` binary as an
|
||||
//! independent process. Once the process reports its socket via the
|
||||
//! `INSOMNIA-READY` stderr line, the dialog hands control back so main can
|
||||
//! switch the terminal to alternate-screen mode.
|
||||
//!
|
||||
//! The viewport's last frame stays in the terminal's scrollback so the
|
||||
//! user has a record of what was spawned (or why a spawn failed).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{SpawnConfig, spawn_pod};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use manifest::{
|
||||
PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer,
|
||||
user_manifest_path, user_manifest_path_from_env,
|
||||
};
|
||||
use manifest::{ProfileDiscovery, ScopeConfig};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
@@ -97,7 +91,11 @@ pub async fn run(
|
||||
profile: Option<String>,
|
||||
) -> Result<SpawnOutcome, SpawnError> {
|
||||
let defaults = load_spawn_defaults()?;
|
||||
let mut profile_choices = defaults.profile_choices;
|
||||
let mut profile_choices = if resume_from.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
defaults.profile_choices
|
||||
};
|
||||
let profile_index = initial_profile_index(
|
||||
&mut profile_choices,
|
||||
profile.as_deref(),
|
||||
@@ -232,42 +230,6 @@ struct ProfileChoice {
|
||||
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
||||
|
||||
// Run the same merge pod itself uses, then read what's missing off the
|
||||
// result. We only look at `scope.allow` here — `pod.name` is an
|
||||
// instance-level identifier and is supplied by the dialog or `--pod`.
|
||||
// TUI must pre-read the same user manifest path that the pod CLI will use,
|
||||
// including a non-empty INSOMNIA_USER_MANIFEST override; empty values fall
|
||||
// back to the auto-discovered path.
|
||||
let user_layer = user_manifest_path_for_spawn(
|
||||
std::env::var_os(manifest::paths::USER_MANIFEST_ENV),
|
||||
user_manifest_path(),
|
||||
)
|
||||
.filter(|p| p.is_file())
|
||||
.and_then(|p| load_layer(&p).ok());
|
||||
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
|
||||
|
||||
let mut cascade = PodManifestConfig::builtin_defaults();
|
||||
for layer in [user_layer.as_ref(), project_layer.as_ref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
cascade = cascade.merge(layer.clone());
|
||||
}
|
||||
let cascade_has_scope = !cascade.scope.allow.is_empty();
|
||||
|
||||
let scope_origin = match (
|
||||
project_layer
|
||||
.as_ref()
|
||||
.is_some_and(|l| !l.scope.allow.is_empty()),
|
||||
user_layer
|
||||
.as_ref()
|
||||
.is_some_and(|l| !l.scope.allow.is_empty()),
|
||||
) {
|
||||
(true, _) => ScopeOrigin::FromProject,
|
||||
(false, true) => ScopeOrigin::FromUser,
|
||||
(false, false) => ScopeOrigin::CwdDefault,
|
||||
};
|
||||
|
||||
let default_name = cwd
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
@@ -279,8 +241,8 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||
|
||||
Ok(SpawnDefaults {
|
||||
cwd,
|
||||
cascade_has_scope,
|
||||
scope_origin,
|
||||
cascade_has_scope: true,
|
||||
scope_origin: ScopeOrigin::FromProfile,
|
||||
default_name,
|
||||
default_profile_index,
|
||||
profile_choices,
|
||||
@@ -288,16 +250,11 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||
}
|
||||
|
||||
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);
|
||||
return (Vec::new(), 0);
|
||||
};
|
||||
|
||||
let mut choices = Vec::new();
|
||||
for entry in registry.entries() {
|
||||
let mut label = entry.qualified_name();
|
||||
if entry.is_default {
|
||||
@@ -343,13 +300,6 @@ fn initial_profile_index(
|
||||
choices.len() - 1
|
||||
}
|
||||
|
||||
fn user_manifest_path_for_spawn(
|
||||
env_value: Option<OsString>,
|
||||
default_user_manifest: Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
user_manifest_path_from_env(env_value).or(default_user_manifest)
|
||||
}
|
||||
|
||||
fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
||||
Form {
|
||||
cwd: defaults.cwd,
|
||||
@@ -362,11 +312,7 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
||||
resume_from: None,
|
||||
resume_by_pod_name: true,
|
||||
resume_scope: None,
|
||||
profile_choices: vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}],
|
||||
profile_choices: Vec::new(),
|
||||
profile_index: 0,
|
||||
}
|
||||
}
|
||||
@@ -519,16 +465,14 @@ enum MessageKind {
|
||||
}
|
||||
|
||||
enum ScopeOrigin {
|
||||
FromUser,
|
||||
FromProject,
|
||||
FromProfile,
|
||||
CwdDefault,
|
||||
}
|
||||
|
||||
struct Form {
|
||||
cwd: PathBuf,
|
||||
/// True when at least one cascade layer (user or project manifest)
|
||||
/// already declares `scope.allow`. Drives whether the overlay
|
||||
/// should add a cwd-write rule.
|
||||
/// True when the launch source already supplies `scope.allow`.
|
||||
/// Drives whether the compatibility overlay should add a cwd-write rule.
|
||||
cascade_has_scope: bool,
|
||||
/// Display label for the scope row in the dialog.
|
||||
scope_origin: ScopeOrigin,
|
||||
@@ -648,7 +592,7 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
|
||||
let layout = Layout::vertical([
|
||||
Constraint::Length(1), // title
|
||||
Constraint::Length(1), // name field
|
||||
Constraint::Length(1), // context (manifest or scope default)
|
||||
Constraint::Length(1), // context (profile or scope default)
|
||||
Constraint::Length(1), // hint
|
||||
Constraint::Length(1), // message
|
||||
Constraint::Length(1), // spacer
|
||||
@@ -716,15 +660,10 @@ fn context_line(form: &Form) -> Line<'_> {
|
||||
}
|
||||
|
||||
match form.scope_origin {
|
||||
ScopeOrigin::FromProject => Line::from(vec![
|
||||
ScopeOrigin::FromProfile => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("from project manifest", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
ScopeOrigin::FromUser => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("from user manifest", Style::default().fg(Color::Green)),
|
||||
Span::styled("from default profile", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
ScopeOrigin::CwdDefault => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
@@ -767,7 +706,7 @@ mod tests {
|
||||
cwd: PathBuf::from("/work/example"),
|
||||
cascade_has_scope,
|
||||
scope_origin: if cascade_has_scope {
|
||||
ScopeOrigin::FromProject
|
||||
ScopeOrigin::FromProfile
|
||||
} else {
|
||||
ScopeOrigin::CwdDefault
|
||||
},
|
||||
@@ -778,11 +717,7 @@ mod tests {
|
||||
resume_from: None,
|
||||
resume_by_pod_name: false,
|
||||
resume_scope: None,
|
||||
profile_choices: vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}],
|
||||
profile_choices: Vec::new(),
|
||||
profile_index: 0,
|
||||
}
|
||||
}
|
||||
@@ -792,14 +727,10 @@ mod tests {
|
||||
let defaults = SpawnDefaults {
|
||||
cwd: PathBuf::from("/work/example"),
|
||||
cascade_has_scope: true,
|
||||
scope_origin: ScopeOrigin::FromProject,
|
||||
scope_origin: ScopeOrigin::FromProfile,
|
||||
default_name: "ignored".to_string(),
|
||||
default_profile_index: 0,
|
||||
profile_choices: vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}],
|
||||
profile_choices: Vec::new(),
|
||||
};
|
||||
let f = form_for_pod_name("agent".to_string(), defaults);
|
||||
|
||||
@@ -860,46 +791,6 @@ mod tests {
|
||||
assert_eq!(deny[0]["target"].as_str(), Some("/work/example/child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_merge_detects_scope_from_any_layer() {
|
||||
let user = PodManifestConfig::from_toml(
|
||||
r#"
|
||||
[[scope.allow]]
|
||||
target = "/from-user"
|
||||
permission = "write"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut cascade = PodManifestConfig::builtin_defaults();
|
||||
cascade = cascade.merge(user);
|
||||
assert!(!cascade.scope.allow.is_empty());
|
||||
|
||||
let empty_cascade = PodManifestConfig::builtin_defaults();
|
||||
assert!(empty_cascade.scope.allow.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manifest_path_for_spawn_prefers_non_empty_env_override() {
|
||||
assert_eq!(
|
||||
user_manifest_path_for_spawn(
|
||||
Some(OsString::from("/tmp/override.toml")),
|
||||
Some(PathBuf::from("/default/manifest.toml")),
|
||||
),
|
||||
Some(PathBuf::from("/tmp/override.toml")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manifest_path_for_spawn_treats_empty_env_as_unset() {
|
||||
assert_eq!(
|
||||
user_manifest_path_for_spawn(
|
||||
Some(OsString::from("")),
|
||||
Some(PathBuf::from("/default/manifest.toml")),
|
||||
),
|
||||
Some(PathBuf::from("/default/manifest.toml")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_use_project_registry_default() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -925,7 +816,7 @@ coder = "profiles/coder.nix"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_include_no_profile_source_labels_and_default_marker() {
|
||||
fn profile_choices_include_builtin_and_project_default_marker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let insomnia = project.join(".insomnia");
|
||||
@@ -942,22 +833,17 @@ 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!(choices[0].selector.as_deref(), Some("builtin:default"));
|
||||
assert_eq!(choices[0].label, "builtin:default");
|
||||
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() {
|
||||
fn profile_cycle_selects_profiles_without_manifest_cascade_opt_out() {
|
||||
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(),
|
||||
@@ -969,7 +855,7 @@ description = "Project coder"
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
form.profile_index = 1;
|
||||
form.profile_index = 0;
|
||||
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
@@ -981,7 +867,10 @@ description = "Project coder"
|
||||
Some("user:reviewer")
|
||||
);
|
||||
form.cycle_profile_next();
|
||||
assert_eq!(form.selected_profile_selector(), None);
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
Some("project:coder")
|
||||
);
|
||||
form.cycle_profile_prev();
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
@@ -991,15 +880,11 @@ description = "Project coder"
|
||||
|
||||
#[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 mut choices = Vec::new();
|
||||
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");
|
||||
assert_eq!(selected, 0);
|
||||
assert_eq!(choices[0].selector.as_deref(), Some("coder"));
|
||||
assert_eq!(choices[0].label, "coder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user