mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat(cli): add devices list
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user