Complete terminal management workflow

This commit is contained in:
2026-10-21 23:25:31 +03:00
parent f718f739b4
commit 2ccd67d64c
19 changed files with 7313 additions and 53 deletions
+1430
View File
File diff suppressed because it is too large Load Diff
+1065
View File
File diff suppressed because it is too large Load Diff
+690
View File
@@ -0,0 +1,690 @@
//! Responsive, secret-free rendering for the client list and detail screens.
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use crate::app::ClientDisplay;
use crate::domain::PublicKey;
use crate::runtime::{InterfaceObservation, Observed, RuntimeObservation, RuntimePeer};
use super::forms::FormController;
use super::state::{Screen, SessionView, UiState};
pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
let area = frame.area();
if area.width < 34 || area.height < 8 {
let message = if forms.qr_output().is_some() {
"Terminal too small for QR. Resize to at least 34 × 8, or use the saved client file; Esc clears the QR."
} else {
"Terminal too small. Resize to at least 34 × 8."
};
frame.render_widget(
Paragraph::new(message)
.block(
Block::default()
.borders(Borders::ALL)
.title("WireGuard Admin"),
)
.wrap(Wrap { trim: true }),
area,
);
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(2),
])
.split(area);
let status = state
.view
.as_ref()
.map_or("Loading saved state".to_owned(), state_summary);
frame.render_widget(
Paragraph::new(status)
.block(
Block::default()
.borders(Borders::ALL)
.title("WireGuard Admin"),
)
.wrap(Wrap { trim: true }),
rows[0],
);
if forms.is_active() {
if let Some(qr) = forms.qr_output() {
render_qr_disclosure(frame, rows[1], qr, &forms.display_lines());
} else {
let lines = if forms.is_draft_review() {
draft_review_lines(state)
} else {
forms.display_lines()
};
frame.render_widget(
Paragraph::new(Text::from(
lines.into_iter().map(Line::from).collect::<Vec<_>>(),
))
.block(
Block::default()
.borders(Borders::ALL)
.title("Management form"),
)
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1],
);
}
} else {
match state.screen {
Screen::Help => render_help(frame, rows[1]),
Screen::ClientDetails => render_details(frame, rows[1], state),
Screen::Progress => {
let text = state
.status_message
.as_deref()
.unwrap_or("Operation in progress…");
frame.render_widget(
Paragraph::new(safe_text(text))
.block(Block::default().borders(Borders::ALL).title("Progress"))
.wrap(Wrap { trim: false }),
rows[1],
);
}
Screen::Message => {
let text = state.status_message.as_deref().unwrap_or("Ready");
frame.render_widget(
Paragraph::new(safe_text(text))
.block(Block::default().borders(Borders::ALL).title("Status"))
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1],
);
}
Screen::ClientList => render_list(frame, rows[1], state),
}
}
frame.render_widget(
Paragraph::new("n new r rename d delete p preview c Commit R restart o first file O reissue b backups B backup s setup i import x discard q exit")
.block(Block::default().borders(Borders::ALL)),
rows[2],
);
}
fn render_qr_disclosure(
frame: &mut Frame<'_>,
area: ratatui::layout::Rect,
qr: &crate::delivery::QrCodeOutput,
lines: &[String],
) {
let inner_width = area.width.saturating_sub(2) as usize;
let header_rows = lines.len();
let available_height = (area.height as usize).saturating_sub(2 + header_rows);
let side = qr.side();
let mut text = lines.iter().map(|line| safe_text(line)).collect::<Vec<_>>();
if !qr_fits(side, inner_width, available_height) {
text.push("QR is too large for this terminal. Resize to fit the complete code or use the saved client file.".to_owned());
} else {
for y in 0..side {
let mut row = String::with_capacity(side * 2);
for x in 0..side {
row.push_str(if qr.module(x, y).unwrap_or(false) {
"██"
} else {
" "
});
}
text.push(row);
}
}
frame.render_widget(
Paragraph::new(Text::from(
text.into_iter().map(Line::from).collect::<Vec<_>>(),
))
.block(
Block::default()
.borders(Borders::ALL)
.title("Secret disclosure · Esc clears"),
)
.wrap(Wrap { trim: false }),
area,
);
}
fn qr_fits(side: usize, inner_width: usize, available_height: usize) -> bool {
side.saturating_mul(2) <= inner_width && side <= available_height
}
fn draft_review_lines(state: &UiState) -> Vec<String> {
let Some(view) = state.view.as_ref() else {
return vec!["Draft state is still loading.".to_owned()];
};
let mut lines = vec![
"Semantic draft preview".to_owned(),
format!(
"Pending draft: {}",
if view.has_draft { "yes" } else { "no" }
),
];
for (identity, row) in &view.client_states {
match (&row.saved, &row.draft) {
(None, Some(draft)) => lines.push(format!(
"Added {identity}: {} (IPv4 {}, IPv6 {})",
safe_text(&draft.alias),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
(Some(saved), None) => lines.push(format!(
"Removed {identity}: {} (saved removal remains unapplied until restart)",
safe_text(&saved.alias)
)),
(Some(saved), Some(draft)) if saved != draft => lines.push(format!(
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}",
safe_text(&saved.alias),
safe_text(&draft.alias),
saved.addresses.ipv4,
saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
_ => {}
}
}
if lines.len() == 2 && view.has_draft {
lines.push(
"A vault or managed metadata draft is pending; no client state differs.".to_owned(),
);
} else if lines.len() == 2 {
lines.push("No semantic client changes are pending.".to_owned());
}
lines.push("No raw server configuration is displayed.".to_owned());
lines
}
fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) {
let Some(view) = state.view.as_ref() else {
frame.render_widget(Paragraph::new("Loading client projection…"), area);
return;
};
if view.clients.is_empty() {
let text = if view.document_issues.is_empty() {
"No peers are configured."
} else {
"No peer rows. Document issues are listed below."
};
frame.render_widget(
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Clients")),
area,
);
return;
}
let mut items: Vec<ListItem<'_>> = view
.clients
.iter()
.map(|client| {
let identity = client
.identity
.map(|key| short_key(&key))
.unwrap_or_else(|| format!("peer at line {}", client.line));
let supported = if client.mutable_identity.is_some() {
"supported"
} else {
"inspection only"
};
let addresses = safe_text(&client.allowed_ips.join(", "));
let row_state = client
.identity
.and_then(|identity| view.client_states.get(&identity))
.map_or_else(
|| "state: unavailable".to_owned(),
|row| match (&row.saved, &row.draft) {
(None, Some(_)) => "Draft added".to_owned(),
(Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(),
(Some(_), None) => "Draft removed".to_owned(),
(Some(_), Some(_)) => "Saved".to_owned(),
(None, None) => "State unavailable".to_owned(),
},
);
let anomaly_marker = if client.issues.is_empty() {
String::new()
} else {
" ! anomaly".to_owned()
};
let anomaly_details = if client.issues.is_empty() {
String::new()
} else {
format!(" {}", safe_text(&format!("{:?}", client.issues)))
};
ListItem::new(Line::from(vec![
Span::raw(format!("{row_state}{anomaly_marker} ")),
Span::styled(safe_text(&client.label), Style::default().fg(Color::Cyan)),
Span::raw(format!(
" [{identity}] {supported} {addresses}{anomaly_details}"
)),
]))
})
.collect();
items.extend(view.client_states.iter().filter_map(|(identity, row)| {
let saved = row.saved.as_ref()?;
if row.draft.is_some()
|| view
.clients
.iter()
.any(|peer| peer.identity == Some(*identity))
{
return None;
}
let ipv6 = saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |address| address.to_string());
Some(ListItem::new(format!(
"Draft removed {} [{}] saved addresses: {}, {}",
safe_text(&saved.alias),
short_key(identity),
saved.addresses.ipv4,
ipv6
)))
}));
items.extend(view.document_issues.iter().map(|issue| {
ListItem::new(format!(
"Document issue: {}",
safe_text(&format!("{issue:?}"))
))
.style(Style::default().fg(Color::Yellow))
}));
let selected = state
.selected_peer_index
.and_then(|index| view.clients.iter().position(|row| row.peer_index == index))
.or_else(|| {
state.selected_identity.and_then(|identity| {
view.clients
.iter()
.position(|row| row.identity == Some(identity))
})
})
.unwrap_or(0);
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Clients and document issues"),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut list_state = ListState::default().with_selected(Some(selected));
frame.render_stateful_widget(list, area, &mut list_state);
}
fn render_details(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) {
let Some(view) = state.view.as_ref() else {
frame.render_widget(Paragraph::new("Loading client projection…"), area);
return;
};
let selected = state
.selected_peer_index
.and_then(|index| view.clients.iter().find(|row| row.peer_index == index))
.or_else(|| {
state.selected_identity.and_then(|identity| {
view.clients
.iter()
.find(|row| row.identity == Some(identity))
})
});
let Some(client) = selected else {
frame.render_widget(
Paragraph::new("Select a peer from the client list.").block(
Block::default()
.borders(Borders::ALL)
.title("Client details"),
),
area,
);
return;
};
let lines = detail_lines(client, view);
frame.render_widget(
Paragraph::new(Text::from(lines))
.block(
Block::default()
.borders(Borders::ALL)
.title("Client details"),
)
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
area,
);
}
fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from(format!("Label: {}", safe_text(&client.label))),
Line::from(format!(
"Public key: {}",
client
.identity
.map_or_else(|| "unavailable".to_owned(), |key| key.to_string())
)),
Line::from(format!(
"Allowed IPs: {}",
safe_text(&client.allowed_ips.join(", "))
)),
Line::from(format!(
"Mutation: {}",
if client.mutable_identity.is_some() {
"supported"
} else {
"disabled: ambiguous or unsupported peer"
}
)),
];
if let Some(identity) = client.identity {
if let Some(row) = view.client_states.get(&identity) {
lines.push(Line::from(format!(
"Draft: {}",
safe_text(row.draft.as_ref().map_or("absent", |c| c.alias.as_str()))
)));
lines.push(Line::from(format!(
"Saved: {}",
safe_text(row.saved.as_ref().map_or("absent", |c| c.alias.as_str()))
)));
if row.draft != row.saved {
lines.push(Line::from("Draft differs from saved state."));
if let Some(client) = &row.draft {
lines.push(Line::from(format!(
"Draft addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4,
client
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)));
}
if let Some(client) = &row.saved {
lines.push(Line::from(format!(
"Saved addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4,
client
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)));
}
}
}
if let Some(peer) = runtime_peer(&view.statistics, &identity) {
lines.extend(statistics_lines(peer));
} else {
lines.extend([
Line::from("Last handshake: unavailable"),
Line::from("Current received: unavailable"),
Line::from("Current transmitted: unavailable"),
]);
}
}
lines.push(Line::from(format!("Applied state: {:?}", view.applied)));
for issue in &client.issues {
lines.push(Line::from(format!(
"Peer issue: {}",
safe_text(&format!("{issue:?}"))
)));
}
for issue in &view.document_issues {
lines.push(Line::from(format!(
"Document issue: {}",
safe_text(&format!("{issue:?}"))
)));
}
lines
}
fn runtime_peer<'a>(
observation: &'a RuntimeObservation,
key: &PublicKey,
) -> Option<&'a RuntimePeer> {
match &observation.interface {
InterfaceObservation::Present { peers, .. } => peers.peers.get(key),
_ => None,
}
}
fn statistics_lines(peer: &RuntimePeer) -> [Line<'static>; 3] {
let handshake = match &peer.last_handshake {
Observed::Unavailable => "unavailable".to_owned(),
Observed::Available(None) => "never".to_owned(),
Observed::Available(Some(time)) => time
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "available (time formatting failed)".to_owned()),
};
[
Line::from(format!("Last handshake: {handshake}")),
Line::from(format!(
"Current received: {}",
counter(&peer.received_bytes)
)),
Line::from(format!(
"Current transmitted: {}",
counter(&peer.transmitted_bytes)
)),
]
}
fn counter(value: &Observed<u64>) -> String {
match value {
Observed::Available(bytes) => format!("{bytes} bytes"),
Observed::Unavailable => "unavailable".to_owned(),
}
}
fn state_summary(view: &SessionView) -> String {
format!(
"Draft / saved / applied: {} pending client changes / {} saved clients / {:?} Issues: {}",
view.client_states
.values()
.filter(|row| row.draft != row.saved)
.count(),
view.client_states
.values()
.filter(|row| row.saved.is_some())
.count(),
view.applied,
view.document_issues.len()
)
}
fn render_help(frame: &mut Frame<'_>, area: ratatui::layout::Rect) {
frame.render_widget(
Paragraph::new("↑/↓ selects a peer. Enter opens details. ! opens document and peer issues. Esc cancels a form or returns to the list. ? opens help. q requests exit and warns about a draft.\n\nn new client, r rename, d delete, p preview draft, c Commit, x discard draft, R preview explicit restart, o first file delivery, O password reissue, b list backups/restore, B create manual backup, s initial vault setup, i import one selected source file.\n\nPeer rows with malformed, duplicate, or unsupported configuration remain visible for inspection. Mutation requires an unambiguous supported full public key.\n\nCommit and restore only change saved files after backup. Restart is explicit, previewed, backed up, and verified. Statistics are read-only observations; a refresh never marks changes applied.")
.block(Block::default().borders(Borders::ALL).title("Help"))
.wrap(Wrap { trim: false }),
area,
);
}
fn short_key(key: &PublicKey) -> String {
let value = key.to_string();
format!("{}…{}", &value[..8], &value[value.len() - 6..])
}
/// Render control characters visibly so untrusted labels cannot move the cursor
/// or forge terminal output.
pub fn safe_text(input: &str) -> String {
input
.chars()
.flat_map(|character| {
if character.is_control() {
character
.escape_default()
.collect::<String>()
.chars()
.collect::<Vec<_>>()
} else {
vec![character]
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::collections::BTreeMap;
use std::net::Ipv4Addr;
#[test]
fn terminal_controls_are_escaped_for_display() {
assert_eq!(
safe_text("client\u{1b}[31m\nname"),
"client\\u{1b}[31m\\nname"
);
}
#[test]
fn zero_never_and_unavailable_have_distinct_text() {
assert_eq!(counter(&Observed::Available(0)), "0 bytes");
assert_eq!(counter(&Observed::Unavailable), "unavailable");
let now = time::OffsetDateTime::UNIX_EPOCH;
assert!(
statistics_lines(&RuntimePeer {
allowed_ips: Observed::Unavailable,
last_handshake: Observed::Available(None),
received_bytes: Observed::Available(0),
transmitted_bytes: Observed::Unavailable,
})[0]
.to_string()
.contains("never")
);
assert!(now.unix_timestamp() == 0);
}
#[test]
fn qr_fit_requires_the_complete_quiet_zone_in_both_dimensions() {
assert!(qr_fits(25, 50, 25));
assert!(!qr_fits(25, 49, 25));
assert!(!qr_fits(25, 50, 24));
}
#[test]
fn client_rows_show_draft_state_and_escape_anomalous_unsupported_peers() {
let identity = PublicKey::from_bytes([7; 32]);
let saved = crate::domain::Client::new(
identity,
"old alias",
crate::domain::ClientAddresses {
ipv4: Ipv4Addr::new(10, 20, 0, 2),
ipv6: None,
},
)
.unwrap();
let draft =
crate::domain::Client::new(identity, "new alias", saved.addresses.clone()).unwrap();
let unsupported_key = PublicKey::from_bytes([8; 32]);
let removed_key = PublicKey::from_bytes([9; 32]);
let removed =
crate::domain::Client::new(removed_key, "removed alias", saved.addresses.clone())
.unwrap();
let view = SessionView {
generation: 1,
clients: vec![
ClientDisplay {
peer_index: 0,
line: 1,
identity: Some(identity),
label: "new alias".to_owned(),
allowed_ips: vec!["10.20.0.2/32".to_owned()],
issues: vec![],
mutable_identity: Some(identity),
},
ClientDisplay {
peer_index: 1,
line: 5,
identity: Some(unsupported_key),
label: "odd\u{1b}[31m peer".to_owned(),
allowed_ips: vec!["bad\nroute".to_owned()],
issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
field: 0,
item: 1,
}],
mutable_identity: None,
},
],
client_states: BTreeMap::from([
(
identity,
super::super::state::ClientStateRow {
saved: Some(saved),
draft: Some(draft),
},
),
(
removed_key,
super::super::state::ClientStateRow {
saved: Some(removed),
draft: None,
},
),
]),
document_issues: vec![],
applied: super::super::state::AppliedState::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: true,
vault_initialized: true,
};
let state = UiState {
view: Some(view),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(220, 12)).unwrap();
terminal
.draw(|frame| render_list(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let rendered = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Draft changed"));
assert!(rendered.contains("inspection only"));
assert!(rendered.contains("removed alias"));
assert!(rendered.contains("Draft removed"));
assert!(rendered.contains("UnsupportedAllowedIpShape"));
assert!(rendered.contains("odd\\u{1b}[31m peer"));
assert!(rendered.contains("bad\\nroute"));
let mut narrow = Terminal::new(TestBackend::new(34, 12)).unwrap();
narrow
.draw(|frame| render_list(frame, frame.area(), &state))
.unwrap();
let narrow_buffer = narrow.backend().buffer();
let narrow_rendered = (0..narrow_buffer.area.height)
.map(|y| {
(0..narrow_buffer.area.width)
.map(|x| narrow_buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(narrow_rendered.contains("Draft changed"));
assert!(narrow_rendered.contains("! anomaly"));
assert!(narrow_rendered.contains("Draft removed"));
}
}
+403
View File
@@ -0,0 +1,403 @@
//! Secret-free navigation and session projections for the terminal interface.
use std::collections::BTreeSet;
use std::net::IpAddr;
use crate::app::{Application, ClientDisplay};
use crate::domain::{Client, PublicKey, RuntimeObservation};
use crate::runtime::RuntimeObservation as Statistics;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Screen {
ClientList,
ClientDetails,
Help,
Progress,
Message,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Focus {
ClientList,
Details,
Dialog,
Help,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DialogKind {
Rename,
Delete,
Create,
Commit,
Restart,
Exit,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AppliedState {
Unknown,
Unavailable,
Verified,
}
/// Public client values for both states. No configuration bytes or secrets are
/// retained in a view snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientStateRow {
pub saved: Option<Client>,
pub draft: Option<Client>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionView {
pub generation: u64,
pub clients: Vec<ClientDisplay>,
pub client_states: std::collections::BTreeMap<PublicKey, ClientStateRow>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
pub applied: AppliedState,
pub reserved_addresses: BTreeSet<IpAddr>,
pub statistics: Statistics,
pub has_draft: bool,
pub vault_initialized: bool,
}
impl SessionView {
pub fn from_application(app: &Application, generation: u64) -> Self {
let states = app.states();
let mut identities = BTreeSet::new();
identities.extend(states.saved().clients().map(|client| client.identity));
identities.extend(states.draft().clients().map(|client| client.identity));
let client_states = identities
.into_iter()
.map(|identity| {
(
identity,
ClientStateRow {
saved: states.saved().get(&identity).cloned(),
draft: states.draft().get(&identity).cloned(),
},
)
})
.collect();
let applied = match states.applied() {
RuntimeObservation::Unknown => AppliedState::Unknown,
RuntimeObservation::Unavailable => AppliedState::Unavailable,
RuntimeObservation::Verified => AppliedState::Verified,
};
let projection = app.display_projection();
Self {
generation,
clients: projection.clients,
client_states,
document_issues: projection.document_issues,
applied,
reserved_addresses: states.reserved_addresses().clone(),
statistics: Statistics::unknown(),
has_draft: app.has_draft_changes(),
vault_initialized: app.vault_initialized(),
}
}
pub fn contains_identity(&self, identity: &PublicKey) -> bool {
self.clients
.iter()
.any(|client| client.identity == Some(*identity))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OperationKind {
CreateClient,
RenameClient,
DeleteClient,
DiscardDraft,
Commit,
CreateBackup,
ListBackups,
ImportSelectedFile,
InitialSetup,
PreviewRestart,
Restart,
DeliverClient,
Restore,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PendingOperation {
pub request_id: u64,
pub kind: OperationKind,
pub generation: u64,
}
/// Mutable UI navigation and latest accepted worker projection.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UiState {
pub screen: Screen,
pub focus: Focus,
pub dialog: Option<DialogKind>,
pub selected_identity: Option<PublicKey>,
/// Authoritative document index used only to inspect malformed peers that
/// have no public key. Mutating actions must always use selected_identity.
pub selected_peer_index: Option<usize>,
pub scroll_offset: usize,
pub generation: u64,
pub view: Option<SessionView>,
pub pending: Option<PendingOperation>,
pub status_message: Option<String>,
}
impl Default for UiState {
fn default() -> Self {
Self {
screen: Screen::ClientList,
focus: Focus::ClientList,
dialog: None,
selected_identity: None,
selected_peer_index: None,
scroll_offset: 0,
generation: 0,
view: None,
pending: None,
status_message: None,
}
}
}
impl UiState {
/// Replace the view only when it belongs to the current generation. The
/// selected full public key remains stable through sorting and refreshes.
pub fn apply_view(&mut self, view: SessionView) -> bool {
if view.generation != self.generation {
return false;
}
if self
.selected_identity
.is_some_and(|identity| !view.contains_identity(&identity))
{
self.selected_identity = None;
}
if let Some(identity) = self.selected_identity {
self.selected_peer_index = view
.clients
.iter()
.find(|client| client.identity == Some(identity))
.map(|client| client.peer_index);
} else if self
.selected_peer_index
.is_some_and(|index| !view.clients.iter().any(|client| client.peer_index == index))
{
self.selected_peer_index = None;
}
self.view = Some(view);
true
}
/// Apply only statistics sampled from the still-current saved/draft view.
/// This never changes the applied-state marker.
pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool {
if generation != self.generation {
return false;
}
let Some(view) = self.view.as_mut() else {
return false;
};
if view.generation != generation {
return false;
}
view.statistics = statistics;
true
}
pub fn begin_operation(&mut self, operation: PendingOperation) -> bool {
if self.pending.is_some() || operation.generation < self.generation {
return false;
}
self.generation = operation.generation;
self.pending = Some(operation);
self.screen = Screen::Progress;
true
}
/// Close a form/dialog without cancelling work already submitted to the
/// application worker. The pending progress state remains visible.
pub fn cancel_dialog(&mut self) {
self.dialog = None;
if self.pending.is_some() {
self.screen = Screen::Progress;
} else {
self.screen = if self.selected_identity.is_some() {
Screen::ClientDetails
} else {
Screen::ClientList
};
}
}
pub fn finish_operation(&mut self, operation: PendingOperation) -> bool {
if self.pending != Some(operation) {
return false;
}
self.pending = None;
self.screen = if self.dialog.is_some() {
Screen::Message
} else if self.selected_identity.is_some() {
Screen::ClientDetails
} else {
Screen::ClientList
};
true
}
pub fn select(&mut self, identity: PublicKey) -> bool {
if !self
.view
.as_ref()
.is_some_and(|view| view.contains_identity(&identity))
{
return false;
}
self.selected_identity = Some(identity);
self.selected_peer_index = self.view.as_ref().and_then(|view| {
view.clients
.iter()
.find(|client| client.identity == Some(identity))
.map(|client| client.peer_index)
});
self.screen = Screen::ClientDetails;
true
}
pub fn select_peer_index(&mut self, peer_index: usize) -> bool {
let Some(client) = self.view.as_ref().and_then(|view| {
view.clients
.iter()
.find(|client| client.peer_index == peer_index)
}) else {
return false;
};
self.selected_peer_index = Some(peer_index);
self.selected_identity = client.identity;
self.screen = Screen::ClientDetails;
true
}
pub fn selected_client_mutable(&self) -> bool {
let Some(identity) = self.selected_identity else {
return false;
};
self.view.as_ref().is_some_and(|view| {
view.clients.iter().any(|client| {
client.identity == Some(identity) && client.mutable_identity == Some(identity)
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::PublicKey;
use crate::wireguard::PeerIssue;
fn row(identity: PublicKey, label: &str, mutable: bool) -> ClientDisplay {
ClientDisplay {
peer_index: 0,
line: 1,
identity: Some(identity),
label: label.to_owned(),
allowed_ips: vec!["10.0.0.2/32".to_owned()],
issues: if mutable {
Vec::new()
} else {
vec![PeerIssue::MissingIpv4HostRoute]
},
mutable_identity: mutable.then_some(identity),
}
}
fn view(generation: u64, clients: Vec<ClientDisplay>) -> SessionView {
SessionView {
generation,
clients,
client_states: Default::default(),
document_issues: Vec::new(),
applied: AppliedState::Unknown,
reserved_addresses: BTreeSet::new(),
statistics: Statistics::unknown(),
has_draft: false,
vault_initialized: true,
}
}
#[test]
fn refresh_preserves_selection_by_public_key_not_alias_or_row_position() {
let a = PublicKey::from_bytes([1; 32]);
let b = PublicKey::from_bytes([2; 32]);
let mut state = UiState::default();
assert!(state.apply_view(view(
0,
vec![row(a, "same alias", true), row(b, "same alias", true)]
)));
assert!(state.select(b));
assert!(state.apply_view(view(
0,
vec![row(b, "renamed", true), row(a, "same alias", true)]
)));
assert_eq!(state.selected_identity, Some(b));
}
#[test]
fn stale_view_is_rejected_and_unsupported_selected_peer_is_not_mutable() {
let key = PublicKey::from_bytes([3; 32]);
let mut state = UiState::default();
assert!(state.apply_view(view(0, vec![row(key, "legacy", false)])));
assert!(state.select(key));
assert!(!state.selected_client_mutable());
state.generation = 1;
assert!(!state.apply_view(view(0, vec![row(key, "stale", true)])));
assert_eq!(state.selected_identity, Some(key));
}
#[test]
fn malformed_peer_can_be_inspected_by_document_location_without_mutation_identity() {
let mut malformed = row(PublicKey::from_bytes([8; 32]), "malformed", false);
malformed.identity = None;
malformed.mutable_identity = None;
malformed.peer_index = 3;
let mut state = UiState::default();
assert!(state.apply_view(view(0, vec![malformed])));
assert!(state.select_peer_index(3));
assert_eq!(state.selected_identity, None);
assert_eq!(state.selected_peer_index, Some(3));
assert!(!state.selected_client_mutable());
}
#[test]
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
let mut state = UiState {
dialog: Some(DialogKind::Commit),
..UiState::default()
};
let operation = PendingOperation {
request_id: 4,
kind: OperationKind::Commit,
generation: 1,
};
assert!(state.begin_operation(operation));
state.cancel_dialog();
assert_eq!(state.pending, Some(operation));
assert_eq!(state.screen, Screen::Progress);
}
#[test]
fn statistics_refresh_is_generation_bound_and_never_changes_applied_state() {
let key = PublicKey::from_bytes([4; 32]);
let mut state = UiState::default();
assert!(state.apply_view(view(0, vec![row(key, "client", true)])));
assert!(state.apply_statistics(0, Statistics::unknown()));
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Unknown);
state.generation = 1;
assert!(!state.apply_statistics(0, Statistics::unknown()));
}
}
+1077
View File
File diff suppressed because it is too large Load Diff