Compare commits
18 changed files with 193 additions and 1635 deletions
|
|
@ -1,271 +0,0 @@
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
use ratatui::{
|
|
||||||
Frame,
|
|
||||||
layout::{Constraint, Layout, Rect},
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
controls::button::{ActionButton, ButtonIntent, render_button},
|
|
||||||
interaction_result::InteractionResult,
|
|
||||||
render_context::RenderContext,
|
|
||||||
screens::screens::{HitMap, KeyHint, Screen, UiEvent},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DialogButton {
|
|
||||||
Cancel,
|
|
||||||
Confirm,
|
|
||||||
Custom(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ConfirmDialog {
|
|
||||||
title: String,
|
|
||||||
message: Vec<String>,
|
|
||||||
buttons: Vec<DialogButtonConfig>,
|
|
||||||
focused_button: usize,
|
|
||||||
on_confirm: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
|
|
||||||
on_cancel: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DialogButtonConfig {
|
|
||||||
label: String,
|
|
||||||
intent: ButtonIntent,
|
|
||||||
enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConfirmDialog {
|
|
||||||
pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: title.into(),
|
|
||||||
message: vec![message.into()],
|
|
||||||
buttons: vec![
|
|
||||||
DialogButtonConfig {
|
|
||||||
label: "Cancel".to_owned(),
|
|
||||||
intent: ButtonIntent::Cancel,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
DialogButtonConfig {
|
|
||||||
label: "Confirm".to_owned(),
|
|
||||||
intent: ButtonIntent::Primary,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
focused_button: 0,
|
|
||||||
on_confirm: None,
|
|
||||||
on_cancel: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn destructive(title: impl Into<String>, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: title.into(),
|
|
||||||
message: vec![message.into()],
|
|
||||||
buttons: vec![
|
|
||||||
DialogButtonConfig {
|
|
||||||
label: "Cancel".to_owned(),
|
|
||||||
intent: ButtonIntent::Cancel,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
DialogButtonConfig {
|
|
||||||
label: "Delete".to_owned(),
|
|
||||||
intent: ButtonIntent::Destructive,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
focused_button: 0,
|
|
||||||
on_confirm: None,
|
|
||||||
on_cancel: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_message_line(mut self, line: impl Into<String>) -> Self {
|
|
||||||
self.message.push(line.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_button(mut self, label: impl Into<String>, intent: ButtonIntent) -> Self {
|
|
||||||
self.buttons.push(DialogButtonConfig {
|
|
||||||
label: label.into(),
|
|
||||||
intent,
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_confirm_action<F: Fn() -> InteractionResult + Send + Sync + 'static>(
|
|
||||||
mut self,
|
|
||||||
action: F,
|
|
||||||
) -> Self {
|
|
||||||
self.on_confirm = Some(Box::new(action));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_cancel_action<F: Fn() -> InteractionResult + Send + Sync + 'static>(
|
|
||||||
mut self,
|
|
||||||
action: F,
|
|
||||||
) -> Self {
|
|
||||||
self.on_cancel = Some(Box::new(action));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn activate(&self) -> InteractionResult {
|
|
||||||
match self.focused_button {
|
|
||||||
0 => {
|
|
||||||
if let Some(action) = &self.on_cancel {
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
InteractionResult::CloseScreen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1 => {
|
|
||||||
if let Some(action) = &self.on_confirm {
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
InteractionResult::CloseScreen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => InteractionResult::CloseScreen,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_button(&mut self) {
|
|
||||||
self.focused_button = (self.focused_button + 1) % self.buttons.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prev_button(&mut self) {
|
|
||||||
if self.focused_button == 0 {
|
|
||||||
self.focused_button = self.buttons.len() - 1;
|
|
||||||
} else {
|
|
||||||
self.focused_button -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Screen for ConfirmDialog {
|
|
||||||
fn as_any(&self) -> &dyn std::any::Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
|
||||||
let area = crate::layout::fit::centered_rect(
|
|
||||||
rect,
|
|
||||||
crate::layout::fit::RequiredSize {
|
|
||||||
width: 50,
|
|
||||||
height: (self.message.len() + 8) as u16,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
f.render_widget(Clear, area);
|
|
||||||
let block = Block::default()
|
|
||||||
.title(format!(" {} ", self.title))
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(context.theme.borders.focused)
|
|
||||||
.style(context.theme.surfaces.overlay);
|
|
||||||
|
|
||||||
let inner = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
|
|
||||||
let rows = Layout::vertical([
|
|
||||||
Constraint::Min(self.message.len() as u16),
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Length(1),
|
|
||||||
])
|
|
||||||
.split(inner);
|
|
||||||
|
|
||||||
let lines: Vec<Line> = self
|
|
||||||
.message
|
|
||||||
.iter()
|
|
||||||
.map(|line| Line::from(Span::styled(line.as_str(), context.theme.text.normal)))
|
|
||||||
.collect();
|
|
||||||
f.render_widget(Paragraph::new(lines), rows[0]);
|
|
||||||
|
|
||||||
let buttons_area = rows[2];
|
|
||||||
let button_widths: Vec<u16> = self
|
|
||||||
.buttons
|
|
||||||
.iter()
|
|
||||||
.map(|b| {
|
|
||||||
crate::controls::button::button_minimum_width(&b.label)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let total_width: u16 = button_widths.iter().sum();
|
|
||||||
let spacing = self.buttons.len().saturating_sub(1) as u16;
|
|
||||||
let available = buttons_area.width;
|
|
||||||
let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2;
|
|
||||||
|
|
||||||
let mut x = start_x;
|
|
||||||
for (i, (button_config, &width)) in
|
|
||||||
self.buttons.iter().zip(&button_widths).enumerate()
|
|
||||||
{
|
|
||||||
let button_area = Rect {
|
|
||||||
x,
|
|
||||||
y: buttons_area.y,
|
|
||||||
width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
x = x.saturating_add(width + 1);
|
|
||||||
|
|
||||||
render_button(
|
|
||||||
f,
|
|
||||||
button_area,
|
|
||||||
ActionButton {
|
|
||||||
label: &button_config.label,
|
|
||||||
intent: button_config.intent,
|
|
||||||
focused: self.focused_button == i,
|
|
||||||
enabled: button_config.enabled,
|
|
||||||
},
|
|
||||||
context.theme,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
|
||||||
let UiEvent::Key(key) = event else {
|
|
||||||
return InteractionResult::Unhandled;
|
|
||||||
};
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Esc => InteractionResult::CloseScreen,
|
|
||||||
KeyCode::Tab => {
|
|
||||||
self.next_button();
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::BackTab => {
|
|
||||||
self.prev_button();
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::Left => {
|
|
||||||
self.prev_button();
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::Right => {
|
|
||||||
self.next_button();
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
|
||||||
_ => InteractionResult::Unhandled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key_hints(&self) -> Vec<KeyHint> {
|
|
||||||
vec![
|
|
||||||
KeyHint {
|
|
||||||
keys: "Tab",
|
|
||||||
action: "Switch button",
|
|
||||||
},
|
|
||||||
KeyHint {
|
|
||||||
keys: "Enter",
|
|
||||||
action: "Confirm",
|
|
||||||
},
|
|
||||||
KeyHint {
|
|
||||||
keys: "Esc",
|
|
||||||
action: "Cancel",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +1,25 @@
|
||||||
use crate::ipc_client::{DaemonStatus, IpcConnectionState};
|
|
||||||
use crate::theme::ResolvedTheme;
|
use crate::theme::ResolvedTheme;
|
||||||
use crate::{
|
use crate::{
|
||||||
controls::button::ButtonIntent,
|
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||||
screens::screens::{AppAction, HitMap},
|
screens::screens::{AppAction, HitMap},
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
text::{Line, Span},
|
text::Span,
|
||||||
widgets::Paragraph,
|
widgets::Paragraph,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn connection_badge(state: &IpcConnectionState, theme: &ResolvedTheme) -> (&'static str, ratatui::style::Style) {
|
/// Shared application bar. The brand cell is deliberately an action so it is
|
||||||
match state {
|
/// a reliable way home from every screen.
|
||||||
IpcConnectionState::Connected => ("OK", theme.status.success),
|
|
||||||
IpcConnectionState::Connecting => ("..", theme.status.warning),
|
|
||||||
IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning),
|
|
||||||
IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => ("FAIL", theme.status.error),
|
|
||||||
IpcConnectionState::Disconnected => ("WARN", theme.status.warning),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn omikron_badge(daemon: &DaemonStatus, theme: &ResolvedTheme) -> (String, ratatui::style::Style) {
|
|
||||||
use iota_ipc::ComponentId;
|
|
||||||
let health = daemon.components.get(&ComponentId::Omikron);
|
|
||||||
let (label, style) = match health.map(|h| h.status) {
|
|
||||||
Some(iota_ipc::HealthStatus::Healthy) => ("OK", theme.status.success),
|
|
||||||
Some(iota_ipc::HealthStatus::Degraded) => ("WARN", theme.status.warning),
|
|
||||||
Some(iota_ipc::HealthStatus::Failed) => ("FAIL", theme.status.error),
|
|
||||||
None => ("--", theme.text.muted),
|
|
||||||
};
|
|
||||||
let detail = health
|
|
||||||
.and_then(|h| h.message.as_deref())
|
|
||||||
.map(|m| format!(" {m}"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
(format!("{label}{detail}"), style)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_header(
|
pub fn render_header(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
connection: &IpcConnectionState,
|
title: &str,
|
||||||
daemon: &DaemonStatus,
|
|
||||||
theme: &ResolvedTheme,
|
theme: &ResolvedTheme,
|
||||||
hits: &mut HitMap,
|
hits: &mut HitMap,
|
||||||
focused_action: Option<usize>,
|
focused_action: Option<usize>,
|
||||||
) {
|
) {
|
||||||
let version = if daemon.version.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!(" v{}", daemon.version)
|
|
||||||
};
|
|
||||||
|
|
||||||
let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
|
|
||||||
let cells = Layout::horizontal([
|
let cells = Layout::horizontal([
|
||||||
Constraint::Min(28),
|
Constraint::Min(28),
|
||||||
Constraint::Length(12),
|
Constraint::Length(12),
|
||||||
|
|
@ -60,75 +27,36 @@ pub fn render_header(
|
||||||
Constraint::Length(12),
|
Constraint::Length(12),
|
||||||
Constraint::Length(8),
|
Constraint::Length(8),
|
||||||
])
|
])
|
||||||
.split(rows[0]);
|
.split(area);
|
||||||
let cells2 = Layout::horizontal([
|
|
||||||
Constraint::Min(28),
|
|
||||||
Constraint::Length(12),
|
|
||||||
Constraint::Length(12),
|
|
||||||
Constraint::Length(12),
|
|
||||||
Constraint::Length(8),
|
|
||||||
])
|
|
||||||
.split(rows[1]);
|
|
||||||
|
|
||||||
let (ipc_label, ipc_style) = connection_badge(connection, theme);
|
|
||||||
let (omikron_text, omikron_style) = omikron_badge(daemon, theme);
|
|
||||||
|
|
||||||
let brand_line1 = Line::from(vec![
|
|
||||||
Span::styled(format!(" IOTA{version}"), theme.surfaces.toolbar),
|
|
||||||
Span::styled(format!(" IPC:[{ipc_label}]"), ipc_style),
|
|
||||||
]);
|
|
||||||
let brand_line2 = Line::from(vec![
|
|
||||||
Span::styled(" Omikron: ", theme.surfaces.toolbar),
|
|
||||||
Span::styled(format!("[{omikron_text}]"), omikron_style),
|
|
||||||
]);
|
|
||||||
let brand_area = Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: area.y,
|
|
||||||
width: cells[0].width,
|
|
||||||
height: area.height,
|
|
||||||
};
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar),
|
Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)),
|
||||||
brand_area,
|
cells[0],
|
||||||
);
|
);
|
||||||
hits.register(brand_area, AppAction::OpenMain);
|
hits.register(cells[0], AppAction::OpenMain);
|
||||||
|
for (index, (area, label, action)) in [
|
||||||
for (index, (top, _bottom, label, intent, action)) in [
|
(cells[1], "Overview", AppAction::OpenOverview),
|
||||||
(cells[1], cells2[1], "Overview", ButtonIntent::Primary, AppAction::OpenOverview),
|
(cells[2], "Users", AppAction::OpenUsers),
|
||||||
(cells[2], cells2[2], "Users", ButtonIntent::Neutral, AppAction::OpenUsers),
|
(cells[3], "Settings", AppAction::OpenSettings),
|
||||||
(cells[3], cells2[3], "Settings", ButtonIntent::Neutral, AppAction::OpenSettings),
|
(cells[4], "Quit", AppAction::Quit),
|
||||||
(cells[4], cells2[4], "Quit", ButtonIntent::Destructive, AppAction::Quit),
|
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
let button_area = Rect {
|
render_button(
|
||||||
x: top.x,
|
frame,
|
||||||
y: top.y,
|
area,
|
||||||
width: top.width,
|
ActionButton {
|
||||||
height: area.height,
|
label,
|
||||||
};
|
intent: if action == AppAction::Quit {
|
||||||
let style = match (intent, focused_action == Some(index)) {
|
ButtonIntent::Destructive
|
||||||
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
|
} else {
|
||||||
(ButtonIntent::Primary, false) => theme.buttons.primary,
|
ButtonIntent::Neutral
|
||||||
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
|
},
|
||||||
(ButtonIntent::Neutral, false) => theme.buttons.neutral,
|
focused: focused_action == Some(index),
|
||||||
(ButtonIntent::Cancel, true) => theme.buttons.cancel_focused,
|
enabled: true,
|
||||||
(ButtonIntent::Cancel, false) => theme.buttons.cancel,
|
},
|
||||||
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
|
theme,
|
||||||
};
|
|
||||||
let display_label = if focused_action == Some(index) {
|
|
||||||
format!("› {label}")
|
|
||||||
} else {
|
|
||||||
label.to_owned()
|
|
||||||
};
|
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new(vec![
|
|
||||||
Line::from(Span::styled(display_label, style)),
|
|
||||||
Line::from(""),
|
|
||||||
]),
|
|
||||||
button_area,
|
|
||||||
);
|
);
|
||||||
hits.register(button_area, action);
|
hits.register(area, action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ pub mod action;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod checkbox_group;
|
pub mod checkbox_group;
|
||||||
pub mod choice;
|
pub mod choice;
|
||||||
pub mod dialog;
|
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
pub mod panel;
|
pub mod panel;
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
use crossterm::event::KeyCode;
|
|
||||||
use ratatui::{
|
|
||||||
Frame,
|
|
||||||
layout::Rect,
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, Borders, Clear, Paragraph},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
interaction_result::InteractionResult,
|
|
||||||
render_context::RenderContext,
|
|
||||||
screens::screens::{HitMap, Screen, UiEvent},
|
|
||||||
theme::ResolvedTheme,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct HelpOverlay {
|
|
||||||
scroll: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HelpOverlay {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { scroll: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_lines(&self, theme: &ResolvedTheme) -> Vec<Line<'static>> {
|
|
||||||
vec![
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(Span::styled(
|
|
||||||
"Global Keyboard Shortcuts",
|
|
||||||
theme.text.heading,
|
|
||||||
)),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" F6", theme.text.link),
|
|
||||||
Span::styled(" Toggle header navigation", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" Tab", theme.text.link),
|
|
||||||
Span::styled(" Move focus to next panel", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" Shift+Tab", theme.text.link),
|
|
||||||
Span::styled(" Move focus to previous panel", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" Esc", theme.text.link),
|
|
||||||
Span::styled(" Go back / Close dialog", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" Ctrl+C", theme.text.link),
|
|
||||||
Span::styled(" Quit the application", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(Span::styled("Dashboard Navigation", theme.text.heading)),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" o/O", theme.text.link),
|
|
||||||
Span::styled(" Open Overview screen", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" u/U", theme.text.link),
|
|
||||||
Span::styled(" Open Users screen", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" m/M", theme.text.link),
|
|
||||||
Span::styled(" Open Metrics screen", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(Span::styled("Log Panel", theme.text.heading)),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" j/Down", theme.text.link),
|
|
||||||
Span::styled(" Scroll down", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" k/Up", theme.text.link),
|
|
||||||
Span::styled(" Scroll up", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" Enter", theme.text.link),
|
|
||||||
Span::styled(" Lock/unlock scroll", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" /", theme.text.link),
|
|
||||||
Span::styled(" Filter logs", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(Span::styled("Console Panel", theme.text.heading)),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" Enter", theme.text.link),
|
|
||||||
Span::styled(" Send command", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" Up/Down", theme.text.link),
|
|
||||||
Span::styled(" Command history", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" Tab", theme.text.link),
|
|
||||||
Span::styled(" Auto-complete", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" /help", theme.text.link),
|
|
||||||
Span::styled(" List available commands", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(Span::styled("List Navigation", theme.text.heading)),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" j/Down", theme.text.link),
|
|
||||||
Span::styled(" Next item", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" k/Up", theme.text.link),
|
|
||||||
Span::styled(" Previous item", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" PgUp/PgDn", theme.text.link),
|
|
||||||
Span::styled(" Page up/down", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" Home", theme.text.link),
|
|
||||||
Span::styled(" First item", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" End", theme.text.link),
|
|
||||||
Span::styled(" Last item", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(" /", theme.text.link),
|
|
||||||
Span::styled(" Filter list", theme.text.normal),
|
|
||||||
]),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(Span::styled(
|
|
||||||
"Press ? or Esc to close this overlay",
|
|
||||||
theme.text.muted,
|
|
||||||
)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Screen for HelpOverlay {
|
|
||||||
fn as_any(&self) -> &dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
|
||||||
let area = crate::layout::fit::centered_rect(
|
|
||||||
rect,
|
|
||||||
crate::layout::fit::RequiredSize {
|
|
||||||
width: 52,
|
|
||||||
height: 40,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
f.render_widget(Clear, area);
|
|
||||||
let block = Block::default()
|
|
||||||
.title(" Keyboard Shortcuts (?) ")
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(context.theme.borders.focused)
|
|
||||||
.style(context.theme.surfaces.overlay);
|
|
||||||
|
|
||||||
let inner = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
|
|
||||||
let lines = self.build_lines(context.theme);
|
|
||||||
let paragraph = Paragraph::new(lines)
|
|
||||||
.scroll((self.scroll as u16, 0))
|
|
||||||
.style(context.theme.text.normal);
|
|
||||||
f.render_widget(paragraph, inner);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
|
||||||
let UiEvent::Key(key) = event else {
|
|
||||||
return InteractionResult::Unhandled;
|
|
||||||
};
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => {
|
|
||||||
InteractionResult::CloseScreen
|
|
||||||
}
|
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
|
||||||
self.scroll = self.scroll.saturating_add(1);
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
|
||||||
self.scroll = self.scroll.saturating_sub(1);
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::PageDown => {
|
|
||||||
self.scroll = self.scroll.saturating_add(10);
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::PageUp => {
|
|
||||||
self.scroll = self.scroll.saturating_sub(10);
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
_ => InteractionResult::Unhandled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key_hints(&self) -> Vec<crate::screens::screens::KeyHint> {
|
|
||||||
vec![
|
|
||||||
crate::screens::screens::KeyHint {
|
|
||||||
keys: "Up/Down",
|
|
||||||
action: "Scroll",
|
|
||||||
},
|
|
||||||
crate::screens::screens::KeyHint {
|
|
||||||
keys: "Esc/?",
|
|
||||||
action: "Close",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use std::any::Any;
|
|
||||||
|
|
@ -23,12 +23,10 @@ pub mod util {
|
||||||
}
|
}
|
||||||
pub mod app_state;
|
pub mod app_state;
|
||||||
pub mod controls;
|
pub mod controls;
|
||||||
pub mod help_overlay;
|
|
||||||
pub mod input_handler;
|
pub mod input_handler;
|
||||||
pub mod interaction_result;
|
pub mod interaction_result;
|
||||||
pub mod ipc_client;
|
pub mod ipc_client;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod notification;
|
|
||||||
pub mod render_context;
|
pub mod render_context;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use ratatui::{
|
|
||||||
Frame,
|
|
||||||
layout::Rect,
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::Paragraph,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::theme::ResolvedTheme;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum NotificationKind {
|
|
||||||
Success,
|
|
||||||
Warning,
|
|
||||||
Error,
|
|
||||||
Info,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Notification {
|
|
||||||
pub message: String,
|
|
||||||
pub kind: NotificationKind,
|
|
||||||
pub created_at: Instant,
|
|
||||||
pub duration: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Notification {
|
|
||||||
pub fn success(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(message, NotificationKind::Success, Duration::from_secs(3))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn warning(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(message, NotificationKind::Warning, Duration::from_secs(4))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(message, NotificationKind::Error, Duration::from_secs(5))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn info(message: impl Into<String>) -> Self {
|
|
||||||
Self::new(message, NotificationKind::Info, Duration::from_secs(3))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new(message: impl Into<String>, kind: NotificationKind, duration: Duration) -> Self {
|
|
||||||
Self {
|
|
||||||
message: message.into(),
|
|
||||||
kind,
|
|
||||||
created_at: Instant::now(),
|
|
||||||
duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_expired(&self) -> bool {
|
|
||||||
self.created_at.elapsed() >= self.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remaining(&self) -> Duration {
|
|
||||||
self.duration.saturating_sub(self.created_at.elapsed())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn progress(&self) -> f64 {
|
|
||||||
let elapsed = self.created_at.elapsed().as_secs_f64();
|
|
||||||
let total = self.duration.as_secs_f64();
|
|
||||||
(elapsed / total).min(1.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_notification(
|
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
notification: &Notification,
|
|
||||||
theme: &ResolvedTheme,
|
|
||||||
) {
|
|
||||||
let (prefix, style) = match notification.kind {
|
|
||||||
NotificationKind::Success => ("✓ ", theme.status.success),
|
|
||||||
NotificationKind::Warning => ("⚠ ", theme.status.warning),
|
|
||||||
NotificationKind::Error => ("✗ ", theme.status.error),
|
|
||||||
NotificationKind::Info => ("ℹ ", theme.status.info),
|
|
||||||
};
|
|
||||||
|
|
||||||
let remaining = notification.remaining().as_secs();
|
|
||||||
let progress = notification.progress();
|
|
||||||
|
|
||||||
let mut spans = vec![
|
|
||||||
Span::styled(prefix, style),
|
|
||||||
Span::styled(¬ification.message, theme.text.normal),
|
|
||||||
];
|
|
||||||
|
|
||||||
if remaining > 0 {
|
|
||||||
let bar_width = 10;
|
|
||||||
let filled = ((1.0 - progress) * bar_width as f64) as usize;
|
|
||||||
let empty = bar_width - filled;
|
|
||||||
let bar: String = "█".repeat(filled) + &"░".repeat(empty);
|
|
||||||
spans.push(Span::styled(
|
|
||||||
format!(" [{bar}] {remaining}s"),
|
|
||||||
theme.text.muted,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(Line::from(spans));
|
|
||||||
frame.render_widget(paragraph, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_notification_area(
|
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
notifications: &[Notification],
|
|
||||||
theme: &ResolvedTheme,
|
|
||||||
) {
|
|
||||||
if notifications.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let visible_height = area.height as usize;
|
|
||||||
let start = notifications.len().saturating_sub(visible_height);
|
|
||||||
let visible = ¬ifications[start..];
|
|
||||||
|
|
||||||
for (i, notification) in visible.iter().enumerate() {
|
|
||||||
let row = Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: area.y + i as u16,
|
|
||||||
width: area.width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
render_notification(frame, row, notification, theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -49,6 +49,28 @@ impl MainScreen {
|
||||||
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
|
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
|
||||||
self.daemon_status_rx.clone()
|
self.daemon_status_rx.clone()
|
||||||
}
|
}
|
||||||
|
fn status_summary(&self) -> String {
|
||||||
|
let connection = match self.connection_status_rx.borrow().clone() {
|
||||||
|
IpcConnectionState::Connected => "[OK] Connected".to_owned(),
|
||||||
|
IpcConnectionState::Connecting => "[..] Connecting".to_owned(),
|
||||||
|
IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(),
|
||||||
|
IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => {
|
||||||
|
"[FAIL] Failed".to_owned()
|
||||||
|
}
|
||||||
|
IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(),
|
||||||
|
};
|
||||||
|
let daemon = self.daemon_status_rx.borrow().clone();
|
||||||
|
let version = if daemon.version.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" v{}", daemon.version)
|
||||||
|
};
|
||||||
|
let ready = daemon
|
||||||
|
.startup_phase
|
||||||
|
.map(|phase| format!(" {:?}", phase))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("IOTA{version} {connection}{ready}")
|
||||||
|
}
|
||||||
pub async fn new(ui: Arc<UI>) -> Self {
|
pub async fn new(ui: Arc<UI>) -> Self {
|
||||||
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
|
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
|
||||||
|
|
||||||
|
|
@ -232,6 +254,39 @@ impl Screen for MainScreen {
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
|
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
|
||||||
self.layout_width.store(rect.width, Ordering::Relaxed);
|
self.layout_width.store(rect.width, Ordering::Relaxed);
|
||||||
|
// A watch Ref blocks senders until it is dropped. Rendering may do
|
||||||
|
// terminal I/O, so retain only owned snapshots for the whole frame.
|
||||||
|
let status = self.connection_status_rx.borrow().clone();
|
||||||
|
let daemon = self.daemon_status_rx.borrow().clone();
|
||||||
|
let status_text = match status {
|
||||||
|
IpcConnectionState::Connected => "Connected".to_string(),
|
||||||
|
IpcConnectionState::Connecting => "Connecting...".to_string(),
|
||||||
|
IpcConnectionState::Reconnecting { attempt } => {
|
||||||
|
format!("Reconnecting (attempt {})...", attempt)
|
||||||
|
}
|
||||||
|
IpcConnectionState::Incompatible { message } => {
|
||||||
|
format!("Incompatible protocol: {}", message)
|
||||||
|
}
|
||||||
|
IpcConnectionState::Failed { message } => {
|
||||||
|
format!("Connection failed: {}", message)
|
||||||
|
}
|
||||||
|
IpcConnectionState::Disconnected => "Disconnected".to_string(),
|
||||||
|
};
|
||||||
|
let readiness = daemon
|
||||||
|
.startup_phase
|
||||||
|
.map(|phase| format!("{:?}", phase))
|
||||||
|
.unwrap_or_else(|| "Waiting for status".into());
|
||||||
|
let health = daemon
|
||||||
|
.degraded_reason
|
||||||
|
.as_deref()
|
||||||
|
.map(|reason| format!(" — {reason}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let version = if daemon.version.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" v{}", daemon.version)
|
||||||
|
};
|
||||||
|
let _ = (status_text, readiness, health, version);
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
ratatui::widgets::Block::default().style(context.theme.surfaces.canvas),
|
ratatui::widgets::Block::default().style(context.theme.surfaces.canvas),
|
||||||
rect,
|
rect,
|
||||||
|
|
@ -448,6 +503,9 @@ impl Screen for MainScreen {
|
||||||
_ => InteractionResult::Unhandled,
|
_ => InteractionResult::Unhandled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn app_title(&self) -> String {
|
||||||
|
self.status_summary()
|
||||||
|
}
|
||||||
fn key_hints(&self) -> Vec<KeyHint> {
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
if self.selected_coords == (2, 0) {
|
if self.selected_coords == (2, 0) {
|
||||||
vec![
|
vec![
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ pub enum AppEvent {
|
||||||
theme: crate::theme::ThemeName,
|
theme: crate::theme::ThemeName,
|
||||||
color: crate::theme::TerminalPolicy,
|
color: crate::theme::TerminalPolicy,
|
||||||
unicode: crate::theme::TerminalPolicy,
|
unicode: crate::theme::TerminalPolicy,
|
||||||
cli_output: crate::theme::CliOutputFormat,
|
|
||||||
cli_require_confirmation: bool,
|
|
||||||
},
|
},
|
||||||
ThemeSaved(Result<(), String>),
|
ThemeSaved(Result<(), String>),
|
||||||
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
|
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
|
||||||
|
|
@ -120,6 +118,9 @@ pub trait Screen: Send + Sync + Any {
|
||||||
fn handle_action(&mut self, _action: AppAction) -> InteractionResult {
|
fn handle_action(&mut self, _action: AppAction) -> InteractionResult {
|
||||||
InteractionResult::Unhandled
|
InteractionResult::Unhandled
|
||||||
}
|
}
|
||||||
|
fn app_title(&self) -> String {
|
||||||
|
"IOTA".to_owned()
|
||||||
|
}
|
||||||
fn key_hints(&self) -> Vec<KeyHint> {
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
vec![
|
vec![
|
||||||
KeyHint {
|
KeyHint {
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,12 @@ use crate::{
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
render_context::RenderContext,
|
render_context::RenderContext,
|
||||||
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
|
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
|
||||||
theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig},
|
theme::{TerminalPolicy, ThemeName, UiConfig},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum Focus {
|
enum Focus {
|
||||||
Theme,
|
Theme,
|
||||||
CliOutput,
|
|
||||||
CliConfirm,
|
|
||||||
RegenerateKeys,
|
RegenerateKeys,
|
||||||
Back,
|
Back,
|
||||||
}
|
}
|
||||||
|
|
@ -36,8 +34,6 @@ pub struct SettingsScreen {
|
||||||
message: String,
|
message: String,
|
||||||
color: TerminalPolicy,
|
color: TerminalPolicy,
|
||||||
unicode: TerminalPolicy,
|
unicode: TerminalPolicy,
|
||||||
cli_output: CliOutputFormat,
|
|
||||||
cli_require_confirmation: bool,
|
|
||||||
focus: Focus,
|
focus: Focus,
|
||||||
dialog: Option<Dialog>,
|
dialog: Option<Dialog>,
|
||||||
pending: bool,
|
pending: bool,
|
||||||
|
|
@ -49,15 +45,16 @@ impl SettingsScreen {
|
||||||
.iter()
|
.iter()
|
||||||
.position(|theme| *theme == current)
|
.position(|theme| *theme == current)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let config = UiConfig::load_or_default();
|
|
||||||
Self {
|
Self {
|
||||||
selected,
|
selected,
|
||||||
saved: current,
|
saved: current,
|
||||||
message: "Left/Right previews. Enter saves.".into(),
|
message: "Left/Right previews. Enter saves.".into(),
|
||||||
color: config.color,
|
color: UiConfig::load()
|
||||||
unicode: config.unicode,
|
.map(|config| config.color)
|
||||||
cli_output: config.cli_output,
|
.unwrap_or_default(),
|
||||||
cli_require_confirmation: config.cli_require_confirmation,
|
unicode: UiConfig::load()
|
||||||
|
.map(|config| config.unicode)
|
||||||
|
.unwrap_or_default(),
|
||||||
focus: Focus::Theme,
|
focus: Focus::Theme,
|
||||||
dialog: None,
|
dialog: None,
|
||||||
pending: false,
|
pending: false,
|
||||||
|
|
@ -85,9 +82,7 @@ impl SettingsScreen {
|
||||||
|
|
||||||
fn next_focus(&mut self) {
|
fn next_focus(&mut self) {
|
||||||
self.focus = match self.focus {
|
self.focus = match self.focus {
|
||||||
Focus::Theme => Focus::CliOutput,
|
Focus::Theme => Focus::RegenerateKeys,
|
||||||
Focus::CliOutput => Focus::CliConfirm,
|
|
||||||
Focus::CliConfirm => Focus::RegenerateKeys,
|
|
||||||
Focus::RegenerateKeys => Focus::Back,
|
Focus::RegenerateKeys => Focus::Back,
|
||||||
Focus::Back => Focus::Theme,
|
Focus::Back => Focus::Theme,
|
||||||
};
|
};
|
||||||
|
|
@ -97,9 +92,7 @@ impl SettingsScreen {
|
||||||
self.focus = match self.focus {
|
self.focus = match self.focus {
|
||||||
Focus::Theme => Focus::Back,
|
Focus::Theme => Focus::Back,
|
||||||
Focus::Back => Focus::RegenerateKeys,
|
Focus::Back => Focus::RegenerateKeys,
|
||||||
Focus::RegenerateKeys => Focus::CliConfirm,
|
Focus::RegenerateKeys => Focus::Theme,
|
||||||
Focus::CliConfirm => Focus::CliOutput,
|
|
||||||
Focus::CliOutput => Focus::Theme,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,44 +114,24 @@ impl SettingsScreen {
|
||||||
match self.focus {
|
match self.focus {
|
||||||
Focus::Theme => {
|
Focus::Theme => {
|
||||||
self.message = "Saving theme…".into();
|
self.message = "Saving theme…".into();
|
||||||
}
|
let theme = self.selected_theme();
|
||||||
Focus::CliOutput => {
|
let color = self.color;
|
||||||
self.message = "Output format updated.".into();
|
let unicode = self.unicode;
|
||||||
return InteractionResult::Handled;
|
InteractionResult::AppTask {
|
||||||
}
|
task: Box::pin(async move {
|
||||||
Focus::CliConfirm => {
|
UiEvent::App(AppEvent::SaveSettings {
|
||||||
self.cli_require_confirmation = !self.cli_require_confirmation;
|
theme,
|
||||||
self.message = format!(
|
color,
|
||||||
"Confirm: {}",
|
unicode,
|
||||||
if self.cli_require_confirmation {
|
})
|
||||||
"On"
|
}),
|
||||||
} else {
|
}
|
||||||
"Off"
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return InteractionResult::Handled;
|
|
||||||
}
|
}
|
||||||
Focus::RegenerateKeys => {
|
Focus::RegenerateKeys => {
|
||||||
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
|
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
|
||||||
return InteractionResult::Handled;
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
Focus::Back => return InteractionResult::CloseScreen,
|
Focus::Back => InteractionResult::CloseScreen,
|
||||||
}
|
|
||||||
let theme = self.selected_theme();
|
|
||||||
let color = self.color;
|
|
||||||
let unicode = self.unicode;
|
|
||||||
let cli_output = self.cli_output;
|
|
||||||
let cli_require_confirmation = self.cli_require_confirmation;
|
|
||||||
InteractionResult::AppTask {
|
|
||||||
task: Box::pin(async move {
|
|
||||||
UiEvent::App(AppEvent::SaveSettings {
|
|
||||||
theme,
|
|
||||||
color,
|
|
||||||
unicode,
|
|
||||||
cli_output,
|
|
||||||
cli_require_confirmation,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -179,16 +152,13 @@ impl Screen for SettingsScreen {
|
||||||
context: &RenderContext<'_>,
|
context: &RenderContext<'_>,
|
||||||
hits: &mut HitMap,
|
hits: &mut HitMap,
|
||||||
) {
|
) {
|
||||||
let header_block = Block::default()
|
let block = Block::default()
|
||||||
.title(" Settings ")
|
.title(" Settings ")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(context.theme.borders.focused);
|
.border_style(context.theme.borders.focused);
|
||||||
let inner = header_block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(header_block, area);
|
frame.render_widget(block, area);
|
||||||
|
let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
|
||||||
let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)])
|
|
||||||
.split(inner);
|
|
||||||
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(format!(
|
Paragraph::new(format!(
|
||||||
"Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)",
|
"Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)",
|
||||||
|
|
@ -199,28 +169,18 @@ impl Screen for SettingsScreen {
|
||||||
" [preview]"
|
" [preview]"
|
||||||
},
|
},
|
||||||
self.color,
|
self.color,
|
||||||
self.unicode,
|
self.unicode
|
||||||
))
|
))
|
||||||
.style(context.theme.text.heading),
|
.style(context.theme.text.heading),
|
||||||
sections[0],
|
rows[0],
|
||||||
);
|
|
||||||
|
|
||||||
let cli_line = format!(
|
|
||||||
"CLI output: {:?} (L) Confirm: {} (K)",
|
|
||||||
self.cli_output,
|
|
||||||
if self.cli_require_confirmation {
|
|
||||||
"required"
|
|
||||||
} else {
|
|
||||||
"disabled"
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let bottom_rows =
|
let bottom_rows =
|
||||||
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]);
|
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]);
|
||||||
|
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(Span::styled(&self.message, context.theme.text.normal)),
|
Line::from(Span::styled(&self.message, context.theme.text.normal)),
|
||||||
Line::from(Span::styled(&cli_line, context.theme.text.normal)),
|
Line::from(""),
|
||||||
Line::from("Preview"),
|
Line::from("Preview"),
|
||||||
Line::from("[OK] Healthy"),
|
Line::from("[OK] Healthy"),
|
||||||
Line::from("[WARN] Degraded"),
|
Line::from("[WARN] Degraded"),
|
||||||
|
|
@ -392,23 +352,6 @@ impl Screen for SettingsScreen {
|
||||||
self.unicode = Self::next_policy(self.unicode);
|
self.unicode = Self::next_policy(self.unicode);
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
KeyCode::Char('l') | KeyCode::Char('L') => {
|
|
||||||
self.cli_output = self.cli_output.next();
|
|
||||||
self.message = format!("CLI output: {:?}", self.cli_output);
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::Char('k') | KeyCode::Char('K') => {
|
|
||||||
self.cli_require_confirmation = !self.cli_require_confirmation;
|
|
||||||
self.message = format!(
|
|
||||||
"CLI confirm: {}",
|
|
||||||
if self.cli_require_confirmation {
|
|
||||||
"On"
|
|
||||||
} else {
|
|
||||||
"Off"
|
|
||||||
},
|
|
||||||
);
|
|
||||||
InteractionResult::Handled
|
|
||||||
}
|
|
||||||
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
||||||
InteractionResult::CloseScreen
|
InteractionResult::CloseScreen
|
||||||
}
|
}
|
||||||
|
|
@ -462,10 +405,6 @@ impl Screen for SettingsScreen {
|
||||||
keys: "C/U",
|
keys: "C/U",
|
||||||
action: "Color/Unicode",
|
action: "Color/Unicode",
|
||||||
},
|
},
|
||||||
KeyHint {
|
|
||||||
keys: "L/K",
|
|
||||||
action: "CLI Out/Confirm",
|
|
||||||
},
|
|
||||||
KeyHint {
|
KeyHint {
|
||||||
keys: "Esc/B",
|
keys: "Esc/B",
|
||||||
action: "Back",
|
action: "Back",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ use std::{
|
||||||
any::Any,
|
any::Any,
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicU8, AtomicUsize, Ordering},
|
atomic::{AtomicUsize, Ordering},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -56,7 +56,6 @@ pub struct UsersScreen {
|
||||||
viewport_height: AtomicUsize,
|
viewport_height: AtomicUsize,
|
||||||
filter: String,
|
filter: String,
|
||||||
filtering: bool,
|
filtering: bool,
|
||||||
tick: AtomicU8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UsersScreen {
|
impl UsersScreen {
|
||||||
|
|
@ -75,7 +74,6 @@ impl UsersScreen {
|
||||||
viewport_height: AtomicUsize::new(1),
|
viewport_height: AtomicUsize::new(1),
|
||||||
filter: String::new(),
|
filter: String::new(),
|
||||||
filtering: false,
|
filtering: false,
|
||||||
tick: AtomicU8::new(0),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,12 +115,7 @@ impl UsersScreen {
|
||||||
};
|
};
|
||||||
|
|
||||||
if self.loading {
|
if self.loading {
|
||||||
const SPINNERS: &[u8] = b"|/-\\";
|
f.render_widget(Paragraph::new("Loading users…"), inner);
|
||||||
let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()];
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(format!("{ch} Loading users…")),
|
|
||||||
inner,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if visible_indices.is_empty() {
|
if visible_indices.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -17,55 +17,6 @@ pub struct UiConfig {
|
||||||
pub color: TerminalPolicy,
|
pub color: TerminalPolicy,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub unicode: TerminalPolicy,
|
pub unicode: TerminalPolicy,
|
||||||
/// Default CLI output format for headless commands.
|
|
||||||
#[serde(default)]
|
|
||||||
pub cli_output: CliOutputFormat,
|
|
||||||
/// Whether destructive CLI operations require --yes by default.
|
|
||||||
#[serde(default = "default_false")]
|
|
||||||
pub cli_require_confirmation: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_false() -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum CliOutputFormat {
|
|
||||||
#[default]
|
|
||||||
Text,
|
|
||||||
Json,
|
|
||||||
Yaml,
|
|
||||||
Table,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CliOutputFormat {
|
|
||||||
pub fn all() -> &'static [CliOutputFormat] {
|
|
||||||
&[
|
|
||||||
CliOutputFormat::Text,
|
|
||||||
CliOutputFormat::Json,
|
|
||||||
CliOutputFormat::Yaml,
|
|
||||||
CliOutputFormat::Table,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn name(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
CliOutputFormat::Text => "text",
|
|
||||||
CliOutputFormat::Json => "json",
|
|
||||||
CliOutputFormat::Yaml => "yaml",
|
|
||||||
CliOutputFormat::Table => "table",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next(&self) -> Self {
|
|
||||||
match self {
|
|
||||||
CliOutputFormat::Text => CliOutputFormat::Json,
|
|
||||||
CliOutputFormat::Json => CliOutputFormat::Yaml,
|
|
||||||
CliOutputFormat::Yaml => CliOutputFormat::Table,
|
|
||||||
CliOutputFormat::Table => CliOutputFormat::Text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
|
@ -77,16 +28,6 @@ pub enum TerminalPolicy {
|
||||||
Never,
|
Never,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TerminalPolicy {
|
|
||||||
pub fn next(&self) -> Self {
|
|
||||||
match self {
|
|
||||||
TerminalPolicy::Auto => TerminalPolicy::Always,
|
|
||||||
TerminalPolicy::Always => TerminalPolicy::Never,
|
|
||||||
TerminalPolicy::Never => TerminalPolicy::Auto,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum DaemonStartPolicy {
|
pub enum DaemonStartPolicy {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -121,21 +62,8 @@ impl UiConfig {
|
||||||
pub fn path() -> PathBuf {
|
pub fn path() -> PathBuf {
|
||||||
iota_paths::config_dir().join("ui.yaml")
|
iota_paths::config_dir().join("ui.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fallback_path() -> Option<PathBuf> {
|
|
||||||
std::env::var_os("HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.map(|d| d.join(".config").join("iota").join("ui.yaml"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load() -> Result<Self, io::Error> {
|
pub fn load() -> Result<Self, io::Error> {
|
||||||
let path = match (|| std::panic::catch_unwind(|| Self::path()))() {
|
Self::load_from(&Self::path())
|
||||||
Ok(path) => path,
|
|
||||||
Err(_) => Self::fallback_path().ok_or_else(|| {
|
|
||||||
io::Error::new(io::ErrorKind::NotFound, "could not determine config path")
|
|
||||||
})?,
|
|
||||||
};
|
|
||||||
Self::load_from(&path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_from(path: &Path) -> Result<Self, io::Error> {
|
fn load_from(path: &Path) -> Result<Self, io::Error> {
|
||||||
|
|
@ -154,12 +82,6 @@ impl UiConfig {
|
||||||
fs::write(path, yaml)
|
fs::write(path, yaml)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load from config path, or return defaults if the config path can't be resolved.
|
|
||||||
/// This avoids panics when `IOTA_SOCKET` is not set (e.g. in unit tests).
|
|
||||||
pub fn load_or_default() -> Self {
|
|
||||||
Self::load().unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_theme(override_theme: Option<ThemeName>) -> ThemeName {
|
pub fn resolve_theme(override_theme: Option<ThemeName>) -> ThemeName {
|
||||||
Self::resolve_theme_from(
|
Self::resolve_theme_from(
|
||||||
override_theme,
|
override_theme,
|
||||||
|
|
@ -195,33 +117,6 @@ impl UiConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the default CLI output format from config file and environment.
|
|
||||||
/// Priority: IOTA_OUTPUT env var > config file > "text" default.
|
|
||||||
pub fn resolve_cli_output(&self) -> CliOutputFormat {
|
|
||||||
if let Ok(value) = std::env::var("IOTA_OUTPUT") {
|
|
||||||
match value.to_ascii_lowercase().as_str() {
|
|
||||||
"json" => return CliOutputFormat::Json,
|
|
||||||
"yaml" | "yml" => return CliOutputFormat::Yaml,
|
|
||||||
"table" => return CliOutputFormat::Table,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.cli_output
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the default --yes behavior from config file and environment.
|
|
||||||
/// Priority: IOTA_YES env var > config file > false default.
|
|
||||||
pub fn resolve_cli_require_confirmation(&self) -> bool {
|
|
||||||
if let Ok(value) = std::env::var("IOTA_YES") {
|
|
||||||
match value.to_ascii_lowercase().as_str() {
|
|
||||||
"1" | "true" | "yes" | "y" => return false,
|
|
||||||
"0" | "false" | "no" | "n" => return true,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.cli_require_confirmation
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -278,37 +173,4 @@ mod tests {
|
||||||
ThemeName::Ansi
|
ThemeName::Ansi
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cli_output_defaults_to_text() {
|
|
||||||
let config = UiConfig::default();
|
|
||||||
assert_eq!(config.resolve_cli_output(), CliOutputFormat::Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cli_output_cycles_through_variants() {
|
|
||||||
assert_eq!(CliOutputFormat::Text.next(), CliOutputFormat::Json);
|
|
||||||
assert_eq!(CliOutputFormat::Json.next(), CliOutputFormat::Yaml);
|
|
||||||
assert_eq!(CliOutputFormat::Yaml.next(), CliOutputFormat::Table);
|
|
||||||
assert_eq!(CliOutputFormat::Table.next(), CliOutputFormat::Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn require_confirmation_defaults_to_false() {
|
|
||||||
let config = UiConfig::default();
|
|
||||||
assert!(!config.resolve_cli_require_confirmation());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cli_output_serializes_roundtrip() {
|
|
||||||
let config = UiConfig {
|
|
||||||
cli_output: CliOutputFormat::Table,
|
|
||||||
cli_require_confirmation: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let yaml = serde_yaml::to_string(&config).unwrap();
|
|
||||||
let loaded: UiConfig = serde_yaml::from_str(&yaml).unwrap();
|
|
||||||
assert_eq!(loaded.cli_output, CliOutputFormat::Table);
|
|
||||||
assert!(loaded.cli_require_confirmation);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ mod model;
|
||||||
mod name;
|
mod name;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
|
||||||
pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig};
|
pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig};
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
pub use name::ThemeName;
|
pub use name::ThemeName;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
controls::header::render_header,
|
controls::header::render_header,
|
||||||
help_overlay::HelpOverlay,
|
|
||||||
input_handler::setup_input_handler,
|
input_handler::setup_input_handler,
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
ipc_client::{DaemonStatus, IpcClient, IpcConnectionState},
|
ipc_client::IpcClient,
|
||||||
notification::{Notification, render_notification_area},
|
|
||||||
render_context::RenderContext,
|
render_context::RenderContext,
|
||||||
screens::{
|
screens::{
|
||||||
main_screen::MainScreen,
|
main_screen::MainScreen,
|
||||||
|
|
@ -23,7 +21,7 @@ use once_cell::sync::Lazy;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Terminal,
|
Terminal,
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout},
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
io,
|
io,
|
||||||
|
|
@ -55,7 +53,6 @@ pub struct UI {
|
||||||
app_event_tx: mpsc::UnboundedSender<UiEvent>,
|
app_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||||
app_event_rx: Mutex<Option<mpsc::UnboundedReceiver<UiEvent>>>,
|
app_event_rx: Mutex<Option<mpsc::UnboundedReceiver<UiEvent>>>,
|
||||||
header_focus: Mutex<Option<usize>>,
|
header_focus: Mutex<Option<usize>>,
|
||||||
notifications: Arc<Mutex<Vec<Notification>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
|
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
|
||||||
|
|
@ -240,7 +237,6 @@ impl UI {
|
||||||
app_event_tx,
|
app_event_tx,
|
||||||
app_event_rx: Mutex::new(Some(app_event_rx)),
|
app_event_rx: Mutex::new(Some(app_event_rx)),
|
||||||
header_focus: Mutex::new(None),
|
header_focus: Mutex::new(None),
|
||||||
notifications: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,27 +284,6 @@ impl UI {
|
||||||
self.cancellation.clone()
|
self.cancellation.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_notification(&self, notification: Notification) {
|
|
||||||
if let Ok(mut notifications) = self.notifications.lock() {
|
|
||||||
notifications.push(notification);
|
|
||||||
self.invalidate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn clear_expired_notifications(&self) {
|
|
||||||
if let Ok(mut notifications) = self.notifications.lock() {
|
|
||||||
let before = notifications.len();
|
|
||||||
notifications.retain(|n| !n.is_expired());
|
|
||||||
if notifications.len() != before {
|
|
||||||
self.invalidate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn notifications(&self) -> Vec<Notification> {
|
|
||||||
self.notifications.lock().map(|n| n.clone()).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_screen(&self, screen: Box<dyn Screen>) {
|
pub async fn set_screen(&self, screen: Box<dyn Screen>) {
|
||||||
self.screen_stack.write().await.push(screen);
|
self.screen_stack.write().await.push(screen);
|
||||||
self.invalidate();
|
self.invalidate();
|
||||||
|
|
@ -357,8 +332,6 @@ impl UI {
|
||||||
theme,
|
theme,
|
||||||
color,
|
color,
|
||||||
unicode,
|
unicode,
|
||||||
cli_output,
|
|
||||||
cli_require_confirmation,
|
|
||||||
}) = &event
|
}) = &event
|
||||||
{
|
{
|
||||||
self.set_theme(theme::resolve(*theme)).await;
|
self.set_theme(theme::resolve(*theme)).await;
|
||||||
|
|
@ -366,8 +339,6 @@ impl UI {
|
||||||
config.theme = *theme;
|
config.theme = *theme;
|
||||||
config.color = *color;
|
config.color = *color;
|
||||||
config.unicode = *unicode;
|
config.unicode = *unicode;
|
||||||
config.cli_output = *cli_output;
|
|
||||||
config.cli_require_confirmation = *cli_require_confirmation;
|
|
||||||
let result = config
|
let result = config
|
||||||
.save()
|
.save()
|
||||||
.map_err(|error| format!("Could not save UI settings: {error}"));
|
.map_err(|error| format!("Could not save UI settings: {error}"));
|
||||||
|
|
@ -414,18 +385,6 @@ impl UI {
|
||||||
self.invalidate();
|
self.invalidate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if key.code == KeyCode::Char('?') {
|
|
||||||
let has_help_overlay = self
|
|
||||||
.screen_stack
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.any(|s| s.as_any().downcast_ref::<HelpOverlay>().is_some());
|
|
||||||
if !has_help_overlay {
|
|
||||||
self.set_screen(Box::new(HelpOverlay::new())).await;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if header_is_focused {
|
if header_is_focused {
|
||||||
let mut action = None;
|
let mut action = None;
|
||||||
if let Ok(mut focus) = self.header_focus.lock() {
|
if let Ok(mut focus) = self.header_focus.lock() {
|
||||||
|
|
@ -592,61 +551,35 @@ impl UI {
|
||||||
self.set_screen(Box::new(UsersScreen::loading(ipc.clone())))
|
self.set_screen(Box::new(UsersScreen::loading(ipc.clone())))
|
||||||
.await;
|
.await;
|
||||||
let sender = self.app_event_tx.clone();
|
let sender = self.app_event_tx.clone();
|
||||||
let ui = self.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let load = async {
|
let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await {
|
||||||
match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await {
|
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => {
|
||||||
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => {
|
Ok(users
|
||||||
Ok(users
|
.into_iter()
|
||||||
.into_iter()
|
.map(|u| UserEntry {
|
||||||
.map(|u| UserEntry {
|
user_id: u.user_id,
|
||||||
user_id: u.user_id,
|
username: u.username,
|
||||||
username: u.username,
|
})
|
||||||
})
|
.collect())
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
|
||||||
Err(format!("Cannot load users: {error}"))
|
|
||||||
}
|
|
||||||
Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()),
|
|
||||||
Err(error) => Err(format!("Cannot load users: {error}")),
|
|
||||||
}
|
}
|
||||||
};
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
||||||
tokio::pin!(load);
|
Err(format!("Cannot load users: {error}"))
|
||||||
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(200));
|
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
||||||
let result = loop {
|
|
||||||
tokio::select! {
|
|
||||||
result = &mut load => break result,
|
|
||||||
_ = ticker.tick() => {
|
|
||||||
ui.invalidate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()),
|
||||||
|
Err(error) => Err(format!("Cannot load users: {error}")),
|
||||||
};
|
};
|
||||||
let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result)));
|
let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn render(&self) -> io::Result<()> {
|
pub async fn render(&self) -> io::Result<()> {
|
||||||
self.clear_expired_notifications().await;
|
|
||||||
let theme = self.theme.read().await.clone();
|
let theme = self.theme.read().await.clone();
|
||||||
let context = RenderContext {
|
let context = RenderContext {
|
||||||
theme: theme.as_ref(),
|
theme: theme.as_ref(),
|
||||||
};
|
};
|
||||||
// The renderer is the only task that takes the terminal lock. Screen
|
// The renderer is the only task that takes the terminal lock. Screen
|
||||||
// mutations use the stack lock briefly before invalidating a frame.
|
// mutations use the stack lock briefly before invalidating a frame.
|
||||||
let stack_guard = self.screen_stack.read().await;
|
if let Some(screen) = self.screen_stack.read().await.last() {
|
||||||
let (connection, daemon) = stack_guard
|
|
||||||
.iter()
|
|
||||||
.find_map(|item| item.as_any().downcast_ref::<MainScreen>())
|
|
||||||
.map(|main| {
|
|
||||||
(
|
|
||||||
main.connection_status().borrow().clone(),
|
|
||||||
main.daemon_status().borrow().clone(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| (IpcConnectionState::Disconnected, DaemonStatus::default()));
|
|
||||||
if let Some(screen) = stack_guard.last() {
|
|
||||||
let mut terminal = self
|
let mut terminal = self
|
||||||
.terminal
|
.terminal
|
||||||
.lock()
|
.lock()
|
||||||
|
|
@ -659,12 +592,22 @@ impl UI {
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
])
|
])
|
||||||
.split(f.area());
|
.split(f.area());
|
||||||
|
let header_title = self
|
||||||
|
.screen_stack
|
||||||
|
.try_read()
|
||||||
|
.ok()
|
||||||
|
.and_then(|stack| {
|
||||||
|
stack
|
||||||
|
.iter()
|
||||||
|
.find_map(|item| item.as_any().downcast_ref::<MainScreen>())
|
||||||
|
.map(|main| main.app_title())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| screen.app_title());
|
||||||
let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus);
|
let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus);
|
||||||
render_header(
|
render_header(
|
||||||
f,
|
f,
|
||||||
rows[0],
|
rows[0],
|
||||||
&connection,
|
&header_title,
|
||||||
&daemon,
|
|
||||||
context.theme,
|
context.theme,
|
||||||
&mut hits,
|
&mut hits,
|
||||||
header_focus,
|
header_focus,
|
||||||
|
|
@ -672,15 +615,12 @@ impl UI {
|
||||||
let hints = if header_focus.is_some() {
|
let hints = if header_focus.is_some() {
|
||||||
" Left/Right: choose Enter: activate Esc/F6: screen".to_owned()
|
" Left/Right: choose Enter: activate Esc/F6: screen".to_owned()
|
||||||
} else {
|
} else {
|
||||||
let mut screen_hints: Vec<String> = screen
|
screen
|
||||||
.key_hints()
|
.key_hints()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|hint| format!("{}: {}", hint.keys, hint.action))
|
.map(|hint| format!("{}: {}", hint.keys, hint.action))
|
||||||
.collect();
|
.collect::<Vec<_>>()
|
||||||
if !screen_hints.iter().any(|h| h.contains("?")) {
|
.join(" ")
|
||||||
screen_hints.push("?: Help".to_owned());
|
|
||||||
}
|
|
||||||
screen_hints.join(" ")
|
|
||||||
};
|
};
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
ratatui::widgets::Paragraph::new(format!(" {hints}")).style(
|
ratatui::widgets::Paragraph::new(format!(" {hints}")).style(
|
||||||
|
|
@ -693,18 +633,6 @@ impl UI {
|
||||||
rows[2],
|
rows[2],
|
||||||
);
|
);
|
||||||
screen.render(f, rows[1], &context, &mut hits);
|
screen.render(f, rows[1], &context, &mut hits);
|
||||||
|
|
||||||
if let Ok(notifications) = self.notifications.try_lock() {
|
|
||||||
if !notifications.is_empty() {
|
|
||||||
let notification_area = Rect {
|
|
||||||
x: rows[1].x + rows[1].width.saturating_sub(40),
|
|
||||||
y: rows[1].y,
|
|
||||||
width: 40.min(rows[1].width),
|
|
||||||
height: 3.min(rows[1].height),
|
|
||||||
};
|
|
||||||
render_notification_area(f, notification_area, ¬ifications, context.theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})?;
|
})?;
|
||||||
if let Ok(mut current) = self.hits.lock() {
|
if let Ok(mut current) = self.hits.lock() {
|
||||||
*current = hits;
|
*current = hits;
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,7 @@ async fn settings_preview_and_save_emit_typed_application_events() {
|
||||||
UiEvent::App(AppEvent::SaveSettings {
|
UiEvent::App(AppEvent::SaveSettings {
|
||||||
theme: ThemeName::Surface,
|
theme: ThemeName::Surface,
|
||||||
color: _,
|
color: _,
|
||||||
unicode: _,
|
unicode: _
|
||||||
cli_output: _,
|
|
||||||
cli_require_confirmation: _
|
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
|
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
|
||||||
use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig};
|
use iota_cli::theme::ThemeName;
|
||||||
use iota_terms::TermsType;
|
use iota_terms::TermsType;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -23,7 +23,6 @@ pub enum OutputFormat {
|
||||||
Text,
|
Text,
|
||||||
Json,
|
Json,
|
||||||
Yaml,
|
Yaml,
|
||||||
Table,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
|
|
@ -44,17 +43,6 @@ impl From<CliTheme> for ThemeName {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<CliOutputFormat> for OutputFormat {
|
|
||||||
fn from(value: CliOutputFormat) -> Self {
|
|
||||||
match value {
|
|
||||||
CliOutputFormat::Text => OutputFormat::Text,
|
|
||||||
CliOutputFormat::Json => OutputFormat::Json,
|
|
||||||
CliOutputFormat::Yaml => OutputFormat::Yaml,
|
|
||||||
CliOutputFormat::Table => OutputFormat::Table,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "iota",
|
name = "iota",
|
||||||
|
|
@ -324,11 +312,6 @@ impl CliInvocation {
|
||||||
if args.as_slice() == ["help"] {
|
if args.as_slice() == ["help"] {
|
||||||
return Ok(Self::special(Command::Help));
|
return Ok(Self::special(Command::Help));
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = UiConfig::load_or_default();
|
|
||||||
let config_output: OutputFormat = config.cli_output.into();
|
|
||||||
let require_confirmation = config.resolve_cli_require_confirmation();
|
|
||||||
|
|
||||||
let parsed =
|
let parsed =
|
||||||
Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| {
|
Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| {
|
||||||
match error.kind() {
|
match error.kind() {
|
||||||
|
|
@ -343,20 +326,6 @@ impl CliInvocation {
|
||||||
Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)),
|
Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)),
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
};
|
};
|
||||||
|
|
||||||
let output = if parsed.output == OutputFormat::Text {
|
|
||||||
config_output
|
|
||||||
} else {
|
|
||||||
parsed.output
|
|
||||||
};
|
|
||||||
|
|
||||||
let resolve_confirmed = |yes_flag: bool| -> bool {
|
|
||||||
if yes_flag {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
!require_confirmation
|
|
||||||
};
|
|
||||||
|
|
||||||
let command = match parsed.command {
|
let command = match parsed.command {
|
||||||
None => Command::Dashboard,
|
None => Command::Dashboard,
|
||||||
Some(CliCommand::Status) => Command::Status,
|
Some(CliCommand::Status) => Command::Status,
|
||||||
|
|
@ -370,7 +339,7 @@ impl CliInvocation {
|
||||||
UsersAction::Add { username } => Command::UsersAdd { username },
|
UsersAction::Add { username } => Command::UsersAdd { username },
|
||||||
UsersAction::Remove { user_id, yes } => Command::UsersRemove {
|
UsersAction::Remove { user_id, yes } => Command::UsersRemove {
|
||||||
user_id,
|
user_id,
|
||||||
confirmed: resolve_confirmed(yes),
|
confirmed: yes,
|
||||||
},
|
},
|
||||||
UsersAction::Import { username } => Command::UsersImport { username },
|
UsersAction::Import { username } => Command::UsersImport { username },
|
||||||
},
|
},
|
||||||
|
|
@ -379,18 +348,14 @@ impl CliInvocation {
|
||||||
OmikronAction::Status => Command::OmikronStatus,
|
OmikronAction::Status => Command::OmikronStatus,
|
||||||
},
|
},
|
||||||
Some(CliCommand::Identity(identity)) => match identity.action {
|
Some(CliCommand::Identity(identity)) => match identity.action {
|
||||||
IdentityAction::Rotate { yes } => Command::IdentityRotate {
|
IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes },
|
||||||
confirmed: resolve_confirmed(yes),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Some(CliCommand::Config(config)) => match config.action {
|
Some(CliCommand::Config(config)) => match config.action {
|
||||||
ConfigAction::Get => Command::ConfigGet,
|
ConfigAction::Get => Command::ConfigGet,
|
||||||
ConfigAction::Set { key, value } => Command::ConfigSet { key, value },
|
ConfigAction::Set { key, value } => Command::ConfigSet { key, value },
|
||||||
ConfigAction::Reload => Command::ConfigReload,
|
ConfigAction::Reload => Command::ConfigReload,
|
||||||
},
|
},
|
||||||
Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys {
|
Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes },
|
||||||
confirmed: resolve_confirmed(yes),
|
|
||||||
},
|
|
||||||
Some(CliCommand::Logs { limit }) => Command::Logs { limit },
|
Some(CliCommand::Logs { limit }) => Command::Logs { limit },
|
||||||
Some(CliCommand::Update(update)) => match update.action {
|
Some(CliCommand::Update(update)) => match update.action {
|
||||||
UpdateAction::Check => Command::UpdateCheck,
|
UpdateAction::Check => Command::UpdateCheck,
|
||||||
|
|
@ -406,12 +371,8 @@ impl CliInvocation {
|
||||||
TermsAction::Accept { system } => Command::TermsAccept { system },
|
TermsAction::Accept { system } => Command::TermsAccept { system },
|
||||||
},
|
},
|
||||||
Some(CliCommand::Daemon(daemon)) => match daemon.action {
|
Some(CliCommand::Daemon(daemon)) => match daemon.action {
|
||||||
DaemonAction::Restart { yes } => Command::DaemonRestart {
|
DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes },
|
||||||
confirmed: resolve_confirmed(yes),
|
DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes },
|
||||||
},
|
|
||||||
DaemonAction::Stop { yes } => Command::DaemonStop {
|
|
||||||
confirmed: resolve_confirmed(yes),
|
|
||||||
},
|
|
||||||
DaemonAction::Enable { mode } => Command::DaemonEnable { mode },
|
DaemonAction::Enable { mode } => Command::DaemonEnable { mode },
|
||||||
DaemonAction::DisableStartup => Command::DaemonDisableStartup,
|
DaemonAction::DisableStartup => Command::DaemonDisableStartup,
|
||||||
DaemonAction::Status => Command::DaemonDaemonStatus,
|
DaemonAction::Status => Command::DaemonDaemonStatus,
|
||||||
|
|
@ -424,7 +385,7 @@ impl CliInvocation {
|
||||||
};
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
theme_override: parsed.theme.map(Into::into),
|
theme_override: parsed.theme.map(Into::into),
|
||||||
output,
|
output: parsed.output,
|
||||||
color: if parsed.no_color {
|
color: if parsed.no_color {
|
||||||
CapabilityPolicy::Never
|
CapabilityPolicy::Never
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -558,12 +519,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_unconfirmed_destructive_commands_explicitly() {
|
fn parses_unconfirmed_destructive_commands_explicitly() {
|
||||||
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
|
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(invocation.command, Command::DaemonStop { confirmed: false });
|
||||||
invocation.command,
|
|
||||||
Command::DaemonStop {
|
|
||||||
confirmed: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -604,7 +560,7 @@ mod tests {
|
||||||
invocation.command,
|
invocation.command,
|
||||||
Command::UsersRemove {
|
Command::UsersRemove {
|
||||||
user_id: 42,
|
user_id: 42,
|
||||||
confirmed: true,
|
confirmed: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -634,7 +590,7 @@ mod tests {
|
||||||
let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap();
|
let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
invocation.command,
|
invocation.command,
|
||||||
Command::IdentityRotate { confirmed: true }
|
Command::IdentityRotate { confirmed: false }
|
||||||
);
|
);
|
||||||
let invocation =
|
let invocation =
|
||||||
CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap();
|
CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
use std::env;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct ColorConfig {
|
|
||||||
pub enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ColorConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ColorConfig {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let enabled = env::var("NO_COLOR").is_err()
|
|
||||||
&& env::var("TERM")
|
|
||||||
.map(|t| t != "dumb")
|
|
||||||
.unwrap_or(true);
|
|
||||||
Self { enabled }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn colorize(&self, text: &str, style: Style) -> String {
|
|
||||||
if !self.enabled {
|
|
||||||
return text.to_string();
|
|
||||||
}
|
|
||||||
format!("{}{}\x1b[0m", style.prefix(), text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct Style {
|
|
||||||
pub fg: Option<u8>,
|
|
||||||
pub bg: Option<u8>,
|
|
||||||
pub bold: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Style {
|
|
||||||
pub const fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
fg: None,
|
|
||||||
bg: None,
|
|
||||||
bold: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn fg(mut self, color: u8) -> Self {
|
|
||||||
self.fg = Some(color);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn bold(mut self) -> Self {
|
|
||||||
self.bold = true;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prefix(&self) -> String {
|
|
||||||
let mut codes = Vec::new();
|
|
||||||
if self.bold {
|
|
||||||
codes.push("1".to_string());
|
|
||||||
}
|
|
||||||
if let Some(fg) = self.fg {
|
|
||||||
codes.push(format!("3{}", fg));
|
|
||||||
}
|
|
||||||
if let Some(bg) = self.bg {
|
|
||||||
codes.push(format!("4{}", bg));
|
|
||||||
}
|
|
||||||
if codes.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("\x1b[{}m", codes.join(";"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const SUCCESS: Style = Style::new().fg(2);
|
|
||||||
pub const WARNING: Style = Style::new().fg(3);
|
|
||||||
pub const ERROR: Style = Style::new().fg(1);
|
|
||||||
pub const INFO: Style = Style::new().fg(4);
|
|
||||||
pub const MUTED: Style = Style::new().fg(8);
|
|
||||||
pub const HEADING: Style = Style::new().bold();
|
|
||||||
|
|
||||||
pub fn success(config: &ColorConfig, text: &str) -> String {
|
|
||||||
config.colorize(text, SUCCESS)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn warning(config: &ColorConfig, text: &str) -> String {
|
|
||||||
config.colorize(text, WARNING)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error(config: &ColorConfig, text: &str) -> String {
|
|
||||||
config.colorize(text, ERROR)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn info(config: &ColorConfig, text: &str) -> String {
|
|
||||||
config.colorize(text, INFO)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn muted(config: &ColorConfig, text: &str) -> String {
|
|
||||||
config.colorize(text, MUTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn heading(config: &ColorConfig, text: &str) -> String {
|
|
||||||
config.colorize(text, HEADING)
|
|
||||||
}
|
|
||||||
367
iota/src/main.rs
367
iota/src/main.rs
|
|
@ -7,14 +7,12 @@ use iota_process_manager::detect;
|
||||||
use std::{path::Path, process::ExitCode, sync::Arc};
|
use std::{path::Path, process::ExitCode, sync::Arc};
|
||||||
|
|
||||||
mod cli_args;
|
mod cli_args;
|
||||||
mod cli_color;
|
|
||||||
mod daemon_setup_flow;
|
mod daemon_setup_flow;
|
||||||
mod local_daemon;
|
mod local_daemon;
|
||||||
mod startup_error;
|
mod startup_error;
|
||||||
mod terms;
|
mod terms;
|
||||||
|
|
||||||
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
|
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
|
||||||
use cli_color::ColorConfig;
|
|
||||||
use startup_error::StartupError;
|
use startup_error::StartupError;
|
||||||
|
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
|
|
@ -23,7 +21,7 @@ async fn main() -> ExitCode {
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if !matches!(error, StartupError::Cancelled) {
|
if !matches!(error, StartupError::Cancelled) {
|
||||||
startup_error::print_error(&error);
|
eprintln!("{error}");
|
||||||
}
|
}
|
||||||
startup_error::exit_code(&error)
|
startup_error::exit_code(&error)
|
||||||
}
|
}
|
||||||
|
|
@ -385,93 +383,7 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
let color = cli_color::ColorConfig::new();
|
println!("{}", CliInvocation::help_text());
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
cli_color::heading(&color, "Iota Operator Console")
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
println!("Usage: iota [OPTIONS] [COMMAND]");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
cli_color::info(&color, "Commands:")
|
|
||||||
);
|
|
||||||
println!(" (no command) Launch the interactive dashboard");
|
|
||||||
println!(" status Show daemon status");
|
|
||||||
println!(" tasks List active tasks");
|
|
||||||
println!(" users list List all users");
|
|
||||||
println!(" users show <ID> Show user details");
|
|
||||||
println!(" users add <NAME> Create a new user");
|
|
||||||
println!(" users remove <ID> Remove a user (requires --yes)");
|
|
||||||
println!(" omikron status Show Omikron connection status");
|
|
||||||
println!(" omikron reconnect Reconnect to Omikron");
|
|
||||||
println!(" identity rotate Rotate identity keys (requires --yes)");
|
|
||||||
println!(" config get Show current configuration");
|
|
||||||
println!(" config set <KEY> <VAL> Set a configuration value");
|
|
||||||
println!(" config reload Reload configuration");
|
|
||||||
println!(" components Show component health");
|
|
||||||
println!(" logs [--limit N] Show recent log entries");
|
|
||||||
println!(" update check Check for updates");
|
|
||||||
println!(" community list List communities");
|
|
||||||
println!(" terms status Show terms acceptance status");
|
|
||||||
println!(" terms show <DOC> Show a terms document");
|
|
||||||
println!(" terms accept Accept required terms");
|
|
||||||
println!(" daemon restart Restart the daemon (requires --yes)");
|
|
||||||
println!(" daemon stop Stop the daemon (requires --yes)");
|
|
||||||
println!(" daemon enable Enable daemon at startup");
|
|
||||||
println!(" daemon disable-startup Disable daemon at startup");
|
|
||||||
println!(" daemon startup-status Show startup configuration");
|
|
||||||
println!(" daemon start Start the daemon");
|
|
||||||
println!(" daemon restart-service Restart the daemon service");
|
|
||||||
println!(" daemon stop-service Stop the daemon service");
|
|
||||||
println!(" daemon install Install from a bundle");
|
|
||||||
println!(" help Show this help message");
|
|
||||||
println!(" completions <SHELL> Generate shell completions");
|
|
||||||
println!(" man Show the man page");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
cli_color::info(&color, "Options:")
|
|
||||||
);
|
|
||||||
println!(" --theme <THEME> Theme: monospace, binary, ansi, surface");
|
|
||||||
println!(" --output <FORMAT> Output format: text, json, yaml, table");
|
|
||||||
println!(" --color <WHEN> Color: auto, always, never");
|
|
||||||
println!(" --unicode <WHEN> Unicode: auto, always, never");
|
|
||||||
println!(" --no-color Disable colored output");
|
|
||||||
println!(" --yes, -y Confirm destructive operations");
|
|
||||||
println!(" -h, --help Show help");
|
|
||||||
println!(" -V, --version Show version");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
cli_color::info(&color, "Examples:")
|
|
||||||
);
|
|
||||||
println!(" iota Launch the interactive dashboard");
|
|
||||||
println!(" iota status Show daemon status");
|
|
||||||
println!(" iota users list --output=json List users in JSON format");
|
|
||||||
println!(" iota users add alice Create a user named 'alice'");
|
|
||||||
println!(" iota users remove 42 --yes Remove user 42");
|
|
||||||
println!(" iota config get --output=yaml Show config in YAML format");
|
|
||||||
println!(" iota logs --limit 50 Show last 50 log entries");
|
|
||||||
println!(" iota completions bash Generate bash completions");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
cli_color::info(&color, "Exit Codes:")
|
|
||||||
);
|
|
||||||
println!(" 0 Success");
|
|
||||||
println!(" 1 General error");
|
|
||||||
println!(" 2 Invalid command or arguments");
|
|
||||||
println!(" 130 Interrupted (Ctrl+C)");
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
cli_color::muted(&color, "Environment Variables:")
|
|
||||||
);
|
|
||||||
println!(" NO_COLOR Disable colored output when set");
|
|
||||||
println!(" TERM Terminal type (dumb disables colors)");
|
|
||||||
println!(" IOTA_THEME Default theme override");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_completions(shell: &str) -> Result<(), StartupError> {
|
fn print_completions(shell: &str) -> Result<(), StartupError> {
|
||||||
|
|
@ -510,47 +422,14 @@ fn print_man_page() {
|
||||||
println!(".TH IOTA 1");
|
println!(".TH IOTA 1");
|
||||||
println!(".SH NAME\n iota \\- Iota operator console");
|
println!(".SH NAME\n iota \\- Iota operator console");
|
||||||
println!(".SH SYNOPSIS\n.B iota\n[global options] [command]");
|
println!(".SH SYNOPSIS\n.B iota\n[global options] [command]");
|
||||||
println!(".SH DESCRIPTION");
|
|
||||||
println!("Iota is the operator console for managing Iota daemon instances.");
|
|
||||||
println!("It provides both an interactive dashboard and headless CLI commands.");
|
|
||||||
println!(".SH COMMANDS");
|
println!(".SH COMMANDS");
|
||||||
for command in CliInvocation::command_paths() {
|
for command in CliInvocation::command_paths() {
|
||||||
println!(".TP\n.B {command}");
|
println!(".TP\n.B {command}");
|
||||||
}
|
}
|
||||||
println!(".SH GLOBAL OPTIONS");
|
println!(".SH GLOBAL OPTIONS");
|
||||||
println!(".TP\n.B --output text|json|yaml|table");
|
println!(".TP\n.B --output text|json|yaml");
|
||||||
println!("Set the output format for headless commands.");
|
|
||||||
println!(".TP\n.B --color auto|always|never");
|
println!(".TP\n.B --color auto|always|never");
|
||||||
println!("Control colored output.");
|
|
||||||
println!(".TP\n.B --unicode auto|always|never");
|
println!(".TP\n.B --unicode auto|always|never");
|
||||||
println!("Control Unicode character rendering.");
|
|
||||||
println!(".TP\n.B --yes, -y");
|
|
||||||
println!("Confirm destructive operations without prompting.");
|
|
||||||
println!(".SH EXIT CODES");
|
|
||||||
println!(".TP\n.B 0");
|
|
||||||
println!("Success");
|
|
||||||
println!(".TP\n.B 1");
|
|
||||||
println!("General error");
|
|
||||||
println!(".TP\n.B 2");
|
|
||||||
println!("Invalid command or arguments");
|
|
||||||
println!(".TP\n.B 130");
|
|
||||||
println!("Interrupted (Ctrl+C)");
|
|
||||||
println!(".SH EXAMPLES");
|
|
||||||
println!(".TP\n.B iota");
|
|
||||||
println!("Launch the interactive dashboard");
|
|
||||||
println!(".TP\n.B iota status");
|
|
||||||
println!("Show daemon status");
|
|
||||||
println!(".TP\n.B iota users list --output=json");
|
|
||||||
println!("List users in JSON format");
|
|
||||||
println!(".TP\n.B iota users add alice");
|
|
||||||
println!("Create a user named 'alice'");
|
|
||||||
println!(".SH ENVIRONMENT");
|
|
||||||
println!(".TP\n.B NO_COLOR");
|
|
||||||
println!("Disable colored output when set");
|
|
||||||
println!(".TP\n.B TERM");
|
|
||||||
println!("Terminal type (dumb disables colors)");
|
|
||||||
println!(".TP\n.B IOTA_THEME");
|
|
||||||
println!("Default theme override");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_command(
|
async fn run_command(
|
||||||
|
|
@ -558,7 +437,6 @@ async fn run_command(
|
||||||
command: Command,
|
command: Command,
|
||||||
output: OutputFormat,
|
output: OutputFormat,
|
||||||
) -> Result<(), StartupError> {
|
) -> Result<(), StartupError> {
|
||||||
let color = ColorConfig::new();
|
|
||||||
let request = match command {
|
let request = match command {
|
||||||
Command::Status => LocalRequest::GetStatus,
|
Command::Status => LocalRequest::GetStatus,
|
||||||
Command::Tasks => LocalRequest::ListTasks,
|
Command::Tasks => LocalRequest::ListTasks,
|
||||||
|
|
@ -630,31 +508,18 @@ async fn run_command(
|
||||||
}
|
}
|
||||||
match payload {
|
match payload {
|
||||||
ResponsePayload::Status(status) => {
|
ResponsePayload::Status(status) => {
|
||||||
let phase_color = if status.degraded_reason.is_some() {
|
print!("Phase: {}", status.phase);
|
||||||
cli_color::WARNING
|
|
||||||
} else {
|
|
||||||
cli_color::SUCCESS
|
|
||||||
};
|
|
||||||
print!(
|
|
||||||
"{} {}",
|
|
||||||
cli_color::info(&color, "Phase:"),
|
|
||||||
color.colorize(&status.phase, phase_color)
|
|
||||||
);
|
|
||||||
if !status.tasks.is_empty() {
|
if !status.tasks.is_empty() {
|
||||||
print!(
|
print!(", Tasks: {}", status.tasks.join(", "));
|
||||||
", {} {}",
|
|
||||||
cli_color::info(&color, "Tasks:"),
|
|
||||||
status.tasks.join(", ")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if let Some(reason) = status.degraded_reason {
|
if let Some(reason) = status.degraded_reason {
|
||||||
print!(", {}: {}", cli_color::warning(&color, "Degraded"), reason);
|
print!(", Degraded: {}", reason);
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
ResponsePayload::Tasks(tasks) => {
|
ResponsePayload::Tasks(tasks) => {
|
||||||
if tasks.is_empty() {
|
if tasks.is_empty() {
|
||||||
println!("{}", cli_color::muted(&color, "No active tasks."));
|
println!("No active tasks.");
|
||||||
} else {
|
} else {
|
||||||
for task in &tasks {
|
for task in &tasks {
|
||||||
println!("{}", task.name);
|
println!("{}", task.name);
|
||||||
|
|
@ -663,31 +528,18 @@ async fn run_command(
|
||||||
}
|
}
|
||||||
ResponsePayload::Users(users) => {
|
ResponsePayload::Users(users) => {
|
||||||
if users.is_empty() {
|
if users.is_empty() {
|
||||||
println!("{}", cli_color::muted(&color, "No users."));
|
println!("No users.");
|
||||||
} else {
|
} else {
|
||||||
for user in &users {
|
for user in &users {
|
||||||
println!(
|
println!("{} ({})", user.username, user.user_id);
|
||||||
"{} ({})",
|
|
||||||
cli_color::heading(&color, &user.username),
|
|
||||||
user.user_id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResponsePayload::UserCreated { user_id, username } => {
|
ResponsePayload::UserCreated { user_id, username } => {
|
||||||
println!(
|
println!("Created user {} ({})", username, user_id);
|
||||||
"{} {} ({})",
|
|
||||||
cli_color::success(&color, "Created user"),
|
|
||||||
cli_color::heading(&color, &username),
|
|
||||||
user_id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ResponsePayload::UserRemoved { user_id } => {
|
ResponsePayload::UserRemoved { user_id } => {
|
||||||
println!(
|
println!("Removed user {}", user_id);
|
||||||
"{} {}",
|
|
||||||
cli_color::warning(&color, "Removed user"),
|
|
||||||
user_id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ResponsePayload::Acknowledged { message } => {
|
ResponsePayload::Acknowledged { message } => {
|
||||||
println!("{}", message);
|
println!("{}", message);
|
||||||
|
|
@ -699,57 +551,32 @@ async fn run_command(
|
||||||
println!("{}", config.yaml);
|
println!("{}", config.yaml);
|
||||||
}
|
}
|
||||||
ResponsePayload::OmikronStatus(status) => {
|
ResponsePayload::OmikronStatus(status) => {
|
||||||
println!(
|
println!("Connected: {}", status.connected);
|
||||||
"{}: {}",
|
|
||||||
cli_color::info(&color, "Connected"),
|
|
||||||
status.connected
|
|
||||||
);
|
|
||||||
if let Some(id) = status.iota_id {
|
if let Some(id) = status.iota_id {
|
||||||
println!(
|
println!("Iota ID: {}", id);
|
||||||
"{}: {}",
|
|
||||||
cli_color::info(&color, "Iota ID"),
|
|
||||||
id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResponsePayload::Components(components) => {
|
ResponsePayload::Components(components) => {
|
||||||
if components.is_empty() {
|
if components.is_empty() {
|
||||||
println!(
|
println!("No component health data available.");
|
||||||
"{}",
|
|
||||||
cli_color::muted(&color, "No component health data available.")
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
for comp in &components {
|
for comp in &components {
|
||||||
let (status_str, style) = match comp.status {
|
let status_str = match comp.status {
|
||||||
iota_ipc::HealthStatus::Healthy => {
|
iota_ipc::HealthStatus::Healthy => "healthy",
|
||||||
("healthy", cli_color::SUCCESS)
|
iota_ipc::HealthStatus::Degraded => "degraded",
|
||||||
}
|
iota_ipc::HealthStatus::Failed => "failed",
|
||||||
iota_ipc::HealthStatus::Degraded => {
|
|
||||||
("degraded", cli_color::WARNING)
|
|
||||||
}
|
|
||||||
iota_ipc::HealthStatus::Failed => ("failed", cli_color::ERROR),
|
|
||||||
};
|
};
|
||||||
let suffix = comp
|
let suffix = comp
|
||||||
.message
|
.message
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|m| format!(" ({m})"))
|
.map(|m| format!(" ({m})"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
println!(
|
println!("{:?}: {}{}", comp.id, status_str, suffix);
|
||||||
"{:?}: {}{}",
|
|
||||||
comp.id,
|
|
||||||
color.colorize(status_str, style),
|
|
||||||
suffix
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResponsePayload::UserDetail(user) => {
|
ResponsePayload::UserDetail(user) => {
|
||||||
println!(
|
println!("User: {} ({})", user.username, user.user_id);
|
||||||
"{}: {} ({})",
|
|
||||||
cli_color::info(&color, "User"),
|
|
||||||
cli_color::heading(&color, &user.username),
|
|
||||||
user.user_id
|
|
||||||
);
|
|
||||||
if let Some(ref name) = user.display_name {
|
if let Some(ref name) = user.display_name {
|
||||||
println!("Display Name: {name}");
|
println!("Display Name: {name}");
|
||||||
}
|
}
|
||||||
|
|
@ -761,42 +588,23 @@ async fn run_command(
|
||||||
ResponsePayload::LogEntries(logs) => {
|
ResponsePayload::LogEntries(logs) => {
|
||||||
for entry in &logs.entries {
|
for entry in &logs.entries {
|
||||||
let ts = entry.timestamp_ms;
|
let ts = entry.timestamp_ms;
|
||||||
let (level, style) = if entry.is_error {
|
let level = if entry.is_error { "ERR" } else { "INF" };
|
||||||
("ERR", cli_color::ERROR)
|
println!("[{ts}] {level} {}: {}", entry.sender, entry.message);
|
||||||
} else {
|
|
||||||
("INF", cli_color::INFO)
|
|
||||||
};
|
|
||||||
println!(
|
|
||||||
"[{ts}] {} {}: {}",
|
|
||||||
color.colorize(level, style),
|
|
||||||
entry.sender,
|
|
||||||
entry.message
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResponsePayload::UpdateStatus(status) => {
|
ResponsePayload::UpdateStatus(status) => {
|
||||||
if status.available {
|
if status.available {
|
||||||
println!(
|
println!("Update available.");
|
||||||
"{}",
|
|
||||||
cli_color::success(&color, "Update available.")
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!("Up to date.");
|
||||||
"{}",
|
|
||||||
cli_color::info(&color, "Up to date.")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResponsePayload::Communities(communities) => {
|
ResponsePayload::Communities(communities) => {
|
||||||
if communities.is_empty() {
|
if communities.is_empty() {
|
||||||
println!("{}", cli_color::muted(&color, "No communities."));
|
println!("No communities.");
|
||||||
} else {
|
} else {
|
||||||
for c in &communities {
|
for c in &communities {
|
||||||
println!(
|
println!("{} ({})", c.title, c.name);
|
||||||
"{} ({})",
|
|
||||||
cli_color::heading(&color, &c.title),
|
|
||||||
c.name
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -826,130 +634,7 @@ fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<
|
||||||
"Cannot encode YAML output: {error}"
|
"Cannot encode YAML output: {error}"
|
||||||
)))?
|
)))?
|
||||||
),
|
),
|
||||||
OutputFormat::Table => render_table(payload),
|
|
||||||
OutputFormat::Text => unreachable!(),
|
OutputFormat::Text => unreachable!(),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_table(payload: &ResponsePayload) {
|
|
||||||
match payload {
|
|
||||||
ResponsePayload::Users(users) => {
|
|
||||||
if users.is_empty() {
|
|
||||||
println!("No users.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
println!("{:<8} {}", "ID", "USERNAME");
|
|
||||||
println!("{:<8} {}", "--------", "--------");
|
|
||||||
for user in users {
|
|
||||||
println!("{:<8} {}", user.user_id, user.username);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResponsePayload::Tasks(tasks) => {
|
|
||||||
if tasks.is_empty() {
|
|
||||||
println!("No active tasks.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
println!("{}", "NAME");
|
|
||||||
println!("{}", "--------");
|
|
||||||
for task in tasks {
|
|
||||||
println!("{}", task.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResponsePayload::Components(components) => {
|
|
||||||
if components.is_empty() {
|
|
||||||
println!("No component health data available.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
println!("{:<20} {:<10} {}", "COMPONENT", "STATUS", "MESSAGE");
|
|
||||||
println!("{:<20} {:<10} {}", "--------", "--------", "--------");
|
|
||||||
for comp in components {
|
|
||||||
let status_str = match comp.status {
|
|
||||||
iota_ipc::HealthStatus::Healthy => "healthy",
|
|
||||||
iota_ipc::HealthStatus::Degraded => "degraded",
|
|
||||||
iota_ipc::HealthStatus::Failed => "failed",
|
|
||||||
};
|
|
||||||
let message = comp.message.as_deref().unwrap_or("-");
|
|
||||||
println!("{:<20} {:<10} {}", format!("{:?}", comp.id), status_str, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResponsePayload::Communities(communities) => {
|
|
||||||
if communities.is_empty() {
|
|
||||||
println!("No communities.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
println!("{:<20} {}", "NAME", "TITLE");
|
|
||||||
println!("{:<20} {}", "--------", "--------");
|
|
||||||
for c in communities {
|
|
||||||
println!("{:<20} {}", c.name, c.title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResponsePayload::LogEntries(logs) => {
|
|
||||||
if logs.entries.is_empty() {
|
|
||||||
println!("No log entries.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
println!("{:<20} {:<6} {:<12} {}", "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE");
|
|
||||||
println!("{:<20} {:<6} {:<12} {}", "--------", "--------", "--------", "--------");
|
|
||||||
for entry in &logs.entries {
|
|
||||||
let level = if entry.is_error { "ERR" } else { "INF" };
|
|
||||||
println!(
|
|
||||||
"{:<20} {:<6} {:<12} {}",
|
|
||||||
entry.timestamp_ms, level, entry.sender, entry.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResponsePayload::Status(status) => {
|
|
||||||
println!("{:<15} {}", "Field", "Value");
|
|
||||||
println!("{:<15} {}", "--------", "--------");
|
|
||||||
println!("{:<15} {}", "Phase", status.phase);
|
|
||||||
if !status.tasks.is_empty() {
|
|
||||||
println!("{:<15} {}", "Tasks", status.tasks.join(", "));
|
|
||||||
}
|
|
||||||
if let Some(reason) = &status.degraded_reason {
|
|
||||||
println!("{:<15} {}", "Degraded", reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResponsePayload::DaemonStatus(status) => {
|
|
||||||
println!("{}", status.formatted);
|
|
||||||
}
|
|
||||||
ResponsePayload::Config(config) => {
|
|
||||||
println!("{}", config.yaml);
|
|
||||||
}
|
|
||||||
ResponsePayload::OmikronStatus(status) => {
|
|
||||||
println!("{:<15} {}", "Field", "Value");
|
|
||||||
println!("{:<15} {}", "--------", "--------");
|
|
||||||
println!("{:<15} {}", "Connected", status.connected);
|
|
||||||
if let Some(id) = &status.iota_id {
|
|
||||||
println!("{:<15} {}", "Iota ID", id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResponsePayload::UpdateStatus(status) => {
|
|
||||||
println!("{:<15} {}", "Field", "Value");
|
|
||||||
println!("{:<15} {}", "--------", "--------");
|
|
||||||
println!("{:<15} {}", "Available", status.available);
|
|
||||||
}
|
|
||||||
ResponsePayload::UserCreated { user_id, username } => {
|
|
||||||
println!("Created user {} ({})", username, user_id);
|
|
||||||
}
|
|
||||||
ResponsePayload::UserRemoved { user_id } => {
|
|
||||||
println!("Removed user {}", user_id);
|
|
||||||
}
|
|
||||||
ResponsePayload::Acknowledged { message } => {
|
|
||||||
println!("{}", message);
|
|
||||||
}
|
|
||||||
ResponsePayload::UserDetail(user) => {
|
|
||||||
println!("{:<15} {}", "Field", "Value");
|
|
||||||
println!("{:<15} {}", "--------", "--------");
|
|
||||||
println!("{:<15} {}", "Username", user.username);
|
|
||||||
println!("{:<15} {}", "User ID", user.user_id);
|
|
||||||
if let Some(ref name) = user.display_name {
|
|
||||||
println!("{:<15} {}", "Display Name", name);
|
|
||||||
}
|
|
||||||
println!("{:<15} {}", "Created At", user.created_at);
|
|
||||||
if !user.trusted_apps.is_empty() {
|
|
||||||
println!("{:<15} {}", "Trusted Apps", user.trusted_apps.join(", "));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -28,52 +28,6 @@ impl StartupError {
|
||||||
_ => 1,
|
_ => 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn suggestion(&self) -> Option<&'static str> {
|
|
||||||
match self {
|
|
||||||
Self::DaemonExecutableMissing(_) => {
|
|
||||||
Some("Install the daemon with `iota daemon install` or ensure it is in your PATH.")
|
|
||||||
}
|
|
||||||
Self::LocalSocketNotWritable(_, _) => {
|
|
||||||
Some("Check permissions on the parent directory or run as your user (not root).")
|
|
||||||
}
|
|
||||||
Self::SystemManagerUnavailable => {
|
|
||||||
Some("Install systemd or another supported process manager.")
|
|
||||||
}
|
|
||||||
Self::SystemPermissionDenied(_) => {
|
|
||||||
Some("Run with appropriate privileges or use a user-level daemon instead.")
|
|
||||||
}
|
|
||||||
Self::SocketPermissionDenied(_) => {
|
|
||||||
Some(
|
|
||||||
"Check file permissions on the socket or ensure the daemon is running as your user.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Self::IpcTimedOut(_) => {
|
|
||||||
Some(
|
|
||||||
"The daemon may be starting up. Wait a moment and try again, or check daemon logs.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Self::ProtocolMismatch { .. } => {
|
|
||||||
Some("Update your CLI or daemon to match versions.")
|
|
||||||
}
|
|
||||||
Self::DaemonExited { .. } => {
|
|
||||||
Some("Restart the daemon with `iota daemon restart`.")
|
|
||||||
}
|
|
||||||
Self::IpcBindUnavailable(_) => {
|
|
||||||
Some("Another instance may be running. Stop it first or use a different socket path.")
|
|
||||||
}
|
|
||||||
Self::Terminal(_) => {
|
|
||||||
Some("Use a terminal that supports interactive mode, or run commands headlessly.")
|
|
||||||
}
|
|
||||||
Self::Consent(_) => {
|
|
||||||
Some("Run `iota terms accept` in an interactive terminal to review and accept terms.")
|
|
||||||
}
|
|
||||||
Self::InvalidCommand(_) => {
|
|
||||||
Some("Run `iota --help` to see available commands.")
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
impl fmt::Display for StartupError {
|
impl fmt::Display for StartupError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
|
@ -121,18 +75,6 @@ pub fn exit_code(error: &StartupError) -> ExitCode {
|
||||||
ExitCode::from(error.exit_code())
|
ExitCode::from(error.exit_code())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_error(error: &StartupError) {
|
|
||||||
let color = crate::cli_color::ColorConfig::new();
|
|
||||||
eprintln!("{} {}", crate::cli_color::error(&color, "error:"), error);
|
|
||||||
if let Some(suggestion) = error.suggestion() {
|
|
||||||
eprintln!(
|
|
||||||
" {} {}",
|
|
||||||
crate::cli_color::info(&color, "hint:"),
|
|
||||||
suggestion
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -147,10 +89,4 @@ mod tests {
|
||||||
assert!(error.to_string().contains("authorization"));
|
assert!(error.to_string().contains("authorization"));
|
||||||
assert!(error.to_string().contains("administrator"));
|
assert!(error.to_string().contains("administrator"));
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn most_errors_have_suggestions() {
|
|
||||||
assert!(StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")).suggestion().is_some());
|
|
||||||
assert!(StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")).suggestion().is_some());
|
|
||||||
assert!(StartupError::Cancelled.suggestion().is_none());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue