feat(cli): add devices list

This commit is contained in:
Tien Do Nam
2026-07-30 14:35:13 +02:00
parent fb17512bdc
commit f50bcb372b
10 changed files with 498 additions and 35 deletions
+115
View File
@@ -0,0 +1,115 @@
//! The device list overlay: paired and discovered devices, with the send
//! and unpair actions.
use super::App;
use crate::device_list::{DeviceList, DeviceListOutcome, DeviceRow, Row};
use crate::ui::Category;
use crossterm::event::KeyEvent;
impl App {
pub(super) fn open_device_list(&mut self) {
match DeviceList::open(self.device_rows()) {
Ok(list) => {
self.ui.suspend();
self.device_list = Some(list);
}
Err(err) => {
self.ui
.log_plain(&format!("Could not open the device list: {err}"));
}
}
}
/// Builds the rows of the device list: the paired devices (with the
/// live address of those that are also discovered), then the discovered
/// devices that are not paired.
pub(super) fn device_rows(&self) -> Vec<Row> {
let mut rows = vec![Row::Header("Paired")];
let mut empty = true;
for (fingerprint, paired) in self.storage.paired.iter() {
empty = false;
let discovered = self.registry.by_fingerprint(fingerprint);
rows.push(Row::Device(DeviceRow {
fingerprint: fingerprint.clone(),
alias: discovered
.map(|device| device.alias.clone())
.unwrap_or_else(|| paired.alias.clone()),
slot: discovered.and_then(|device| device.slot),
host: discovered.map(|device| device.host.clone()),
paired: true,
}));
}
if empty {
rows.push(Row::Empty);
}
rows.push(Row::Spacer);
rows.push(Row::Header("Discovered"));
let mut empty = true;
for device in self.registry.devices() {
if self.storage.paired.contains(&device.fingerprint) {
continue;
}
empty = false;
rows.push(Row::Device(DeviceRow {
fingerprint: device.fingerprint.clone(),
alias: device.alias.clone(),
slot: device.slot,
host: Some(device.host.clone()),
paired: false,
}));
}
if empty {
rows.push(Row::Empty);
}
rows
}
pub(super) fn handle_device_list_key(&mut self, key: KeyEvent) {
let Some(list) = &mut self.device_list else {
return;
};
match list.handle_key(key) {
DeviceListOutcome::Open => {}
DeviceListOutcome::Closed => self.close_device_list(),
DeviceListOutcome::Send { fingerprint } => {
self.close_device_list();
if let Some(device) = self.registry.by_fingerprint(&fingerprint).cloned() {
self.open_picker(device);
}
}
DeviceListOutcome::Unpair { fingerprint } => self.unpair(&fingerprint),
}
}
pub(super) fn close_device_list(&mut self) {
if let Some(list) = self.device_list.take() {
list.close();
self.ui.resume();
}
}
/// Removes a paired device; the list stays open and refreshes (the
/// device reappears under "Discovered" when it is still around). The
/// log line shows up once the list is closed.
fn unpair(&mut self, fingerprint: &str) {
match self.storage.paired.remove(fingerprint) {
Ok(Some(device)) => {
self.ui
.log(Category::Discovery, &format!("{}: Unpaired", device.alias));
}
Ok(None) => {}
Err(err) => {
self.ui.log(
Category::Discovery,
&format!("Unpaired for this run, but saving failed: {err:#}"),
);
}
}
let rows = self.device_rows();
if let Some(list) = &mut self.device_list {
list.set_rows(rows);
list.draw();
}
}
}
+19 -5
View File
@@ -1,9 +1,11 @@
mod devices;
mod discovery;
mod receive;
mod sending;
mod status;
use crate::Args;
use crate::device_list::DeviceList;
use crate::devices::DeviceRegistry;
use crate::picker::Picker;
use crate::storage;
@@ -69,6 +71,7 @@ struct App {
receive: Option<ReceiveSession>,
send: Option<SendState>,
picker: Option<Picker>,
device_list: Option<DeviceList>,
events_tx: mpsc::Sender<AppEvent>,
}
@@ -155,6 +158,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> {
receive: None,
send: None,
picker: None,
device_list: None,
events_tx: events_tx.clone(),
};
@@ -181,12 +185,13 @@ pub async fn run(args: Args) -> anyhow::Result<()> {
}
}
// Shutdown: leave a possibly open picker, restore the terminal, stop the
// Shutdown: leave a possibly open modal, restore the terminal, stop the
// network tasks (briefly, so the ports are released cleanly).
if let Some(picker) = app.picker.take() {
picker.close();
app.ui.resume();
}
app.close_device_list();
app.ui.set_status(None);
let _ = crossterm::terminal::disable_raw_mode();
let _ = server_stop_tx.send(());
@@ -247,17 +252,22 @@ impl App {
return self.handle_ctrl_c();
}
// While the picker is open it consumes every key.
// While the picker or the device list is open it consumes every key.
if self.picker.is_some() {
self.handle_picker_key(key);
return false;
}
if self.device_list.is_some() {
self.handle_device_list_key(key);
return false;
}
if let KeyCode::Char(c) = key.code {
match c.to_ascii_lowercase() {
'y' => self.answer_pending(Answer::Accept),
'n' => self.answer_pending(Answer::Decline),
'p' => self.answer_pending(Answer::AcceptAndPair),
'd' => self.open_device_list(),
'1'..='9' => self.start_picking(c as u8 - b'0'),
_ => {}
}
@@ -265,15 +275,19 @@ impl App {
false
}
/// Cancels the current activity: the picker, the pending request and the
/// active transfers. Returns `true` (quit) only when there was nothing to
/// cancel.
/// Cancels the current activity: the open modal, the pending request and
/// the active transfers. Returns `true` (quit) only when there was
/// nothing to cancel.
fn handle_ctrl_c(&mut self) -> bool {
if let Some(picker) = self.picker.take() {
picker.close();
self.ui.resume();
return false;
}
if self.device_list.is_some() {
self.close_device_list();
return false;
}
let mut cancelled = false;
if self.pending.is_some() {
self.answer_pending(Answer::Decline);
+12 -8
View File
@@ -2,6 +2,7 @@
//! transfer driven by the [`crate::send_task`].
use super::App;
use crate::devices::Device;
use crate::picker::{Picker, PickerOutcome};
use crate::send_task;
use crate::ui::Category;
@@ -27,17 +28,20 @@ pub(super) struct SendState {
impl App {
pub(super) fn start_picking(&mut self, slot: u8) {
let Some(device) = self.registry.by_slot(slot) else {
let Some(device) = self.registry.by_slot(slot).cloned() else {
self.ui
.log(Category::Send, &format!("No device on [{slot}]"));
return;
};
self.open_picker(device);
}
pub(super) fn open_picker(&mut self, device: Device) {
if self.send.is_some() {
self.ui.log(Category::Send, "A send is already in progress");
return;
}
let alias = device.alias.clone();
match Picker::open(slot) {
match Picker::open(device.fingerprint) {
Ok(picker) => {
self.ui.suspend();
self.picker = Some(picker);
@@ -45,7 +49,7 @@ impl App {
Err(err) => {
self.ui.log(
Category::Send,
&format!("{alias}: could not open the file picker: {err}"),
&format!("{}: could not open the file picker: {err}", device.alias),
);
}
}
@@ -59,10 +63,10 @@ impl App {
PickerOutcome::Open => {}
PickerOutcome::Picked(files) => {
let picker = self.picker.take().unwrap();
let slot = picker.slot;
let fingerprint = picker.fingerprint.clone();
picker.close();
self.ui.resume();
self.start_send(slot, files);
self.start_send(&fingerprint, files);
}
PickerOutcome::Cancelled => {
let picker = self.picker.take().unwrap();
@@ -72,8 +76,8 @@ impl App {
}
}
fn start_send(&mut self, slot: u8, picked: Vec<PathBuf>) {
let Some(device) = self.registry.by_slot(slot).cloned() else {
fn start_send(&mut self, fingerprint: &str, picked: Vec<PathBuf>) {
let Some(device) = self.registry.by_fingerprint(fingerprint).cloned() else {
return;
};
+9
View File
@@ -14,6 +14,15 @@ impl App {
picker.draw();
return;
}
if self.device_list.is_some() {
// Also picks up devices discovered while the list is open.
let rows = self.device_rows();
if let Some(list) = &mut self.device_list {
list.set_rows(rows);
list.draw();
}
return;
}
self.render_status();
}
+279
View File
@@ -0,0 +1,279 @@
use crate::util;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState, Paragraph};
use std::io::Stdout;
/// One row of the device list: the section headlines, the devices and the
/// filler rows around them.
pub enum Row {
Header(&'static str),
/// The placeholder shown instead of an empty section.
Empty,
/// A blank line between the sections.
Spacer,
Device(DeviceRow),
}
/// A selectable device entry.
pub struct DeviceRow {
pub fingerprint: String,
pub alias: String,
/// The send hotkey (1-9) of the discovered device, if one was free.
pub slot: Option<u8>,
/// Where the device was last seen; `None` for a paired device that has
/// not been discovered in this run and therefore cannot be sent to.
pub host: Option<String>,
/// Paired devices offer the remove action.
pub paired: bool,
}
/// What a key press did to the device list.
pub enum DeviceListOutcome {
/// The list stays open.
Open,
/// The user closed the list.
Closed,
/// The user wants to send files to this device.
Send { fingerprint: String },
/// The user confirmed removing this paired device.
Unpair { fingerprint: String },
}
/// A pending remove confirmation, answered with y/n.
struct Confirm {
fingerprint: String,
alias: String,
}
/// A modal list of the paired and discovered devices, rendered on the
/// alternate screen while the log UI is suspended.
///
/// Up/Down navigates, Enter sends to the highlighted device, Delete (or
/// Backspace) removes a paired device after a y/n confirmation, Esc or D
/// closes the list.
pub struct DeviceList {
terminal: Terminal<CrosstermBackend<Stdout>>,
rows: Vec<Row>,
list_state: ListState,
confirm: Option<Confirm>,
}
impl DeviceList {
/// Enters the alternate screen and shows the list.
/// The caller must suspend the log UI first and resume it after [DeviceList::close].
pub fn open(rows: Vec<Row>) -> anyhow::Result<Self> {
util::enter_alternate_screen()?;
let terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
let mut list = Self {
terminal,
rows,
list_state: ListState::default(),
confirm: None,
};
list.select_first_device();
list.draw();
Ok(list)
}
/// Leaves the alternate screen. Must be called exactly once.
pub fn close(self) {
util::leave_alternate_screen();
}
/// Replaces the rows (discovery goes on while the list is open), keeping
/// the highlight on the same device where possible.
pub fn set_rows(&mut self, rows: Vec<Row>) {
let highlighted = self
.selected_device()
.map(|device| device.fingerprint.clone());
self.rows = rows;
if let Some(confirm) = &self.confirm
&& !self
.device_rows()
.any(|(_, device)| device.paired && device.fingerprint == confirm.fingerprint)
{
self.confirm = None;
}
let index = highlighted.and_then(|fingerprint| {
self.device_rows()
.find(|(_, device)| device.fingerprint == fingerprint)
.map(|(index, _)| index)
});
match index {
Some(index) => self.list_state.select(Some(index)),
None => self.select_first_device(),
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> DeviceListOutcome {
// The confirmation prompt consumes every key.
if self.confirm.is_some() {
match key.code {
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'y') => {
let confirm = self.confirm.take().unwrap();
return DeviceListOutcome::Unpair {
fingerprint: confirm.fingerprint,
};
}
KeyCode::Esc => self.confirm = None,
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'n') => self.confirm = None,
_ => {}
}
self.draw();
return DeviceListOutcome::Open;
}
match key.code {
KeyCode::Esc => return DeviceListOutcome::Closed,
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'d') => return DeviceListOutcome::Closed,
KeyCode::Up => self.move_cursor(-1),
KeyCode::Down => self.move_cursor(1),
KeyCode::Enter => {
// A paired device that was never discovered has no address.
if let Some(device) = self.selected_device()
&& device.host.is_some()
{
return DeviceListOutcome::Send {
fingerprint: device.fingerprint.clone(),
};
}
}
// Backspace, because that is what the key labeled "delete"
// sends on macOS.
KeyCode::Delete | KeyCode::Backspace => {
if let Some(device) = self.selected_device()
&& device.paired
{
self.confirm = Some(Confirm {
fingerprint: device.fingerprint.clone(),
alias: device.alias.clone(),
});
}
}
_ => {}
}
self.draw();
DeviceListOutcome::Open
}
/// The device rows with their positions in [DeviceList::rows].
fn device_rows(&self) -> impl Iterator<Item = (usize, &DeviceRow)> {
self.rows
.iter()
.enumerate()
.filter_map(|(index, row)| match row {
Row::Device(device) => Some((index, device)),
_ => None,
})
}
/// The highlighted device, or `None` when there are no devices.
fn selected_device(&self) -> Option<&DeviceRow> {
match self
.list_state
.selected()
.and_then(|index| self.rows.get(index))
{
Some(Row::Device(device)) => Some(device),
_ => None,
}
}
fn select_first_device(&mut self) {
let first = self.device_rows().next().map(|(index, _)| index);
self.list_state.select(first);
}
fn move_cursor(&mut self, delta: isize) {
let indices: Vec<usize> = self.device_rows().map(|(index, _)| index).collect();
if indices.is_empty() {
return;
}
let cursor = self
.list_state
.selected()
.and_then(|selected| indices.iter().position(|&index| index == selected))
.unwrap_or_default() as isize;
let cursor = (cursor + delta).rem_euclid(indices.len() as isize);
self.list_state.select(Some(indices[cursor as usize]));
}
pub fn draw(&mut self) {
let Self {
terminal,
rows,
list_state,
confirm,
} = self;
let help = match confirm {
Some(confirm) => Span::styled(
format!(" Remove {}? y/n", confirm.alias),
Style::default().add_modifier(Modifier::BOLD),
),
None => Span::raw(" ↑/↓: navigate Enter: send Del: remove (paired only) Esc: close"),
};
let _ = terminal.draw(|frame| {
let [main_area, help_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
let list = List::new(rows.iter().map(row_item))
.block(
Block::default()
.borders(Borders::ALL)
.title_top(" Devices "),
)
.highlight_spacing(HighlightSpacing::Always)
.highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(list, main_area, list_state);
frame.render_widget(Paragraph::new(help), help_area);
});
}
}
fn row_item(row: &Row) -> ListItem<'_> {
match row {
Row::Header(title) => ListItem::new(Span::styled(
*title,
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)),
Row::Empty => ListItem::new(Span::styled(
" (none)",
Style::default().fg(Color::DarkGray),
)),
Row::Spacer => ListItem::new(""),
Row::Device(device) => {
let slot = match device.slot {
Some(slot) => slot.to_string(),
None => "-".to_string(),
};
match &device.host {
Some(host) => ListItem::new(Span::styled(
format!(" [{slot}] {} ({host})", device.alias),
Style::default().fg(Color::White),
)),
None => ListItem::new(Span::styled(
format!(" [{slot}] {} (offline)", device.alias),
Style::default().fg(Color::DarkGray),
)),
}
}
}
}
+11
View File
@@ -76,4 +76,15 @@ impl DeviceRegistry {
pub fn by_slot(&self, slot: u8) -> Option<&Device> {
self.devices.iter().find(|device| device.slot == Some(slot))
}
pub fn by_fingerprint(&self, fingerprint: &str) -> Option<&Device> {
self.devices
.iter()
.find(|device| device.fingerprint == fingerprint)
}
/// All devices in discovery order.
pub fn devices(&self) -> &[Device] {
&self.devices
}
}
+2
View File
@@ -1,5 +1,6 @@
mod app;
mod banner;
mod device_list;
mod devices;
mod picker;
mod send_task;
@@ -33,6 +34,7 @@ const HELP_SECTIONS: &str = "Events:\n \
R Receive files\n\
\nHotkeys:\n \
1-9 Send files to the device with that number\n \
D Show the paired and discovered devices\n \
Y/N/P Accept / Decline / Accept-and-Pair an incoming request\n \
Ctrl+C Cancel the current transfer or request, or quit when idle\n \
\nEnvironment Variables:\n \
+7 -22
View File
@@ -1,6 +1,5 @@
use crate::util;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{Clear, ClearType};
use crossterm::{cursor, execute};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
@@ -24,8 +23,8 @@ const SCROLL_COUNT: usize = 12;
/// The explorer only supplies the directory listing; navigation and rendering
/// are done here so that the filter can hide entries.
pub struct Picker {
/// The device slot the picked files will be sent to.
pub slot: u8,
/// The fingerprint of the device the picked files will be sent to.
pub fingerprint: String,
explorer: FileExplorer,
terminal: Terminal<CrosstermBackend<Stdout>>,
@@ -56,16 +55,12 @@ pub enum PickerOutcome {
impl Picker {
/// Enters the alternate screen and shows the picker.
/// The caller must suspend the log UI first and resume it after [Picker::close].
pub fn open(slot: u8) -> anyhow::Result<Self> {
pub fn open(fingerprint: String) -> anyhow::Result<Self> {
let explorer = FileExplorer::new()?;
execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
cursor::Hide
)?;
util::enter_alternate_screen()?;
let terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
let mut picker = Self {
slot,
fingerprint,
explorer,
terminal,
selected: Vec::new(),
@@ -81,17 +76,7 @@ impl Picker {
/// Leaves the alternate screen. Must be called exactly once.
pub fn close(self) {
// Clears via crossterm, not `Terminal::clear`: the latter queries the
// cursor position, whose response is read from the event stream but
// the keyboard reader thread is parked in `crossterm::event::read()`,
// so the query only ever returns after crossterm's 2s timeout.
let _ = execute!(
std::io::stdout(),
Clear(ClearType::All),
cursor::MoveTo(0, 0),
crossterm::terminal::LeaveAlternateScreen,
cursor::Show
);
util::leave_alternate_screen();
}
pub fn handle_key(&mut self, key: KeyEvent) -> PickerOutcome {
+14
View File
@@ -84,6 +84,20 @@ impl PairedDevices {
self.save()
}
/// Removes a device and saves the file. The device stays removed for
/// this run even when saving fails.
pub fn remove(&mut self, fingerprint: &str) -> anyhow::Result<Option<PairedDevice>> {
match self.file.devices.remove(fingerprint) {
Some(device) => self.save().map(|()| Some(device)),
None => Ok(None),
}
}
/// All paired devices with their fingerprints, ordered by fingerprint.
pub fn iter(&self) -> impl Iterator<Item = (&String, &PairedDevice)> {
self.file.devices.iter()
}
fn save(&self) -> anyhow::Result<()> {
// Write-then-rename so a crash cannot leave a truncated file.
let temp = self.path.with_extension("json.tmp");
+30
View File
@@ -1,7 +1,37 @@
use crossterm::terminal::{Clear, ClearType};
use crossterm::{cursor, execute};
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// Enters the alternate screen for a modal widget (the file picker or the
/// device list). The caller must suspend the log UI first and resume it
/// after [leave_alternate_screen].
pub fn enter_alternate_screen() -> anyhow::Result<()> {
execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
cursor::Hide
)?;
Ok(())
}
/// Leaves the alternate screen. Must be called exactly once per enter.
///
/// Clears via crossterm, not `Terminal::clear`: the latter queries the
/// cursor position, whose response is read from the event stream but
/// the keyboard reader thread is parked in `crossterm::event::read()`,
/// so the query only ever returns after crossterm's 2s timeout.
pub fn leave_alternate_screen() {
let _ = execute!(
std::io::stdout(),
Clear(ClearType::All),
cursor::MoveTo(0, 0),
crossterm::terminal::LeaveAlternateScreen,
cursor::Show
);
}
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;