Implement adaptive client list

This commit is contained in:
2026-10-28 23:12:22 +02:00
parent d49721ff8e
commit 4960c65f65
8 changed files with 1794 additions and 256 deletions
+1159 -163
View File
File diff suppressed because it is too large Load Diff
+297 -32
View File
@@ -54,6 +54,9 @@ pub struct SessionView {
pub generation: u64,
pub clients: Vec<ClientDisplay>,
pub client_states: std::collections::BTreeMap<PublicKey, ClientStateRow>,
/// Number of peer sections in the installed server document, when its
/// saved bytes can be projected. This includes nonmanaged peers.
pub saved_peer_count: Option<usize>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
pub applied: AppliedState,
pub reserved_addresses: BTreeSet<IpAddr>,
@@ -86,10 +89,15 @@ impl SessionView {
RuntimeObservation::Verified => AppliedState::Verified,
};
let projection = app.display_projection();
let saved_peer_count =
crate::wireguard::ConfigDocument::parse(&app.saved_bytes().server_config)
.ok()
.map(|document| document.peers().len());
Self {
generation,
clients: projection.clients,
client_states,
saved_peer_count,
document_issues: projection.document_issues,
applied,
reserved_addresses: states.reserved_addresses().clone(),
@@ -104,6 +112,57 @@ impl SessionView {
.iter()
.any(|client| client.identity == Some(*identity))
}
/// Rows shown in the client table. Document issues are deliberately
/// excluded because they are summaries, not client action targets.
pub fn visible_records(&self) -> Vec<VisibleRecord<'_>> {
let mut records = self
.clients
.iter()
.map(VisibleRecord::Peer)
.collect::<Vec<_>>();
records.extend(self.client_states.iter().filter_map(|(identity, row)| {
let client = row.saved.as_ref()?;
if row.draft.is_some()
|| self
.clients
.iter()
.any(|peer| peer.identity == Some(*identity))
{
return None;
}
Some(VisibleRecord::DraftRemoved {
identity: *identity,
client,
})
}));
records
}
}
#[derive(Clone, Copy, Debug)]
pub enum VisibleRecord<'a> {
Peer(&'a ClientDisplay),
DraftRemoved {
identity: PublicKey,
client: &'a Client,
},
}
impl VisibleRecord<'_> {
pub fn identity(self) -> Option<PublicKey> {
match self {
Self::Peer(client) => client.identity,
Self::DraftRemoved { identity, .. } => Some(identity),
}
}
pub fn peer_index(self) -> Option<usize> {
match self {
Self::Peer(client) => Some(client.peer_index),
Self::DraftRemoved { .. } => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -171,28 +230,92 @@ impl UiState {
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.reconcile_selection(&view);
self.view = Some(view);
true
}
fn reconcile_selection(&mut self, view: &SessionView) {
if let Some(identity) = self.selected_identity {
let matching = view
.clients
.iter()
.filter(|client| client.identity == Some(identity))
.collect::<Vec<_>>();
if matching.len() == 1 {
self.selected_peer_index = Some(matching[0].peer_index);
return;
}
if matching.len() > 1 {
if self
.selected_peer_index
.is_some_and(|index| matching.iter().any(|client| client.peer_index == index))
{
return;
}
// The old row disappeared and the key is now ambiguous. Do
// not guess which peer the administrator meant.
self.selected_identity = None;
self.selected_peer_index = None;
self.select_first_record(view);
return;
}
let removed = view.client_states.get(&identity).is_some_and(|row| {
row.saved.is_some()
&& row.draft.is_none()
&& !view
.clients
.iter()
.any(|client| client.identity == Some(identity))
});
if removed {
self.selected_peer_index = None;
return;
}
self.selected_identity = None;
self.selected_peer_index = None;
}
if let Some(peer_index) = self.selected_peer_index {
if let Some(client) = view
.clients
.iter()
.find(|client| client.peer_index == peer_index)
{
self.selected_identity = client.identity;
return;
}
self.selected_peer_index = None;
}
if self.selected_identity.is_some_and(|identity| {
view.client_states
.get(&identity)
.is_some_and(|row| row.saved.is_some() && row.draft.is_none())
}) {
return;
}
self.select_first_record(view);
}
fn select_first_record(&mut self, view: &SessionView) {
match view.visible_records().first().copied() {
Some(VisibleRecord::Peer(client)) => {
self.selected_peer_index = Some(client.peer_index);
self.selected_identity = client.identity;
}
Some(VisibleRecord::DraftRemoved { identity, .. }) => {
self.selected_peer_index = None;
self.selected_identity = Some(identity);
}
None => {
self.selected_peer_index = None;
self.selected_identity = None;
}
}
}
/// 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 {
@@ -250,20 +373,19 @@ impl UiState {
}
pub fn select(&mut self, identity: PublicKey) -> bool {
if !self
.view
.as_ref()
.is_some_and(|view| view.contains_identity(&identity))
{
let Some(view) = &self.view else { return false };
let mut matching = view
.clients
.iter()
.filter(|row| row.identity == Some(identity));
let Some(client) = matching.next() else {
return false;
};
if matching.next().is_some() {
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.selected_peer_index = Some(client.peer_index);
self.screen = Screen::ClientDetails;
true
}
@@ -290,14 +412,81 @@ impl UiState {
true
}
pub fn selected_record(&self) -> Option<VisibleRecord<'_>> {
let view = self.view.as_ref()?;
let records = view.visible_records();
self.selected_visible_index(&records)
.and_then(|index| records.get(index).copied())
}
pub fn selected_visible_index(&self, records: &[VisibleRecord<'_>]) -> Option<usize> {
self.selected_peer_index
.and_then(|peer_index| {
records
.iter()
.position(|record| record.peer_index() == Some(peer_index))
})
.or_else(|| {
self.selected_identity.and_then(|identity| {
records
.iter()
.position(|record| record.identity() == Some(identity))
})
})
.or_else(|| (!records.is_empty()).then_some(0))
}
pub fn select_visible_index(&mut self, selected: usize) -> bool {
let Some(record) = self
.view
.as_ref()
.and_then(|view| view.visible_records().get(selected).copied())
else {
return false;
};
match record {
VisibleRecord::Peer(client) => {
self.selected_peer_index = Some(client.peer_index);
self.selected_identity = client.identity;
}
VisibleRecord::DraftRemoved { identity, .. } => {
self.selected_peer_index = None;
self.selected_identity = Some(identity);
}
}
self.scroll_offset = 0;
true
}
pub fn open_selected_record(&mut self) -> bool {
let Some(view) = &self.view else { return false };
let records = view.visible_records();
let Some(index) = self.selected_visible_index(&records) else {
return false;
};
self.select_visible_index(index);
self.screen = Screen::ClientDetails;
self.scroll_offset = 0;
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)
})
let mut matching = view
.clients
.iter()
.filter(|client| client.identity == Some(identity));
let Some(client) = matching.next() else {
return false;
};
matching.next().is_none()
&& self
.selected_peer_index
.is_none_or(|peer_index| peer_index == client.peer_index)
&& client.mutable_identity == Some(identity)
})
}
}
@@ -330,6 +519,7 @@ mod tests {
generation,
clients,
client_states: Default::default(),
saved_peer_count: None,
document_issues: Vec::new(),
applied: AppliedState::Unknown,
reserved_addresses: BTreeSet::new(),
@@ -382,6 +572,81 @@ mod tests {
assert!(!state.selected_client_mutable());
}
#[test]
fn selection_uses_peer_position_for_duplicate_keys_and_disables_mutation() {
let key = PublicKey::from_bytes([12; 32]);
let mut first = row(key, "first duplicate", true);
first.peer_index = 3;
let mut second = row(key, "second duplicate", true);
second.peer_index = 8;
let mut state = UiState::default();
assert!(state.apply_view(view(0, vec![first, second])));
assert_eq!(state.selected_peer_index, Some(3));
assert!(!state.selected_client_mutable());
assert!(state.highlight_peer_index(8));
assert_eq!(state.selected_record().unwrap().peer_index(), Some(8));
assert_eq!(
match state.selected_record().unwrap() {
VisibleRecord::Peer(client) => client.label.as_str(),
VisibleRecord::DraftRemoved { .. } => panic!("duplicate resolved to removal"),
},
"second duplicate"
);
assert!(!state.selected_client_mutable());
let mut refreshed_first = row(key, "refreshed first", true);
refreshed_first.peer_index = 3;
let mut refreshed_second = row(key, "refreshed second", true);
refreshed_second.peer_index = 8;
assert!(state.apply_view(view(0, vec![refreshed_second, refreshed_first])));
assert_eq!(state.selected_peer_index, Some(8));
assert_eq!(
match state.selected_record().unwrap() {
VisibleRecord::Peer(client) => client.label.as_str(),
VisibleRecord::DraftRemoved { .. } => panic!("duplicate resolved to removal"),
},
"refreshed second"
);
assert!(!state.selected_client_mutable());
}
#[test]
fn removed_saved_client_is_visible_and_never_mutable() {
let key = PublicKey::from_bytes([13; 32]);
let removed = Client::new(
key,
"removed alias",
crate::domain::ClientAddresses {
ipv4: "10.0.0.9".parse().unwrap(),
ipv6: None,
},
)
.unwrap();
let mut view = view(0, Vec::new());
view.client_states.insert(
key,
ClientStateRow {
saved: Some(removed),
draft: None,
},
);
view.document_issues
.push(crate::wireguard::DocumentIssue::MalformedSectionHeader { line: 5 });
let mut state = UiState::default();
assert!(state.apply_view(view));
assert_eq!(state.selected_identity, Some(key));
assert_eq!(
state.selected_visible_index(&state.view.as_ref().unwrap().visible_records()),
Some(0)
);
assert!(matches!(
state.selected_record(),
Some(VisibleRecord::DraftRemoved { identity, .. }) if identity == key
));
assert!(!state.selected_client_mutable());
assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1);
}
#[test]
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
let mut state = UiState {
+112 -29
View File
@@ -869,15 +869,7 @@ fn handle_navigation_key(code: KeyCode, state: &mut UiState) -> bool {
true
}
KeyCode::Enter if state.screen == Screen::ClientList => {
let peer_index = state.selected_peer_index.or_else(|| {
state
.view
.as_ref()
.and_then(|view| view.clients.first().map(|client| client.peer_index))
});
if let Some(peer_index) = peer_index {
let _ = state.select_peer_index(peer_index);
}
let _ = state.open_selected_record();
state.scroll_offset = 0;
true
}
@@ -891,31 +883,17 @@ fn handle_navigation_key(code: KeyCode, state: &mut UiState) -> bool {
fn move_selection(state: &mut UiState, down: bool) {
let Some(view) = &state.view else { return };
if view.clients.is_empty() {
let records = view.visible_records();
if records.is_empty() {
return;
}
let current = state
.selected_peer_index
.and_then(|selected| {
view.clients
.iter()
.position(|row| row.peer_index == selected)
})
.or_else(|| {
state.selected_identity.and_then(|identity| {
view.clients
.iter()
.position(|row| row.identity == Some(identity))
})
})
.unwrap_or(0);
let current = state.selected_visible_index(&records).unwrap_or(0);
let next = if down {
(current + 1) % view.clients.len()
(current + 1) % records.len()
} else {
current.checked_sub(1).unwrap_or(view.clients.len() - 1)
current.checked_sub(1).unwrap_or(records.len() - 1)
};
let peer_index = view.clients[next].peer_index;
let _ = state.highlight_peer_index(peer_index);
let _ = state.select_visible_index(next);
}
#[cfg(test)]
@@ -996,6 +974,7 @@ mod tests {
unsupported,
],
client_states: Default::default(),
saved_peer_count: Some(0),
document_issues: Vec::new(),
applied: AppliedState::Unknown,
reserved_addresses: Default::default(),
@@ -1064,6 +1043,109 @@ mod tests {
assert!(!state.selected_client_mutable());
}
#[test]
fn keyboard_navigation_reaches_draft_removed_rows_without_authorizing_them() {
let removed_key = crate::domain::PublicKey::from_bytes([19; 32]);
let mut view = navigation_view();
view.client_states.insert(
removed_key,
crate::tui::state::ClientStateRow {
saved: Some(
crate::domain::Client::new(
removed_key,
"removed",
crate::domain::ClientAddresses {
ipv4: "10.0.0.9".parse().unwrap(),
ipv6: None,
},
)
.unwrap(),
),
draft: None,
},
);
let mut state = UiState {
view: Some(view),
..UiState::default()
};
assert!(handle_navigation_key(KeyCode::Down, &mut state));
assert_eq!(state.selected_peer_index, Some(9));
assert!(handle_navigation_key(KeyCode::Down, &mut state));
assert_eq!(state.selected_peer_index, None);
assert_eq!(state.selected_identity, Some(removed_key));
assert!(!state.selected_client_mutable());
assert!(handle_navigation_key(KeyCode::Enter, &mut state));
assert_eq!(state.screen, Screen::ClientDetails);
assert!(matches!(
state.selected_record(),
Some(crate::tui::state::VisibleRecord::DraftRemoved { identity, .. })
if identity == removed_key
));
assert!(handle_navigation_key(KeyCode::Esc, &mut state));
assert_eq!(state.screen, Screen::ClientList);
assert_eq!(state.selected_identity, Some(removed_key));
let mut empty = UiState {
view: Some(SessionView {
generation: 0,
clients: Vec::new(),
client_states: Default::default(),
saved_peer_count: Some(0),
document_issues: vec![crate::wireguard::DocumentIssue::MalformedSectionHeader {
line: 1,
}],
applied: AppliedState::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
}),
..UiState::default()
};
assert!(handle_navigation_key(KeyCode::Enter, &mut empty));
assert_eq!(empty.screen, Screen::ClientList);
assert!(empty.selected_record().is_none());
}
#[test]
fn duplicate_public_keys_remain_inspectable_by_peer_position_but_not_mutable() {
let key = crate::domain::PublicKey::from_bytes([20; 32]);
let mut first = navigation_view().clients[0].clone();
first.peer_index = 3;
first.identity = Some(key);
first.mutable_identity = Some(key);
first.label = "first duplicate".to_owned();
let mut second = first.clone();
second.peer_index = 7;
second.label = "second duplicate".to_owned();
let mut state = UiState {
view: Some(SessionView {
generation: 0,
clients: vec![first, second],
client_states: Default::default(),
saved_peer_count: Some(2),
document_issues: Vec::new(),
applied: AppliedState::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
}),
..UiState::default()
};
assert!(handle_navigation_key(KeyCode::Down, &mut state));
assert_eq!(state.selected_peer_index, Some(7));
assert_eq!(state.selected_identity, Some(key));
assert!(!state.selected_client_mutable());
assert!(handle_navigation_key(KeyCode::Enter, &mut state));
assert!(matches!(
state.selected_record(),
Some(crate::tui::state::VisibleRecord::Peer(client))
if client.peer_index == 7 && client.label == "second duplicate"
));
assert!(!state.selected_client_mutable());
}
#[test]
fn issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors() {
let mut view = navigation_view();
@@ -1275,6 +1357,7 @@ mod tests {
generation: 1,
clients: Vec::new(),
client_states: Default::default(),
saved_peer_count: Some(0),
document_issues: Vec::new(),
applied: AppliedState::Unknown,
reserved_addresses: Default::default(),