Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 244 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ hyper = { version = "^1.11" }
hyper-util = { version = "^0.1" }
inventory = { version = "0.3" }
itertools = { version = "^0.15" }
jsonschema = { version = "0.49", default-features = false }
json-patch = { version = "^4.2" }
lazy_static = { version = "^1.5" }
open = { version = "^5.4" }
Expand Down
4 changes: 3 additions & 1 deletion deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ ignore = [
{ id = "RUSTSEC-2026-0222", reason = "wasmtime as dep of extism"},
{ id = "RUSTSEC-2026-0247", reason = "dep of wasmtime"},
{ id = "RUSTSEC-2026-0250", reason = "dep of wasmtime"},
{ id = "RUSTSEC-2026-0251", reason = "dep of wasmtime"}
{ id = "RUSTSEC-2026-0251", reason = "dep of wasmtime"},
{ id = "RUSTSEC-2026-0253", reason = "ratatui dep"}
]
# If this is true, then cargo deny will use the git executable to fetch advisory database.
# If this is false, then it uses a built-in git library.
Expand All @@ -101,6 +102,7 @@ allow = [
"ISC",
"LicenseRef-ring",
"MIT",
"MIT-0",
"MPL-2.0",
"NCSA",
"OpenSSL",
Expand Down
3 changes: 3 additions & 0 deletions openstack_tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ edit = "0.1.5"
eyre = { workspace = true }
futures = { workspace = true }
itertools = { workspace = true }
jsonschema = { workspace = true }
lazy_static = "^1.5"
open.workspace = true
openstack_sdk = { path = "../openstack_sdk", version = "^0.22", default-features = false, features = ["async", "block_storage", "compute", "dns", "identity", "image", "load_balancer", "network"] }
Expand All @@ -40,11 +41,13 @@ ratatui = { version = "^0.30", features = ["serde", "macros", "crossterm"] }
secrecy = "0.10.3"
serde = { workspace = true }
serde_json = { workspace = true }
serde_path_to_error = "0.1.20"
serde_yaml = "^0.9"
signal-hook = "^0.4"
strip-ansi-escapes = "^0.2"
structable = { workspace = true }
strum = { version = "^0.28", features = ["derive"] }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
Expand Down
6 changes: 6 additions & 0 deletions openstack_tui/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ pub enum Action {
/// Edit. Open the default editor to get the user input for the operation.
Edit {
template: String,
/// JSON Schema of the request body, when the resource has one
/// (`ResourceBehaviour::editor_schema`). Used to validate the
/// edited buffer beyond what plain YAML parsing catches (required
/// fields, enums, ranges, ...) before it is sent back as
/// `Action::EditResult`.
schema: Option<String>,
original_action: Box<Action>,
},
EditResult {
Expand Down
106 changes: 93 additions & 13 deletions openstack_tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ use crate::{
cloud_worker::{AuthAction, Cloud},
components::{
Component, auth_helper::AuthHelper, cloud_select_popup::CloudSelect,
confirm_popup::ConfirmPopup, describe::Describe, error_popup::ErrorPopup, header::Header,
home::Home, project_select_popup::ProjectSelect, region_select_popup::RegionSelect,
resource_select_popup::ApiRequestSelect,
confirm_popup::ConfirmPopup, describe::Describe, editor_validation,
error_popup::ErrorPopup, header::Header, home::Home, project_select_popup::ProjectSelect,
region_select_popup::RegionSelect, resource_select_popup::ApiRequestSelect,
},
config::Config,
error::TuiError,
Expand Down Expand Up @@ -482,7 +482,7 @@ impl App {
}
Action::Suspend => self.should_suspend = true,
Action::Resume => self.should_suspend = false,
Action::ClearScreen => tui.terminal.clear()?,
Action::ClearScreen => tui.clear()?,
Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
Action::Render => self.render(tui)?,
Action::Clouds(_)
Expand Down Expand Up @@ -551,35 +551,115 @@ impl App {
}
Action::Edit {
ref template,
ref schema,
ref original_action,
} => {
tui.exit()?;
let mut buffer = template.clone();
// Retry until the buffer parses as valid YAML, or the user abandons the
// edit by clearing the buffer (only comments/whitespace left).

// When a schema is available, drop it next to the buffer as a temp
// file and point editors that understand it (yaml-language-server) at
// it via a modeline comment -- completion/live validation for free in
// VS Code / neovim+yamlls, harmless elsewhere. Kept alive for the
// whole retry loop so the modeline path stays valid across re-edits.
let schema_temp_file = schema.as_ref().and_then(|s| {
let mut f = tempfile::Builder::new().suffix(".json").tempfile().ok()?;
std::io::Write::write_all(&mut f, s.as_bytes()).ok()?;
Some(f)
});
let mut buffer = match &schema_temp_file {
Some(f) => format!(
"# yaml-language-server: $schema={}\n{template}",
f.path().display()
),
None => template.clone(),
};

// Marks the boundary between a (regenerated-every-retry) error banner
// and the user's actual content. Without this, each retry appended a
// fresh banner on top of `edited`, which already contained the banner
// from the previous retry -- stale errors piled up instead of being
// replaced. Splitting on this marker lets each retry discard whatever
// banner is currently above it before prepending the new one.
const CONTENT_MARKER: &str =
"# ----- content below this line; messages above are regenerated on every retry, do not edit them -----";
let strip_banner = |edited: &str| -> String {
match edited.split_once(CONTENT_MARKER) {
Some((_, rest)) => rest.trim_start_matches('\n').to_string(),
None => edited.to_string(),
}
};

// Retry until the buffer parses as valid YAML and (when a schema is
// available) validates against it, or the user abandons the edit --
// by clearing the buffer (only comments/whitespace left), quitting the
// editor without changing anything (e.g. plain `:q`), or exiting the
// editor in a way that produces no usable result (non-zero exit, e.g.
// `:cq` or a killed editor process).
let parsed = loop {
let edited = edit::edit(&buffer)?;
let edited = match edit::edit(&buffer) {
Ok(edited) => edited,
Err(err) => {
tracing::warn!(
"Editor did not return usable content, aborting edit: {err}"
);
break None;
}
};
tracing::debug!("after editing: '{}'", edited);

let abandoned = edited.lines().all(|line| {
if edited == buffer {
break None;
}

let content = strip_banner(&edited);
let abandoned = content.lines().all(|line| {
let trimmed = line.trim();
trimmed.is_empty() || trimmed.starts_with('#')
});
if abandoned {
break None;
}

match serde_yaml::from_str::<serde_json::Value>(&edited) {
Ok(value) => break Some(value),
match serde_yaml::from_str::<serde_json::Value>(&content) {
Ok(value) => {
// Blank optional fields parse as explicit YAML `null`, but
// BODY_SCHEMA property types are rarely declared nullable
// (the OpenAPI convention for "optional" is to omit the
// key) -- validating/sending them as-is would flag every
// blank optional field as a type mismatch.
let value = editor_validation::strip_null_fields(value);
if let Some(schema_str) = schema {
let errors = editor_validation::validate_body(schema_str, &value);
if !errors.is_empty() {
let comments = errors
.iter()
.map(|e| format!("# {e}"))
.collect::<Vec<_>>()
.join("\n");
buffer = format!(
"# Schema validation failed:\n{comments}\n# Fix the errors below and save to retry, or clear everything below the marker to abort.\n{CONTENT_MARKER}\n{content}"
);
continue;
}
}
break Some(value);
}
Err(err) => {
buffer = format!(
"# Error parsing YAML: {err}\n# Fix the error below and save to retry, or clear the buffer entirely to abort.\n{edited}"
"# Error parsing YAML: {err}\n# Fix the error below and save to retry, or clear everything below the marker to abort.\n{CONTENT_MARKER}\n{content}"
);
}
}
};
drop(schema_temp_file);
tui.enter()?;
tui.terminal.clear()?;
// Deferred via Action::ClearScreen (as the suspend/resume path above
// does) rather than calling tui.terminal.clear() here directly: that
// call reads the cursor position via a DSR terminal query, which can
// race with stray output the just-exited $EDITOR left behind and time
// out ("cursor position could not be read"). Routing through the
// action queue gives the terminal a tick to settle first.
self.action_tx.send(Action::ClearScreen)?;
if let Some(result) = parsed {
self.action_tx.send(Action::EditResult {
result,
Expand Down
1 change: 1 addition & 0 deletions openstack_tui/src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub mod compute;
pub mod confirm_popup;
pub mod describe;
pub mod dns;
pub mod editor_validation;
pub mod error_popup;
pub mod generic_resource_view;
pub mod header;
Expand Down
Loading
Loading