fix(rooms): harden chat-room parsing and TUI state from review findings

Adversarial review of the chat-rooms feature surfaced seven confirmed
issues; all fixed with regression tests:

lib (DoS hardening — a malicious/MITM server could send a tiny frame
with a ~4-billion element count):
- room_list: bound the name/count vector loops by remaining payload.
- join_room: bound the member-list loop the same way.

TUI (soulseek-rs/src/models/rooms.rs, ui):
- a message for a room we already left no longer resurrects its tab
  (route via open_index, not ensure_open).
- closing a tab now clears the unread badge on the room we land on.
- a room-list refresh keeps the highlight on the same room across a
  re-sort (track selection by name, not index).
- Esc in the room list clears a lingering filter before closing, matching
  the title's 'Esc: clear' hint.
- chat log renders one row per line so the newest message is never
  clipped by wrapping.
This commit is contained in:
michel
2026-07-19 20:15:42 +02:00
parent ce75d0a101
commit 9595afe9fb
5 changed files with 155 additions and 12 deletions
+6
View File
@@ -777,6 +777,12 @@ impl MainTui {
fn handle_rooms_list_input(&mut self, key: KeyEvent) {
let len = self.state.rooms.filtered_rooms().len();
match key.code {
// Esc peels back one level: clear a lingering filter first (as the
// title's "Esc: clear" promises), otherwise close the popup.
KeyCode::Esc if !self.state.rooms.list_filter.is_empty() => {
self.state.rooms.list_filter.clear();
self.state.rooms.list_selected = 0;
}
KeyCode::Esc | KeyCode::Char('q') => {
self.state.show_rooms = false;
}
+6 -6
View File
@@ -10,7 +10,7 @@ use ratatui::{
text::{Line, Span},
widgets::{
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table,
TableState, Wrap,
TableState,
},
};
@@ -181,7 +181,10 @@ fn render_messages(
area: Rect,
lines: &[crate::models::RoomLine],
) {
// Auto-scroll: keep the most recent lines that fit the height.
// Auto-scroll: show the most recent lines. Each RoomLine renders as exactly
// one row (no wrapping) so the tail count is exact and the newest message
// is always visible; over-long lines are clipped at the right edge rather
// than wrapping and pushing newer lines off the bottom.
let height = area.height as usize;
let start = lines.len().saturating_sub(height.max(1));
let rendered: Vec<Line> = lines[start..]
@@ -194,10 +197,7 @@ fn render_messages(
None => Line::from(Span::styled(l.text.clone(), dimmed_style())),
})
.collect();
frame.render_widget(
Paragraph::new(rendered).wrap(Wrap { trim: false }),
area,
);
frame.render_widget(Paragraph::new(rendered), area);
}
fn render_users(frame: &mut Frame, area: Rect, users: &[String]) {