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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/ADMIN_DISPUTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ The admin interface provides dedicated tabs for dispute management:

### 1. Disputes Pending Tab

Lists all pending disputes on the Mostro network (state: `Initiated`). Admins can:
Lists pending disputes on the Mostro network (state: `Initiated`, filtered via `get_initiated_disputes`). Admins can:

- **View dispute details**: Order ID, parties involved, status
- **View dispute details**: Dispute ID, status, created time (Created column drops on narrow terminals)
- **Take a dispute**: Select a dispute and press Enter to take ownership
- **Navigate**: Use arrow keys to browse the dispute list
- **Color coding**: Disputes are color-coded by status (Yellow for pending)
- **Navigate**: ↑↓ browse the list; selection is by dispute UUID (`selected_pending_dispute_id`), resolved through `selected_pending_dispute` / `move_pending_dispute_selection` in `src/ui/helpers/dispute_selection.rs`
- **Scrolling**: persistent `disputes_table_state` + `render_table_list_scrollbar` (same offset/track pattern as the Orders tab)

### 2. Disputes in Progress Tab

Expand All @@ -43,7 +43,7 @@ The interface is divided into three main sections:
1. **Left Sidebar (20%)**: List of disputes in progress (or finalized when Shift+C filter is active)
- Shows truncated dispute IDs (safely handles short IDs without panicking)
- **Selection by dispute id** (`AppState.selected_dispute_id`), resolved through `get_filtered_disputes` / `selected_filtered_dispute` / `move_dispute_selection` in `src/ui/helpers/dispute_selection.rs` — Up/Down and all chat/finalize/attachment actions use the **visible** filtered list, never a raw index into mixed open+closed rows
- **Scrollable list**: stateful `List` + `ListState` keeps the highlighted row in view when disputes overflow the sidebar; vertical scrollbar when the list is taller than the panel
- **Scrollable list**: stateful `List` + `ListState` keeps the highlighted row in view when disputes overflow the sidebar; vertical scrollbar via shared `render_table_list_scrollbar` (viewport offset, data-row track)
- Highlighted selection with Up/Down arrow keys (skips disputes hidden by the current filter)
- Updates main area when selection changes
- Shows "No disputes in progress" / "No finalized disputes" when empty
Expand Down
14 changes: 10 additions & 4 deletions docs/TUI_INTERFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ pub struct AppState {
pub active_tab: Tab,
/// Orders tab selection by order UUID (currency-filtered; see helpers/order_selection.rs).
pub selected_order_id: Option<Uuid>,
pub selected_dispute_idx: usize, // Disputes Pending (Initiated) list index
/// Persistent scroll state for the Orders tab table.
pub orders_table_state: TableState,
/// Disputes Pending selection by dispute UUID (initiated projection; see dispute_selection.rs).
pub selected_pending_dispute_id: Option<Uuid>,
/// Persistent scroll state for the Disputes Pending table.
pub disputes_table_state: TableState,
/// Disputes In Progress / Finalized selection is by **dispute id**, not a raw
/// index into `admin_disputes_in_progress` (see `helpers/dispute_selection.rs`).
pub selected_dispute_id: Option<String>,
Expand Down Expand Up @@ -77,7 +82,7 @@ Mostrix supports two distinct roles, each with its own set of tabs and workflows

Focused on trading and order management.

- **Orders**: View the global order book (stateful table scrolls with ↑↓ when the book is taller than the terminal; optional vertical scrollbar).
- **Orders**: View the global order book (persistent `TableState` scrolls with ↑↓; shared vertical scrollbar confined to data rows).
- **My Trades**: Manage active trades.
- **Messages**: Direct messages for trade coordination.
- **Settings**: Local configuration, including key rotation via **Generate New Keys** and mnemonic backup prompts. **User mode only**: **Set Lightning Address (buyer)** / **Clear Lightning Address** — optional `user@domain.com` stored in `settings.toml`; confirm-save fetches LNURL metadata (`payRequest`) before persisting (see `src/util/ln_address.rs`, `spawn_verify_and_save_ln_address_task`). The visible menu and **Enter** routing share **`ADMIN_SETTINGS`** / **`USER_SETTINGS`** in `src/ui/tabs/settings_tab.rs` (`SettingsMenuAction` + label per row; **`settings_action_for_index`**).
Expand All @@ -87,7 +92,7 @@ Focused on trading and order management.

Focused on dispute resolution and protocol management.

- **Disputes Pending**: List of disputes waiting to be taken. Only displays disputes with `Initiated` status (filtering implemented in `disputes_tab.rs`). Admins can select and take ownership of these disputes.
- **Disputes Pending**: List of disputes waiting to be taken (`Initiated` only via `get_initiated_disputes`). Selection by dispute UUID (`selected_pending_dispute_id` + `dispute_selection.rs`); persistent `disputes_table_state` + shared scrollbar (same pattern as Orders). Admins take ownership with Enter.
- **Disputes in Progress**: Complete workspace for managing taken disputes (state: `InProgress`), featuring:
- Integrated chat system with buyer and seller
- Comprehensive dispute information header
Expand Down Expand Up @@ -270,9 +275,10 @@ The `handle_key_event` function dispatches keys based on the current `UiMode`.

Renders a table of pending orders from the Mostro network. Status and order kinds are color-coded for readability.

- **Scrolling**: uses a stateful [`Table`](https://docs.rs/ratatui) + `TableState` so ↑↓ keeps the selected row in view when the order book is taller than the terminal (same idea as the Messages sidebar / Disputes In Progress list). A vertical scrollbar appears when row count exceeds the visible body height.
- **Scrolling**: persistent [`TableState`](https://docs.rs/ratatui) on `AppState.orders_table_state` so ↑↓ keeps the selected row in view without resetting the viewport each frame (aligned with Disputes Pending). A vertical scrollbar from `render_table_list_scrollbar` appears when row count exceeds the visible body; thumb tracks viewport **offset** and stays on the data-row track (does not overwrite borders/header).
- **Selection by order id** (`selected_order_id` + `helpers/order_selection.rs`): ↑↓ / highlight / Enter all resolve through the same currency-filtered book projection. If the stored id is hidden by `currencies_filter`, selection falls back to the first visible row so take/cancel never targets a filtered-out order. Survives book reorders better than a raw list index.
- **Narrow terminals** (`width < 100`): compact column set (Kind / Fiat Amt / Premium / Payment) — Premium stays visible.
- **Short terminals** (`height < 4`): header row is dropped so at least one data row remains visible.

**Source**: `src/ui/tabs/orders_tab.rs`, `src/ui/helpers/order_selection.rs`

Expand Down
19 changes: 2 additions & 17 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,11 +691,8 @@ async fn main() -> Result<(), anyhow::Error> {
}
}

// Ensure the selected dispute index is valid when disputes list changes.
// Only count "initiated" disputes since that's what we display
// Ensure Pending dispute selection stays valid when the list changes.
{
use mostro_core::prelude::*;
use std::str::FromStr;
let disputes_lock = match disputes.lock() {
Ok(g) => g,
Err(e) => {
Expand All @@ -711,19 +708,7 @@ async fn main() -> Result<(), anyhow::Error> {
continue;
}
};
let initiated_count = disputes_lock
.iter()
.filter(|d| {
DisputeStatus::from_str(d.status.as_str())
.map(|s| s == DisputeStatus::Initiated)
.unwrap_or(false)
})
.count();
if initiated_count > 0 && app.selected_dispute_idx >= initiated_count {
app.selected_dispute_idx = initiated_count.saturating_sub(1);
} else if initiated_count == 0 {
app.selected_dispute_idx = 0;
}
crate::ui::helpers::clamp_pending_dispute_selection(&mut app, &disputes_lock);
}

// Process async completions before draw so popups appear without extra keypresses.
Expand Down
13 changes: 10 additions & 3 deletions src/ui/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,11 @@ pub struct AppState {
pub selected_order_id: Option<uuid::Uuid>,
/// Persistent scroll state for the Orders tab table
pub orders_table_state: TableState,
pub selected_dispute_idx: usize, // Selected dispute in Disputes Pending tab
/// Disputes Pending selection by **dispute UUID**, resolved against the
/// initiated-status projection (`helpers/dispute_selection.rs`).
pub selected_pending_dispute_id: Option<uuid::Uuid>,
/// Persistent scroll state for the Disputes Pending table
pub disputes_table_state: TableState,
/// Disputes In Progress / Finalized selection is by **dispute id**, not a raw
/// index into `admin_disputes_in_progress` (see `helpers/dispute_selection.rs`).
pub selected_dispute_id: Option<String>, // Selected dispute (by dispute id) in Disputes in Progress tab
Expand Down Expand Up @@ -280,7 +284,8 @@ impl AppState {
active_tab: initial_tab,
selected_order_id: None,
orders_table_state: TableState::default(),
selected_dispute_idx: 0,
selected_pending_dispute_id: None,
disputes_table_state: TableState::default(),
selected_dispute_id: None,
active_chat_party: ChatParty::Buyer,
admin_chat_input: String::new(),
Expand Down Expand Up @@ -367,9 +372,11 @@ impl AppState {
self.user_role = new_role;
self.active_tab = Tab::first(new_role);
self.mode = UiMode::default_for_role(new_role);
self.selected_dispute_idx = 0;
self.selected_pending_dispute_id = None;
self.disputes_table_state = TableState::default();
self.selected_settings_option = 0;
self.selected_order_id = None;
self.orders_table_state = TableState::default();
self.selected_dispute_id = None;
self.active_chat_party = ChatParty::Buyer;
self.admin_chat_input.clear();
Expand Down
7 changes: 1 addition & 6 deletions src/ui/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,7 @@ pub fn ui_draw(
}
}
(Tab::Admin(AdminTab::DisputesPending), UserRole::Admin) => {
tabs::disputes_tab::render_disputes_tab(
f,
content_area,
disputes,
app.selected_dispute_idx,
)
tabs::disputes_tab::render_disputes_tab(f, content_area, disputes, app)
}
(Tab::Admin(AdminTab::DisputesInProgress), UserRole::Admin) => {
tabs::disputes_in_progress_tab::render_disputes_in_progress(f, content_area, app)
Expand Down
148 changes: 146 additions & 2 deletions src/ui/helpers/dispute_selection.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,97 @@
//! Admin dispute selection helpers shared by rendering and key handling.
//! Dispute selection helpers shared by rendering and key handling.
//!
//! Covers both admin surfaces that pick a dispute from a filtered list:
//! - **Disputes Pending** — `mostro_core::Dispute` rows with `Initiated` status,
//! selected by UUID (`selected_pending_dispute_id`)
//! - **Disputes In Progress / Finalized** — local `AdminDispute` rows, selected by
//! dispute-id string (`selected_dispute_id`)

use std::str::FromStr;

use mostro_core::prelude::DisputeStatus;
use mostro_core::prelude::{Dispute, DisputeStatus};
use uuid::Uuid;

use crate::models::AdminDispute;
use crate::ui::{AppState, DisputeFilter};

/// Pending (initiated) disputes as `(original_index, dispute)` pairs.
pub fn get_initiated_disputes(disputes: &[Dispute]) -> Vec<(usize, Dispute)> {
disputes
.iter()
.enumerate()
.filter(|(_, d)| {
DisputeStatus::from_str(d.status.as_str())
.map(|s| s == DisputeStatus::Initiated)
.unwrap_or(false)
})
.map(|(i, d)| (i, d.clone()))
.collect()
}

/// Display row of the Pending-tab selection inside `initiated`.
///
/// Falls back to the first row when nothing is selected or the id is no longer
/// in the initiated list. Returns `None` only when `initiated` is empty.
pub fn selected_pending_display_idx(
selected_pending_dispute_id: Option<Uuid>,
initiated: &[(usize, Dispute)],
) -> Option<usize> {
if initiated.is_empty() {
return None;
}
Some(
selected_pending_dispute_id
.and_then(|id| initiated.iter().position(|(_, d)| d.id == id))
.unwrap_or(0),
)
}

/// The dispute the Pending table currently shows as selected.
///
/// Resolves `selected_pending_dispute_id` against the initiated-status projection
/// so Enter / take always acts on the highlighted row — never on a non-initiated
/// dispute still present in the raw vec.
pub fn selected_pending_dispute(app: &AppState, disputes: &[Dispute]) -> Option<Dispute> {
let mut initiated = get_initiated_disputes(disputes);
let idx = selected_pending_display_idx(app.selected_pending_dispute_id, &initiated)?;
Some(initiated.swap_remove(idx).1)
}

/// Move Pending-tab selection `delta` rows within initiated disputes, clamping
/// at both ends, and store the landing dispute's id.
pub fn move_pending_dispute_selection(app: &mut AppState, disputes: &[Dispute], delta: isize) {
let initiated = get_initiated_disputes(disputes);
let Some(idx) = selected_pending_display_idx(app.selected_pending_dispute_id, &initiated)
else {
app.selected_pending_dispute_id = None;
return;
};
let new_idx = idx
.saturating_add_signed(delta)
.min(initiated.len().saturating_sub(1));
app.selected_pending_dispute_id = Some(initiated[new_idx].1.id);
}

/// Clamp / clear Pending selection when the initiated list shrinks or empties.
///
/// Keeps a still-valid id unchanged; clears when nothing is initiated; otherwise
/// repairs a missing/stale id to the first initiated dispute (used from the main
/// loop when the dispute list refreshes).
pub fn clamp_pending_dispute_selection(app: &mut AppState, disputes: &[Dispute]) {
let initiated = get_initiated_disputes(disputes);
if initiated.is_empty() {
app.selected_pending_dispute_id = None;
return;
}
if let Some(id) = app.selected_pending_dispute_id {
if initiated.iter().any(|(_, d)| d.id == id) {
return;
}
}
// Missing or stale id → first visible initiated dispute.
app.selected_pending_dispute_id = Some(initiated[0].1.id);
}

/// Filter disputes based on the current filter state.
/// Returns owned data so the caller can mutate app (e.g. scroll state) in the same block.
pub fn get_filtered_disputes(app: &AppState) -> Vec<(usize, AdminDispute)> {
Expand Down Expand Up @@ -230,4 +315,63 @@ mod tests {
move_dispute_selection(&mut app, 1);
assert_eq!(app.selected_dispute_id, None, "navigation stays a no-op");
}

fn pending_dispute(nibble: u8) -> Dispute {
let mut d = Dispute::new(Uuid::from_bytes([nibble * 0x11; 16]), "active".to_string());
d.id = Uuid::from_bytes([nibble * 0x11; 16]);
d
}

#[test]
fn pending_selection_by_id_survives_list_reorder() {
let keep = Uuid::from_bytes([0x22; 16]);
let other = Uuid::from_bytes([0x11; 16]);
let mut app = AppState::new(UserRole::Admin);
app.selected_pending_dispute_id = Some(keep);

let reordered = vec![pending_dispute(1), pending_dispute(2)];
assert_eq!(reordered[1].id, keep);
assert_eq!(reordered[0].id, other);

let selected = selected_pending_dispute(&app, &reordered).expect("selection");
assert_eq!(selected.id, keep);
}

#[test]
fn pending_hidden_selection_falls_back_to_first_initiated() {
let initiated = Uuid::from_bytes([0x11; 16]);
let taken = Uuid::from_bytes([0x22; 16]);
let mut app = AppState::new(UserRole::Admin);
app.selected_pending_dispute_id = Some(taken);

let mut disputes = vec![pending_dispute(1), pending_dispute(2)];
disputes[1].status = "in-progress".to_string();

let selected = selected_pending_dispute(&app, &disputes).expect("fallback");
assert_eq!(selected.id, initiated);

move_pending_dispute_selection(&mut app, &disputes, 1);
assert_eq!(
app.selected_pending_dispute_id,
Some(initiated),
"only one initiated row — clamp stays put"
);
}

#[test]
fn clamp_pending_clears_when_empty_and_repairs_stale_id() {
let mut app = AppState::new(UserRole::Admin);
app.selected_pending_dispute_id = Some(Uuid::from_bytes([0x99; 16]));

clamp_pending_dispute_selection(&mut app, &[]);
assert_eq!(app.selected_pending_dispute_id, None);

let disputes = vec![pending_dispute(1)];
clamp_pending_dispute_selection(&mut app, &disputes);
assert_eq!(
app.selected_pending_dispute_id,
Some(disputes[0].id),
"stale id repaired to first initiated"
);
}
}
36 changes: 35 additions & 1 deletion src/ui/helpers/layout.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,44 @@
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Borders, Paragraph};
use ratatui::widgets::{Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState};

use crate::ui::PRIMARY_COLOR;

/// Vertical scrollbar for a bordered table/list whose selection scrolls with
/// [`ratatui::widgets::TableState`] / [`ratatui::widgets::ListState`].
///
/// Draws only when `content_len` exceeds the visible body. The track is confined
/// to data rows (skipping the top border and optional header) so the thumb does
/// not overwrite corner glyphs. Thumb position is the viewport **offset** after
/// a stateful render, not the selected index.
pub fn render_table_list_scrollbar(
f: &mut ratatui::Frame,
area: Rect,
content_len: usize,
visible_rows: usize,
header_rows: u16,
viewport_offset: usize,
) {
if content_len <= visible_rows || visible_rows == 0 {
return;
}
let track = Rect {
x: area.x,
y: area.y + 1 + header_rows,
width: area.width,
height: visible_rows as u16,
};
let mut scrollbar_state = ScrollbarState::new(content_len)
.viewport_content_length(visible_rows)
.position(viewport_offset);
f.render_stateful_widget(
Scrollbar::default().orientation(ScrollbarOrientation::VerticalRight),
track,
&mut scrollbar_state,
);
}

/// Creates a centered popup area within the given area.
pub fn create_centered_popup(area: Rect, width: u16, height: u16) -> Rect {
let (popup_width, popup_height) = (width.min(area.width), height.min(area.height));
Expand Down
7 changes: 5 additions & 2 deletions src/ui/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@ pub use chat_visibility::{
get_selected_chat_message, get_visible_attachment_messages, message_visible_for_party,
};
pub use dispute_selection::{
get_filtered_disputes, move_dispute_selection, selected_display_idx, selected_filtered_dispute,
clamp_pending_dispute_selection, get_filtered_disputes, get_initiated_disputes,
move_dispute_selection, move_pending_dispute_selection, selected_display_idx,
selected_filtered_dispute, selected_pending_display_idx, selected_pending_dispute,
};
pub use formatting::{
format_local_timestamp, format_order_id, format_premium, format_user_rating,
is_dispute_finalized, relative_time_compact, short_order_id,
};
pub use layout::{
create_centered_popup, render_help_text, render_yes_no_buttons, render_yes_no_cancel_buttons,
create_centered_popup, render_help_text, render_table_list_scrollbar, render_yes_no_buttons,
render_yes_no_cancel_buttons,
};
pub use order_chat_projection::{
active_order_chat_list_len, active_order_chat_list_snapshot, build_active_order_chat_list,
Expand Down
Loading