refactor: cut what a whole-repo audit proved dead, padded, or hand-rolled
Seven region audits, every finding adversarially verified against the whole workspace before being applied; 80 landed and the two that failed in application were refuted honestly (ParseError has a from_str_radix producer the audit missed; NatPmpError's Display is the only reader of its diagnostic payloads). The bigger cuts: - The NewPeer/PierceFireWall cluster: an operation variant nothing ever sent, its 77-line arm, a protocol-wrong Client::pierce_firewall that wrote a peer-init frame down the server socket, and the struct only they used. The live paths in peer/listen.rs are untouched. - The ops loop reads `for operation in reader` instead of hand-rolling recv/break, de-indenting the whole dispatch body. - Dead lib surface: Message::print_hex and four unused accessors, UploadFailed, registry and actor getters, build_watch_user, strip_ansi_codes, delegating shims on ClientContext. Removing public items makes this a semver major, which the release pipeline enforces. - Never-used flexibility: the state-migration framework that never held a migration, border-type helpers that all answered Rounded, the TUI's max_concurrent_downloads field nothing read. - Convergence: six poll loops into poll_until, three wrap-around navigation blocks into cycle, hit tests into Rect::contains, char walkers into floor/ceil_char_boundary, five Download literals into one helper, the suites' shared scaffolding into tests/common. Deliberately not taken: swapping the hand-rolled zlib inflate and MD5 for crates (they replace wire-facing machinery whose tolerance of real-world peer quirks no local test exercises) and pulling chrono into the lib for a log timestamp. Recorded for their own decision. Net -1002 lines. Both e2e modes stay green: 94 CLI tests each way, 16 protocol, 33 lib, unit suites, clippy pedantic clean, aislop 100/100 with the four scanner false positives suppressed inline where they fire.
This commit is contained in:
@@ -149,8 +149,11 @@ pub trait SessionApi: Send + Sync {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
// aislop-ignore-next-line complexity/function-too-long -- scanner bug: a bodyless trait signature followed only by more signatures has no closing brace, so the counter runs to EOF. One line.
|
||||
fn shared_counts(&self) -> (u32, u32);
|
||||
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
|
||||
fn shared_directories(&self) -> Vec<String>;
|
||||
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
|
||||
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()>;
|
||||
|
||||
/// Point future transfers at `directory`.
|
||||
@@ -158,6 +161,7 @@ pub trait SessionApi: Send + Sync {
|
||||
/// Only a daemon holds a download folder of its own — locally every
|
||||
/// download call names its directory, so the local session has nothing to
|
||||
/// store and accepts silently.
|
||||
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
|
||||
fn set_download_directory(&self, directory: String) -> Result<()>;
|
||||
}
|
||||
|
||||
|
||||
@@ -392,9 +392,10 @@ pub enum ConfigCommand {
|
||||
}
|
||||
|
||||
/// How to order search results.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
|
||||
pub enum SortKey {
|
||||
/// Free slot first, then bitrate, peer speed, and size
|
||||
#[default]
|
||||
Best,
|
||||
/// Largest file first
|
||||
Size,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! get one `source` line appended to their rc file, marked so `uninstall` can
|
||||
//! take back exactly what was added and nothing else.
|
||||
|
||||
use super::{unwritable, xdg};
|
||||
use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell};
|
||||
use crate::output::{CliError, CliResult, CompletionRecord, Out};
|
||||
use clap::CommandFactory;
|
||||
@@ -39,7 +40,7 @@ struct Target {
|
||||
impl Target {
|
||||
fn record(&self, action: &str) -> CompletionRecord {
|
||||
CompletionRecord {
|
||||
shell: self.shell.name().to_string(),
|
||||
shell: self.shell.generator().to_string(),
|
||||
path: self.script.display().to_string(),
|
||||
action: action.to_string(),
|
||||
}
|
||||
@@ -51,14 +52,6 @@ impl Target {
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bash => "bash",
|
||||
Self::Zsh => "zsh",
|
||||
Self::Fish => "fish",
|
||||
}
|
||||
}
|
||||
|
||||
const fn generator(self) -> clap_complete::Shell {
|
||||
match self {
|
||||
Self::Bash => clap_complete::Shell::Bash,
|
||||
@@ -213,14 +206,6 @@ impl Dirs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shells follow the XDG layout on every platform, unlike this client's own
|
||||
/// config, which follows each platform's convention (see `persist::paths`).
|
||||
fn xdg(variable: &str, home: &Path, fallback: &str) -> PathBuf {
|
||||
std::env::var_os(variable)
|
||||
.filter(|dir| !dir.is_empty())
|
||||
.map_or_else(|| home.join(fallback), PathBuf::from)
|
||||
}
|
||||
|
||||
fn write_script(path: &Path, script: &[u8]) -> CliResult<bool> {
|
||||
if std::fs::read(path).is_ok_and(|found| found == script) {
|
||||
return Ok(false);
|
||||
@@ -288,10 +273,6 @@ fn create_parent(path: &Path) -> CliResult {
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritable(path: &Path, error: &std::io::Error) -> CliError {
|
||||
CliError::usage(format!("cannot write {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::remote::{Endpoint, RemoteSession};
|
||||
use soulseek_rs::types::{RoomEvent, RoomInfo};
|
||||
use soulseek_rs::{Client, ClientSettings, SessionLoss};
|
||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -223,6 +224,26 @@ fn login(settings: ClientSettings, address: &str) -> CliResult<Client> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask `f` every `poll` until it answers or `timeout` runs out.
|
||||
///
|
||||
/// The library reports what the network sent by handing it over on the next
|
||||
/// call, so a command that wants one answer has to keep asking. Checking first
|
||||
/// and sleeping after means an answer already waiting costs nothing.
|
||||
fn poll_until<T>(
|
||||
timeout: Duration,
|
||||
poll: Duration,
|
||||
mut f: impl FnMut() -> Option<T>,
|
||||
) -> Option<T> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(answer) = f() {
|
||||
return Some(answer);
|
||||
}
|
||||
std::thread::sleep(poll);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ask for the room list and wait for the server's reply.
|
||||
///
|
||||
/// Events already queued are discarded first: servers push a room list at
|
||||
@@ -235,21 +256,15 @@ pub fn await_room_list(
|
||||
let _ = client.take_room_events();
|
||||
client.request_room_list().ok()?;
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
let listed =
|
||||
client.take_room_events().into_iter().find_map(
|
||||
|event| match event {
|
||||
RoomEvent::List(rooms) => Some(rooms),
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
if listed.is_some() {
|
||||
return listed;
|
||||
}
|
||||
std::thread::sleep(poll);
|
||||
}
|
||||
None
|
||||
poll_until(timeout, poll, || {
|
||||
client
|
||||
.take_room_events()
|
||||
.into_iter()
|
||||
.find_map(|event| match event {
|
||||
RoomEvent::List(rooms) => Some(rooms),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait until the server has answered something we asked for after `send`.
|
||||
@@ -295,12 +310,10 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult {
|
||||
}
|
||||
Commands::User(args) => peer::user(ctx, &args),
|
||||
Commands::Whoami => peer::whoami(ctx),
|
||||
// These need no account, so main.rs answers them before asking for
|
||||
// credentials; the arms exist only to keep the match exhaustive.
|
||||
Commands::Portmap => portmap(&ctx.out, ctx.settings.listen_port),
|
||||
// These need the config file as well as a session, so main.rs runs
|
||||
// them where the store is in scope.
|
||||
Commands::Serve(_)
|
||||
// main.rs runs these itself: some need no account, the rest need the
|
||||
// config store in scope. The arms only keep the match exhaustive.
|
||||
Commands::Portmap
|
||||
| Commands::Serve(_)
|
||||
| Commands::Daemon(_)
|
||||
| Commands::Wish(_)
|
||||
| Commands::Config(_)
|
||||
@@ -317,6 +330,19 @@ fn listen_span(duration: u64, follow: bool) -> Option<Duration> {
|
||||
(!follow).then(|| Duration::from_secs(duration))
|
||||
}
|
||||
|
||||
/// Where shells and coding agents keep their configuration: the XDG layout on
|
||||
/// every platform, unlike this client's own config, which follows each
|
||||
/// platform's convention (see `persist::paths`).
|
||||
fn xdg(variable: &str, home: &Path, fallback: &str) -> PathBuf {
|
||||
std::env::var_os(variable)
|
||||
.filter(|dir| !dir.is_empty())
|
||||
.map_or_else(|| home.join(fallback), PathBuf::from)
|
||||
}
|
||||
|
||||
fn unwritable(path: &Path, error: &std::io::Error) -> CliError {
|
||||
CliError::usage(format!("cannot write {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
/// Probe automatic port mapping. A router that does not answer is a negative
|
||||
/// answer, not a crash, so it exits 4 the way `grep` exits 1 on no match.
|
||||
pub fn portmap(out: &Out, port: u16) -> CliResult {
|
||||
|
||||
@@ -47,14 +47,9 @@ fn own_privileges(client: &dyn crate::api::SessionApi) -> Option<u32> {
|
||||
if client.check_privileges().is_err() {
|
||||
return None;
|
||||
}
|
||||
let deadline = Instant::now() + PRIVILEGE_TIMEOUT;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(seconds) = client.own_privilege_seconds() {
|
||||
return Some(seconds);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
None
|
||||
super::poll_until(PRIVILEGE_TIMEOUT, Duration::from_millis(50), || {
|
||||
client.own_privilege_seconds()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn user(ctx: &Ctx, args: &UserArgs) -> CliResult {
|
||||
@@ -141,7 +136,7 @@ pub fn serve(
|
||||
.warn("the share index is empty — peers will find nothing");
|
||||
}
|
||||
|
||||
let span = (!args.follow).then(|| Duration::from_secs(args.duration));
|
||||
let span = super::listen_span(args.duration, args.follow);
|
||||
ctx.out.status(&match span {
|
||||
Some(span) => format!("online for {}s…", span.as_secs()),
|
||||
None => "online until interrupted…".to_string(),
|
||||
@@ -155,20 +150,10 @@ pub fn serve(
|
||||
// served well inside one poll, and "it had to wait" is precisely what a
|
||||
// transfer log exists to report.
|
||||
for queued in session.client.take_upload_events() {
|
||||
let record = upload_record(&queued);
|
||||
let key = (record.user.clone(), record.path.clone());
|
||||
if reported.get(&key) != Some(&record) {
|
||||
ctx.out.emit(&record);
|
||||
reported.insert(key, record);
|
||||
}
|
||||
emit_changed(&mut reported, upload_record(&queued), &ctx.out);
|
||||
}
|
||||
for upload in session.client.uploads() {
|
||||
let record = upload_record(&upload);
|
||||
let key = (record.user.clone(), record.path.clone());
|
||||
if reported.get(&key) != Some(&record) {
|
||||
ctx.out.emit(&record);
|
||||
reported.insert(key, record);
|
||||
}
|
||||
emit_changed(&mut reported, upload_record(&upload), &ctx.out);
|
||||
}
|
||||
if let Some(sweeper) = sweeper.as_mut() {
|
||||
sweeper.tick(ctx, session.client.as_ref());
|
||||
@@ -211,8 +196,7 @@ fn upload_record(upload: &UploadInfo) -> UploadRecord {
|
||||
}
|
||||
|
||||
/// Emit `record` only when it differs from the last one reported for that
|
||||
/// (user, file). Exposed for tests; the serve loop inlines the same rule.
|
||||
#[cfg(test)]
|
||||
/// (user, file).
|
||||
fn emit_changed(
|
||||
reported: &mut HashMap<(String, String), UploadRecord>,
|
||||
record: UploadRecord,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! inside it and is written out on demand. Installing over an older copy is
|
||||
//! what updating means, which is why there is no separate verb for it.
|
||||
|
||||
use super::{unwritable, xdg};
|
||||
use crate::cli::{SkillsArgs, SkillsCommand};
|
||||
use crate::output::{CliError, CliResult, Out, SkillRecord};
|
||||
use directories::UserDirs;
|
||||
@@ -122,7 +123,7 @@ fn targets(args: &SkillsArgs) -> CliResult<Vec<Target>> {
|
||||
)
|
||||
})?;
|
||||
|
||||
let found = detected(&home, &config_home(&home));
|
||||
let found = detected(&home, &xdg("XDG_CONFIG_HOME", &home, ".config"));
|
||||
if found.is_empty() {
|
||||
return Err(CliError::no_results(
|
||||
"no coding agent found in your home directory; name a skills \
|
||||
@@ -149,15 +150,6 @@ fn detected(home: &Path, config_home: &Path) -> Vec<Target> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where an agent keeps its own configuration, which is the XDG location on
|
||||
/// every platform — unlike this client's config, which follows each platform's
|
||||
/// own convention (see `persist::paths`).
|
||||
fn config_home(home: &Path) -> PathBuf {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.filter(|dir| !dir.is_empty())
|
||||
.map_or_else(|| home.join(".config"), PathBuf::from)
|
||||
}
|
||||
|
||||
fn current(file: &Path) -> bool {
|
||||
std::fs::read_to_string(file).is_ok_and(|found| found == SKILL)
|
||||
}
|
||||
@@ -167,10 +159,6 @@ fn ours(file: &Path) -> bool {
|
||||
.is_ok_and(|found| found.contains(FRONTMATTER_NAME))
|
||||
}
|
||||
|
||||
fn unwritable(path: &Path, error: &std::io::Error) -> CliError {
|
||||
CliError::usage(format!("cannot write {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -39,17 +39,13 @@ pub fn fetch_listing(
|
||||
CliError::connection(format!("cannot ask {user} for a listing: {e}"))
|
||||
})?;
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(directories) = client.take_browse_result(user) {
|
||||
return Ok(directories);
|
||||
}
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
Err(CliError::timeout(format!(
|
||||
"{user} did not send a file list within {}s",
|
||||
timeout.as_secs()
|
||||
)))
|
||||
super::poll_until(timeout, POLL, || client.take_browse_result(user))
|
||||
.ok_or_else(|| {
|
||||
CliError::timeout(format!(
|
||||
"{user} did not send a file list within {}s",
|
||||
timeout.as_secs()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult {
|
||||
@@ -143,22 +139,18 @@ pub fn room_users(ctx: &Ctx, room: &str, timeout: Duration) -> CliResult {
|
||||
ctx.out
|
||||
.status(&format!("waiting for {room}'s member list…"));
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut members = Vec::new();
|
||||
while Instant::now() < deadline && members.is_empty() {
|
||||
members = session.client.room_members(room);
|
||||
if members.is_empty() {
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
}
|
||||
let listed = super::poll_until(timeout, POLL, || {
|
||||
let members = session.client.room_members(room);
|
||||
(!members.is_empty()).then_some(members)
|
||||
});
|
||||
let _ = session.client.leave_room(room);
|
||||
|
||||
if members.is_empty() {
|
||||
let Some(members) = listed else {
|
||||
return Err(CliError::timeout(format!(
|
||||
"{room} sent no member list within {}s",
|
||||
timeout.as_secs()
|
||||
)));
|
||||
}
|
||||
};
|
||||
for user in members {
|
||||
ctx.out.emit(&RoomMemberRecord {
|
||||
room: room.to_string(),
|
||||
|
||||
@@ -310,13 +310,9 @@ fn collect(
|
||||
})
|
||||
};
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if client.get_search_results_count(query) > 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
super::poll_until(timeout, Duration::from_millis(100), || {
|
||||
(client.get_search_results_count(query) > 0).then_some(())
|
||||
});
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
let _ = worker.join();
|
||||
Ok(client.get_search_results(query))
|
||||
@@ -334,29 +330,26 @@ fn dedupe(
|
||||
download_dir: &str,
|
||||
out: &Out,
|
||||
) -> Vec<Request> {
|
||||
let mut paths: HashMap<String, String> = HashMap::new();
|
||||
let mut targets: HashMap<PathBuf, String> = HashMap::new();
|
||||
let mut targets: HashMap<PathBuf, Request> = HashMap::new();
|
||||
let mut kept = Vec::new();
|
||||
for request in requests {
|
||||
if let Some(user) = paths.get(&request.path) {
|
||||
out.warn(&format!(
|
||||
"skipping {} from {}: same remote path already queued from \
|
||||
{user}",
|
||||
request.path, request.user
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let target = local_path(download_dir, &request.path);
|
||||
if let Some(other) = targets.get(&target) {
|
||||
if let Some(queued) = targets.get(&target) {
|
||||
let collision = if queued.path == request.path {
|
||||
format!("same remote path already queued from {}", queued.user)
|
||||
} else {
|
||||
format!(
|
||||
"would overwrite the file already queued from {}",
|
||||
queued.user
|
||||
)
|
||||
};
|
||||
out.warn(&format!(
|
||||
"skipping {} from {}: would overwrite the file already queued \
|
||||
from {other}",
|
||||
"skipping {} from {}: {collision}",
|
||||
request.path, request.user
|
||||
));
|
||||
continue;
|
||||
}
|
||||
paths.insert(request.path.clone(), request.user.clone());
|
||||
targets.insert(target, request.user.clone());
|
||||
targets.insert(target, request.clone());
|
||||
kept.push(request);
|
||||
}
|
||||
kept
|
||||
@@ -617,9 +610,8 @@ fn read_requests(
|
||||
}
|
||||
|
||||
/// `Ok(None)` for a blank line, `Err` for something unusable.
|
||||
// aislop-ignore-next-line complexity/function-too-long -- scanner bug, not a long function: the `'{'` char literal below reads as an opening brace, so aislop's counter never closes and measures to EOF. It is 34 lines.
|
||||
// aislop-ignore-next-line complexity/function-too-long -- scanner bug, not a long function: the `'{'` char literal below reads as an opening brace, so aislop's counter never closes and measures to EOF. It is 33 lines.
|
||||
fn parse_request(line: &str) -> Result<Option<Request>, String> {
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if line.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -68,22 +68,13 @@ pub fn run(ctx: &Ctx, store: &Store, args: &WishRunArgs) -> CliResult {
|
||||
/// How to narrow and order what a sweep prints — the parts of `wish run`'s flags
|
||||
/// that outlive the command, so `serve --wishlist` can sweep without inventing
|
||||
/// a set of command-line arguments it never received.
|
||||
#[derive(Default)]
|
||||
struct Sweep {
|
||||
filter: transfer::Filter,
|
||||
sort: SortKey,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl Default for Sweep {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filter: transfer::Filter::default(),
|
||||
sort: SortKey::Best,
|
||||
limit: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&WishRunArgs> for Sweep {
|
||||
fn from(args: &WishRunArgs) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -193,16 +193,14 @@ const BROWSE_PATIENCE: Duration = Duration::from_mins(2);
|
||||
/// `take_browse_result` is per-user and destructive, so the daemon has to know
|
||||
/// who to ask about.
|
||||
#[derive(Default)]
|
||||
pub struct PendingBrowses(Mutex<Vec<(String, Instant)>>);
|
||||
pub struct PendingBrowses(Mutex<HashMap<String, Instant>>);
|
||||
|
||||
impl PendingBrowses {
|
||||
pub fn expect(&self, username: &str) {
|
||||
let Ok(mut pending) = self.0.lock() else {
|
||||
return;
|
||||
};
|
||||
// Asking again restarts the clock: it is a fresh request.
|
||||
pending.retain(|(name, _)| name != username);
|
||||
pending.push((username.to_string(), Instant::now()));
|
||||
if let Ok(mut pending) = self.0.lock() {
|
||||
// Asking again restarts the clock: it is a fresh request.
|
||||
pending.insert(username.to_string(), Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Who to ask about this tick, forgetting the ones that have run out of
|
||||
@@ -211,15 +209,15 @@ impl PendingBrowses {
|
||||
let Ok(mut pending) = self.0.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
pending.retain(|(_, asked)| asked.elapsed() < BROWSE_PATIENCE);
|
||||
pending.iter().map(|(name, _)| name.clone()).collect()
|
||||
pending.retain(|_, asked| asked.elapsed() < BROWSE_PATIENCE);
|
||||
pending.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Stop waiting on a peer — because it answered, or because its request
|
||||
/// never reached the wire.
|
||||
pub fn forget(&self, username: &str) {
|
||||
if let Ok(mut pending) = self.0.lock() {
|
||||
pending.retain(|(name, _)| name != username);
|
||||
pending.remove(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,8 +509,10 @@ mod tests {
|
||||
let asked = Instant::now()
|
||||
.checked_sub(BROWSE_PATIENCE + Duration::from_secs(1))
|
||||
.expect("the clock is past the epoch");
|
||||
let pending =
|
||||
PendingBrowses(Mutex::new(vec![("ghost".to_string(), asked)]));
|
||||
let pending = PendingBrowses(Mutex::new(HashMap::from([(
|
||||
"ghost".to_string(),
|
||||
asked,
|
||||
)])));
|
||||
assert!(pending.snapshot().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,48 +332,42 @@ const fn raise_open_file_limit() -> std::io::Result<u64> {
|
||||
/// afterwards is a poor first experience. stdout stays empty either way, so
|
||||
/// `daemon` still composes in a pipeline.
|
||||
fn ready(ctx: &Ctx, bind: Option<&str>, token: &str) {
|
||||
ctx.out.status("");
|
||||
ctx.out.status("ready — leave this running.");
|
||||
ctx.out.status("");
|
||||
ctx.out
|
||||
.status(" On this machine, nothing else to do. Other commands find");
|
||||
ctx.out
|
||||
.status(" it on their own and share this one login:");
|
||||
ctx.out.status("");
|
||||
ctx.out.status(" soulseek-rs search 'aphex twin'");
|
||||
ctx.out
|
||||
.status(" soulseek-rs (the TUI attaches)");
|
||||
ctx.out.status(" soulseek-rs daemon status");
|
||||
ctx.out.status(" soulseek-rs daemon stop");
|
||||
ctx.out.status("");
|
||||
const BANNER: &str = "
|
||||
ready — leave this running.
|
||||
|
||||
On this machine, nothing else to do. Other commands find
|
||||
it on their own and share this one login:
|
||||
|
||||
soulseek-rs search 'aphex twin'
|
||||
soulseek-rs (the TUI attaches)
|
||||
soulseek-rs daemon status
|
||||
soulseek-rs daemon stop
|
||||
";
|
||||
const LOCAL_ONLY: &str =
|
||||
" To drive it from another machine, restart with --bind ADDR;
|
||||
`soulseek-rs daemon token` prints the secret it needs.
|
||||
";
|
||||
|
||||
ctx.out.status(BANNER);
|
||||
|
||||
let Some(address) = bind else {
|
||||
ctx.out.status(
|
||||
" To drive it from another machine, restart with --bind ADDR;",
|
||||
);
|
||||
ctx.out
|
||||
.status(" `soulseek-rs daemon token` prints the secret it needs.");
|
||||
ctx.out.status("");
|
||||
ctx.out.status(LOCAL_ONLY);
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.out.status(&format!(
|
||||
" From another machine, point a client at {address} with"
|
||||
" From another machine, point a client at {address} with
|
||||
this token:
|
||||
|
||||
{token}
|
||||
|
||||
soulseek-rs config set daemon <this-host>:PORT
|
||||
soulseek-rs config set daemon_token <token above>
|
||||
|
||||
That port has no encryption. Keep it off the open internet and \
|
||||
reach it over SSH instead.
|
||||
"
|
||||
));
|
||||
ctx.out.status(" this token:");
|
||||
ctx.out.status("");
|
||||
ctx.out.status(&format!(" {token}"));
|
||||
ctx.out.status("");
|
||||
ctx.out
|
||||
.status(" soulseek-rs config set daemon <this-host>:PORT");
|
||||
ctx.out
|
||||
.status(" soulseek-rs config set daemon_token <token above>");
|
||||
ctx.out.status("");
|
||||
ctx.out.status(
|
||||
" That port has no encryption. Keep it off the open internet and \
|
||||
reach it over SSH instead.",
|
||||
);
|
||||
ctx.out.status("");
|
||||
}
|
||||
|
||||
/// Remove the socket file so the next start does not have to reason about
|
||||
|
||||
@@ -23,11 +23,6 @@ use serde_json::{Value, json};
|
||||
/// `components/schemas`, the same place OpenAPI does.
|
||||
const DEFINITIONS_PATH: &str = "#/components/schemas/";
|
||||
|
||||
/// A method that takes no parameters.
|
||||
fn nothing() -> Value {
|
||||
json!({ "type": "null" })
|
||||
}
|
||||
|
||||
fn schema_of<T: JsonSchema>(generator: &mut SchemaGenerator) -> Value {
|
||||
generator.subschema_for::<T>().to_value()
|
||||
}
|
||||
@@ -60,6 +55,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
||||
schema_of::<UserRef>(generator)
|
||||
}
|
||||
Method::SharesSet => schema_of::<DirectoriesParams>(generator),
|
||||
// Methods that take no parameters.
|
||||
Method::DaemonStatus
|
||||
| Method::DaemonStop
|
||||
| Method::RpcDiscover
|
||||
@@ -72,7 +68,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
||||
| Method::MessageHistory
|
||||
| Method::SearchList
|
||||
| Method::SharesStatusOf
|
||||
| Method::SharesReindex => nothing(),
|
||||
| Method::SharesReindex => json!({ "type": "null" }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ impl Daemon {
|
||||
///
|
||||
/// The match is exhaustive over [`Method`], so a new method cannot be added
|
||||
/// to the protocol without being implemented here.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn call(
|
||||
&self,
|
||||
method: Method,
|
||||
|
||||
@@ -37,7 +37,6 @@ pub fn resolve_shared_directory(
|
||||
|
||||
/// Validate several configured share paths; invalid ones are skipped with a
|
||||
/// warning instead of aborting (the client just shares less).
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
#[must_use]
|
||||
pub fn resolve_shared_directories(dirs: &[String]) -> Vec<String> {
|
||||
let mut resolved = Vec::new();
|
||||
|
||||
@@ -24,7 +24,6 @@ pub struct SearchEntry {
|
||||
/// finishes; for the rest the session is the authority.
|
||||
pub owned: bool,
|
||||
pub start_time: Instant,
|
||||
#[allow(dead_code)]
|
||||
pub cancel_flag: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
@@ -249,19 +248,6 @@ impl AppState {
|
||||
let peer = peers[next].to_string();
|
||||
self.chat_peer = Some(peer);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[must_use]
|
||||
pub fn get_selected_search(&self) -> Option<&SearchEntry> {
|
||||
self.selected_search_index
|
||||
.and_then(|idx| self.searches.get(idx))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_selected_search_mut(&mut self) -> Option<&mut SearchEntry> {
|
||||
self.selected_search_index
|
||||
.and_then(|idx| self.searches.get_mut(idx))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
|
||||
@@ -436,12 +436,11 @@ pub struct ConfigRecord {
|
||||
|
||||
impl Record for ConfigRecord {
|
||||
fn text(&self) -> String {
|
||||
match &self.value {
|
||||
Some(value) => {
|
||||
format!("{}\t{}", sanitize(&self.key), sanitize(value))
|
||||
}
|
||||
None => format!("{}\t", sanitize(&self.key)),
|
||||
}
|
||||
format!(
|
||||
"{}\t{}",
|
||||
sanitize(&self.key),
|
||||
sanitize(self.value.as_deref().unwrap_or_default())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
//! Versioned JSON state files (downloads, search queries, open rooms,
|
||||
//! private-message history).
|
||||
//!
|
||||
//! Each file is an envelope `{ "version": N, "data": ... }`. On load the
|
||||
//! data passes through the migration chain from its stored version up to
|
||||
//! current. State is disposable: a missing, corrupt, or newer-than-known
|
||||
//! file loads as empty rather than failing startup. Writes are atomic
|
||||
//! (tmp file + rename) so a crash never leaves a torn file.
|
||||
//! Each file is an envelope `{ "version": N, "data": ... }`. State is
|
||||
//! disposable: a missing, corrupt, or newer-than-known file loads as empty
|
||||
//! rather than failing startup. Writes are atomic (tmp file + rename) so a
|
||||
//! crash never leaves a torn file.
|
||||
|
||||
use color_eyre::Result;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A migration takes the `data` value at version `i` and returns it at
|
||||
/// version `i + 1`.
|
||||
type Migration = fn(Value) -> Value;
|
||||
/// The shape every file is written in today. Bump it — and convert the older
|
||||
/// shapes in `load` — when one of them changes.
|
||||
const VERSION: u32 = 0;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
|
||||
pub struct PersistedDownload {
|
||||
@@ -45,86 +44,59 @@ impl StateStore {
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn load_downloads(&self) -> Vec<PersistedDownload> {
|
||||
load(&self.dir.join("downloads.json"), DOWNLOADS_MIGRATIONS)
|
||||
load(&self.dir.join("downloads.json"))
|
||||
}
|
||||
|
||||
pub fn save_downloads(
|
||||
&self,
|
||||
downloads: &[PersistedDownload],
|
||||
) -> Result<()> {
|
||||
save(
|
||||
&self.dir.join("downloads.json"),
|
||||
DOWNLOADS_MIGRATIONS.len() as u32,
|
||||
&downloads,
|
||||
)
|
||||
save(&self.dir.join("downloads.json"), VERSION, &downloads)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn load_search_queries(&self) -> Vec<String> {
|
||||
load(&self.dir.join("searches.json"), SEARCHES_MIGRATIONS)
|
||||
load(&self.dir.join("searches.json"))
|
||||
}
|
||||
|
||||
pub fn save_search_queries(&self, queries: &[String]) -> Result<()> {
|
||||
save(
|
||||
&self.dir.join("searches.json"),
|
||||
SEARCHES_MIGRATIONS.len() as u32,
|
||||
&queries,
|
||||
)
|
||||
save(&self.dir.join("searches.json"), VERSION, &queries)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn load_rooms(&self) -> Vec<String> {
|
||||
load(&self.dir.join("rooms.json"), ROOMS_MIGRATIONS)
|
||||
load(&self.dir.join("rooms.json"))
|
||||
}
|
||||
|
||||
pub fn save_rooms(&self, rooms: &[String]) -> Result<()> {
|
||||
save(
|
||||
&self.dir.join("rooms.json"),
|
||||
ROOMS_MIGRATIONS.len() as u32,
|
||||
&rooms,
|
||||
)
|
||||
save(&self.dir.join("rooms.json"), VERSION, &rooms)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn load_messages(&self) -> Vec<PersistedMessage> {
|
||||
load(&self.dir.join("messages.json"), MESSAGES_MIGRATIONS)
|
||||
load(&self.dir.join("messages.json"))
|
||||
}
|
||||
|
||||
pub fn save_messages(&self, messages: &[PersistedMessage]) -> Result<()> {
|
||||
save(
|
||||
&self.dir.join("messages.json"),
|
||||
MESSAGES_MIGRATIONS.len() as u32,
|
||||
&messages,
|
||||
)
|
||||
save(&self.dir.join("messages.json"), VERSION, &messages)
|
||||
}
|
||||
|
||||
/// The unread private-message count, so the badge survives a restart.
|
||||
#[must_use]
|
||||
pub fn load_unread(&self) -> usize {
|
||||
load(&self.dir.join("unread.json"), UNREAD_MIGRATIONS)
|
||||
load(&self.dir.join("unread.json"))
|
||||
}
|
||||
|
||||
pub fn save_unread(&self, unread: usize) -> Result<()> {
|
||||
save(
|
||||
&self.dir.join("unread.json"),
|
||||
UNREAD_MIGRATIONS.len() as u32,
|
||||
&unread,
|
||||
)
|
||||
save(&self.dir.join("unread.json"), VERSION, &unread)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-file migration chains. `data` at version `i` is upgraded by
|
||||
/// `MIGRATIONS[i]`; the current version is the chain length. All formats
|
||||
/// are at version 0 today — add a fn here when the schema changes.
|
||||
const DOWNLOADS_MIGRATIONS: &[Migration] = &[];
|
||||
const SEARCHES_MIGRATIONS: &[Migration] = &[];
|
||||
const ROOMS_MIGRATIONS: &[Migration] = &[];
|
||||
const MESSAGES_MIGRATIONS: &[Migration] = &[];
|
||||
const UNREAD_MIGRATIONS: &[Migration] = &[];
|
||||
|
||||
/// Load `data` from an envelope file, migrating old versions forward.
|
||||
/// Missing, corrupt, or newer-than-known files all yield `T::default()`.
|
||||
fn load<T: DeserializeOwned + Default>(
|
||||
path: &Path,
|
||||
migrations: &[Migration],
|
||||
) -> T {
|
||||
/// Load `data` from an envelope file. Missing, corrupt, or newer-than-known
|
||||
/// files all yield `T::default()`.
|
||||
fn load<T: DeserializeOwned + Default>(path: &Path) -> T {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return T::default();
|
||||
};
|
||||
@@ -135,15 +107,12 @@ fn load<T: DeserializeOwned + Default>(
|
||||
let version = envelope
|
||||
.get("version")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(u64::MAX) as usize;
|
||||
if version > migrations.len() {
|
||||
.unwrap_or(u64::MAX);
|
||||
if version > u64::from(VERSION) {
|
||||
set_aside(path, "from a newer version");
|
||||
return T::default();
|
||||
}
|
||||
let mut data = envelope.get("data").cloned().unwrap_or(Value::Null);
|
||||
for migration in &migrations[version..] {
|
||||
data = migration(data);
|
||||
}
|
||||
let data = envelope.get("data").cloned().unwrap_or(Value::Null);
|
||||
serde_json::from_value(data).unwrap_or_else(|_| {
|
||||
set_aside(path, "unreadable");
|
||||
T::default()
|
||||
@@ -297,34 +266,6 @@ mod tests {
|
||||
assert!(path.with_extension("json.bak").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrations_upgrade_old_data() {
|
||||
// Machinery test with a synthetic chain: v0 stored plain strings,
|
||||
// v1 wraps each in an object {"name": ...}.
|
||||
#[derive(Debug, PartialEq, Default, Serialize, serde::Deserialize)]
|
||||
struct Named {
|
||||
name: String,
|
||||
}
|
||||
let chain: &[Migration] = &[|data| {
|
||||
Value::Array(
|
||||
data.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|s| serde_json::json!({ "name": s }))
|
||||
.collect(),
|
||||
)
|
||||
}];
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("things.json");
|
||||
std::fs::write(&path, r#"{"version": 0, "data": ["a", "b"]}"#).unwrap();
|
||||
let loaded: Vec<Named> = load(&path, chain);
|
||||
assert_eq!(
|
||||
loaded,
|
||||
vec![Named { name: "a".into() }, Named { name: "b".into() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_is_atomic_no_tmp_file_left_behind() {
|
||||
let (tmp, store) = store();
|
||||
|
||||
@@ -52,8 +52,6 @@ impl From<io::Error> for NatPmpError {
|
||||
/// a shorter lifetime than requested, so callers must use these values.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MapResponse {
|
||||
/// Seconds since the gateway's port table was initialised (reboot detector).
|
||||
pub epoch: u32,
|
||||
pub internal_port: u16,
|
||||
pub external_port: u16,
|
||||
pub lifetime: u32,
|
||||
@@ -87,12 +85,6 @@ pub fn map_tcp(
|
||||
return Err(NatPmpError::ShortResponse);
|
||||
}
|
||||
Ok(MapResponse {
|
||||
epoch: u32::from_be_bytes([
|
||||
response[4],
|
||||
response[5],
|
||||
response[6],
|
||||
response[7],
|
||||
]),
|
||||
internal_port: u16::from_be_bytes([response[8], response[9]]),
|
||||
external_port: u16::from_be_bytes([response[10], response[11]]),
|
||||
lifetime: u32::from_be_bytes([
|
||||
|
||||
+15
-21
@@ -369,6 +369,19 @@ pub fn init_logging(cli: &Cli) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the screen, turn on mouse reporting and take over the terminal.
|
||||
fn enter_terminal() -> ratatui::DefaultTerminal {
|
||||
use ratatui::crossterm::{
|
||||
event::EnableMouseCapture,
|
||||
execute,
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
|
||||
let _ =
|
||||
execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture);
|
||||
ratatui::init()
|
||||
}
|
||||
|
||||
/// Run the interactive TUI (the default no-subcommand path): bring the
|
||||
/// terminal up first, run the login/registration screen (skipped past when
|
||||
/// stored credentials work), persist whatever logged in, then enter the
|
||||
@@ -379,12 +392,6 @@ fn run_default_tui(
|
||||
config_path: Option<PathBuf>,
|
||||
file_config: &crate::persist::config::FileConfig,
|
||||
) -> CliResult {
|
||||
use ratatui::crossterm::{
|
||||
event::EnableMouseCapture,
|
||||
execute,
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
|
||||
let out = Out::new(false, false);
|
||||
let (server_host, server_port) =
|
||||
parse_server_address(&resolved.server).map_err(CliError::usage)?;
|
||||
@@ -419,10 +426,7 @@ fn run_default_tui(
|
||||
version: ClientVersion::REFERENCE_CLIENT,
|
||||
};
|
||||
|
||||
// Clear screen and enable mouse capture before initializing TUI
|
||||
let _ =
|
||||
execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture);
|
||||
let mut terminal = ratatui::init();
|
||||
let mut terminal = enter_terminal();
|
||||
|
||||
let outcome = crate::ui::login::run_login_flow(
|
||||
&mut terminal,
|
||||
@@ -468,7 +472,6 @@ fn run_default_tui(
|
||||
terminal,
|
||||
Arc::new(outcome.client),
|
||||
resolved.download_dir.clone(),
|
||||
resolved.max_concurrent_downloads,
|
||||
Duration::from_secs(resolved.search_timeout),
|
||||
store,
|
||||
config_path,
|
||||
@@ -486,12 +489,6 @@ fn run_attached_tui(
|
||||
endpoint: &crate::remote::Endpoint,
|
||||
config_path: Option<PathBuf>,
|
||||
) -> CliResult {
|
||||
use ratatui::crossterm::{
|
||||
event::EnableMouseCapture,
|
||||
execute,
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
|
||||
// The resolved token, not the flag alone: `daemon_token` in config.toml
|
||||
// is the documented remote setup, and the flag/env are layered into it.
|
||||
let token = resolved
|
||||
@@ -521,9 +518,7 @@ fn run_attached_tui(
|
||||
})?;
|
||||
|
||||
soulseek_rs::utils::logger::enable_buffering();
|
||||
let _ =
|
||||
execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture);
|
||||
let terminal = ratatui::init();
|
||||
let terminal = enter_terminal();
|
||||
|
||||
// No StateStore: the daemon owns what survives a restart, so a second
|
||||
// writer here would fight it for the same files.
|
||||
@@ -531,7 +526,6 @@ fn run_attached_tui(
|
||||
terminal,
|
||||
Arc::new(session),
|
||||
download_dir,
|
||||
resolved.max_concurrent_downloads,
|
||||
Duration::from_secs(resolved.search_timeout),
|
||||
None,
|
||||
config_path,
|
||||
|
||||
@@ -3,6 +3,8 @@ use crate::models::{CommandBarMode, FocusedPane};
|
||||
use ratatui::crossterm::event::{
|
||||
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
||||
};
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::widgets::TableState;
|
||||
|
||||
impl MainTui {
|
||||
pub(super) fn handle_key_event(&mut self, key: KeyEvent) {
|
||||
@@ -189,10 +191,10 @@ impl MainTui {
|
||||
KeyCode::Backspace => {
|
||||
let cursor_position = self.state.command_bar_cursor_position;
|
||||
if cursor_position > 0 {
|
||||
let previous_position = previous_char_boundary(
|
||||
&self.state.command_bar_input,
|
||||
cursor_position,
|
||||
);
|
||||
let previous_position = self
|
||||
.state
|
||||
.command_bar_input
|
||||
.floor_char_boundary(cursor_position.saturating_sub(1));
|
||||
self.state
|
||||
.command_bar_input
|
||||
.drain(previous_position..cursor_position);
|
||||
@@ -202,26 +204,26 @@ impl MainTui {
|
||||
KeyCode::Delete => {
|
||||
let cursor_position = self.state.command_bar_cursor_position;
|
||||
if cursor_position < self.state.command_bar_input.len() {
|
||||
let next_position = next_char_boundary(
|
||||
&self.state.command_bar_input,
|
||||
cursor_position,
|
||||
);
|
||||
let next_position = self
|
||||
.state
|
||||
.command_bar_input
|
||||
.ceil_char_boundary(cursor_position + 1);
|
||||
self.state
|
||||
.command_bar_input
|
||||
.drain(cursor_position..next_position);
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.state.command_bar_cursor_position = previous_char_boundary(
|
||||
&self.state.command_bar_input,
|
||||
self.state.command_bar_cursor_position,
|
||||
);
|
||||
let previous =
|
||||
self.state.command_bar_cursor_position.saturating_sub(1);
|
||||
self.state.command_bar_cursor_position =
|
||||
self.state.command_bar_input.floor_char_boundary(previous);
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.state.command_bar_cursor_position = next_char_boundary(
|
||||
&self.state.command_bar_input,
|
||||
self.state.command_bar_cursor_position,
|
||||
);
|
||||
let input = &self.state.command_bar_input;
|
||||
let next = self.state.command_bar_cursor_position + 1;
|
||||
self.state.command_bar_cursor_position =
|
||||
input.ceil_char_boundary(next.min(input.len()));
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.state.command_bar_cursor_position = 0;
|
||||
@@ -288,26 +290,13 @@ impl MainTui {
|
||||
}
|
||||
|
||||
fn handle_searches_input(&mut self, key: KeyEvent) {
|
||||
let rows = self.state.searches.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k')
|
||||
if !self.state.searches.is_empty() =>
|
||||
{
|
||||
let current =
|
||||
self.state.searches_table_state.selected().unwrap_or(0);
|
||||
let new = if current == 0 {
|
||||
self.state.searches.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
self.state.searches_table_state.select(Some(new));
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
cycle(&mut self.state.searches_table_state, rows, false);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j')
|
||||
if !self.state.searches.is_empty() =>
|
||||
{
|
||||
let current =
|
||||
self.state.searches_table_state.selected().unwrap_or(0);
|
||||
let new = (current + 1) % self.state.searches.len();
|
||||
self.state.searches_table_state.select(Some(new));
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
cycle(&mut self.state.searches_table_state, rows, true);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(selected) =
|
||||
@@ -348,21 +337,11 @@ impl MainTui {
|
||||
};
|
||||
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') if items_count > 0 => {
|
||||
let current =
|
||||
self.state.results_table_state.selected().unwrap_or(0);
|
||||
let new = if current == 0 {
|
||||
items_count - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
self.state.results_table_state.select(Some(new));
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
cycle(&mut self.state.results_table_state, items_count, false);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if items_count > 0 => {
|
||||
let current =
|
||||
self.state.results_table_state.selected().unwrap_or(0);
|
||||
let new = (current + 1) % items_count;
|
||||
self.state.results_table_state.select(Some(new));
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
cycle(&mut self.state.results_table_state, items_count, true);
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if let Some(current) = self.state.results_table_state.selected()
|
||||
@@ -416,17 +395,11 @@ impl MainTui {
|
||||
// The pane lists downloads first, then uploads; navigation spans both.
|
||||
let rows = self.state.downloads.len() + self.state.uploads.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') if rows > 0 => {
|
||||
let current =
|
||||
self.state.downloads_table_state.selected().unwrap_or(0);
|
||||
let new = if current == 0 { rows - 1 } else { current - 1 };
|
||||
self.state.downloads_table_state.select(Some(new));
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
cycle(&mut self.state.downloads_table_state, rows, false);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if rows > 0 => {
|
||||
let current =
|
||||
self.state.downloads_table_state.selected().unwrap_or(0);
|
||||
let new = (current + 1) % rows;
|
||||
self.state.downloads_table_state.select(Some(new));
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
cycle(&mut self.state.downloads_table_state, rows, true);
|
||||
}
|
||||
KeyCode::Char('x') => {
|
||||
self.cancel_selected_upload();
|
||||
@@ -448,63 +421,35 @@ impl MainTui {
|
||||
}
|
||||
|
||||
pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) {
|
||||
if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
|
||||
let (col, row) = (mouse.column, mouse.row);
|
||||
if mouse.kind != MouseEventKind::Down(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if click is within searches pane
|
||||
if let Some(area) = self.state.searches_pane_area
|
||||
&& col >= area.x
|
||||
&& col < area.x + area.width
|
||||
&& row >= area.y
|
||||
&& row < area.y + area.height
|
||||
{
|
||||
self.state.focused_pane = FocusedPane::Searches;
|
||||
let clicked = Position::new(mouse.column, mouse.row);
|
||||
let panes = [
|
||||
(self.state.searches_pane_area, FocusedPane::Searches),
|
||||
(self.state.results_pane_area, FocusedPane::Results),
|
||||
(self.state.downloads_pane_area, FocusedPane::Downloads),
|
||||
];
|
||||
for (area, pane) in panes {
|
||||
if area.is_some_and(|area| area.contains(clicked)) {
|
||||
self.state.focused_pane = pane;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if click is within results pane
|
||||
if let Some(area) = self.state.results_pane_area
|
||||
&& col >= area.x
|
||||
&& col < area.x + area.width
|
||||
&& row >= area.y
|
||||
&& row < area.y + area.height
|
||||
{
|
||||
self.state.focused_pane = FocusedPane::Results;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if click is within downloads pane
|
||||
if let Some(area) = self.state.downloads_pane_area
|
||||
&& col >= area.x
|
||||
&& col < area.x + area.width
|
||||
&& row >= area.y
|
||||
&& row < area.y + area.height
|
||||
{
|
||||
self.state.focused_pane = FocusedPane::Downloads;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_char_boundary(input: &str, cursor_position: usize) -> usize {
|
||||
let cursor_position = input.floor_char_boundary(cursor_position);
|
||||
|
||||
input[..cursor_position]
|
||||
.char_indices()
|
||||
.last()
|
||||
.map_or(0, |(index, _)| index)
|
||||
}
|
||||
|
||||
fn next_char_boundary(input: &str, cursor_position: usize) -> usize {
|
||||
let cursor_position = input.floor_char_boundary(cursor_position);
|
||||
|
||||
if cursor_position >= input.len() {
|
||||
return input.len();
|
||||
/// Move a table's selection one row, wrapping at either end.
|
||||
fn cycle(table: &mut TableState, len: usize, forward: bool) {
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
cursor_position
|
||||
+ input[cursor_position..]
|
||||
.chars()
|
||||
.next()
|
||||
.map_or(0, char::len_utf8)
|
||||
let current = table.selected().unwrap_or(0);
|
||||
let next = if forward {
|
||||
(current + 1) % len
|
||||
} else {
|
||||
(current + len - 1) % len
|
||||
};
|
||||
table.select(Some(next));
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ pub struct MainTui {
|
||||
client: Arc<dyn SessionApi>,
|
||||
state: AppState,
|
||||
download_dir: String,
|
||||
#[allow(dead_code)]
|
||||
max_concurrent_downloads: usize,
|
||||
search_timeout: Duration,
|
||||
spinner_state: usize,
|
||||
store: Option<StateStore>,
|
||||
@@ -39,7 +37,6 @@ impl MainTui {
|
||||
pub fn new(
|
||||
client: Arc<dyn SessionApi>,
|
||||
download_dir: String,
|
||||
max_concurrent_downloads: usize,
|
||||
search_timeout: Duration,
|
||||
store: Option<StateStore>,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
@@ -48,7 +45,6 @@ impl MainTui {
|
||||
client,
|
||||
state: AppState::new(),
|
||||
download_dir,
|
||||
max_concurrent_downloads,
|
||||
search_timeout,
|
||||
spinner_state: 0,
|
||||
store,
|
||||
@@ -256,19 +252,12 @@ pub fn launch_main_tui(
|
||||
terminal: DefaultTerminal,
|
||||
client: Arc<dyn SessionApi>,
|
||||
download_dir: String,
|
||||
max_concurrent_downloads: usize,
|
||||
search_timeout: Duration,
|
||||
store: Option<StateStore>,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
) -> Result<()> {
|
||||
let tui = MainTui::new(
|
||||
client,
|
||||
download_dir,
|
||||
max_concurrent_downloads,
|
||||
search_timeout,
|
||||
store,
|
||||
config_path,
|
||||
);
|
||||
let tui =
|
||||
MainTui::new(client, download_dir, search_timeout, store, config_path);
|
||||
tui.run(terminal)
|
||||
}
|
||||
|
||||
@@ -526,7 +515,6 @@ mod tests {
|
||||
MainTui::new(
|
||||
Arc::new(session),
|
||||
"/tmp".to_string(),
|
||||
1,
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
None,
|
||||
@@ -746,7 +734,6 @@ mod tests {
|
||||
let mut tui = MainTui::new(
|
||||
session.clone(),
|
||||
"/daemon/old".to_string(),
|
||||
1,
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
Some(config_path.clone()),
|
||||
@@ -803,7 +790,6 @@ mod tests {
|
||||
let mut tui = MainTui::new(
|
||||
session.clone(),
|
||||
"/tmp".to_string(),
|
||||
1,
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
Some(config_path),
|
||||
|
||||
@@ -17,18 +17,6 @@ use ratatui::{
|
||||
widgets::Paragraph,
|
||||
};
|
||||
|
||||
const COMMAND_BAR_PREFIX: &str = "search: ";
|
||||
const MESSAGE_BAR_PREFIX: &str = "message (to: recipient text): ";
|
||||
const BROWSE_BAR_PREFIX: &str = "browse user: ";
|
||||
|
||||
const fn command_bar_prefix(mode: CommandBarMode) -> &'static str {
|
||||
match mode {
|
||||
CommandBarMode::Search => COMMAND_BAR_PREFIX,
|
||||
CommandBarMode::Message => MESSAGE_BAR_PREFIX,
|
||||
CommandBarMode::Browse => BROWSE_BAR_PREFIX,
|
||||
}
|
||||
}
|
||||
|
||||
impl MainTui {
|
||||
pub(super) fn render(&mut self, frame: &mut Frame) {
|
||||
let mut constraints = vec![
|
||||
@@ -458,7 +446,11 @@ impl MainTui {
|
||||
}
|
||||
|
||||
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
|
||||
let prefix = command_bar_prefix(self.state.command_bar_mode);
|
||||
let prefix = match self.state.command_bar_mode {
|
||||
CommandBarMode::Search => "search: ",
|
||||
CommandBarMode::Message => "message (to: recipient text): ",
|
||||
CommandBarMode::Browse => "browse user: ",
|
||||
};
|
||||
let block = pane_block(true);
|
||||
// Derive from the block's inner rect so borders and padding stay in
|
||||
// one place; the cursor follows whatever the pane reserves.
|
||||
|
||||
@@ -102,17 +102,13 @@ impl MainTui {
|
||||
self.state.results_is_filtering = false;
|
||||
}
|
||||
|
||||
fn recompute_results_filter(&mut self) {
|
||||
pub(super) fn apply_filter(&mut self) {
|
||||
let (items, indices) = filter_results(
|
||||
&self.state.results_items,
|
||||
&self.state.results_filter_query,
|
||||
);
|
||||
self.state.results_filtered_items = items;
|
||||
self.state.results_filtered_indices = indices;
|
||||
}
|
||||
|
||||
pub(super) fn apply_filter(&mut self) {
|
||||
self.recompute_results_filter();
|
||||
if !self.state.results_filtered_items.is_empty() {
|
||||
self.state.results_table_state.select(Some(0));
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::models::{AppState, MessageDirection};
|
||||
use crate::ui::{
|
||||
accent_style, border_style, border_type, dimmed_style, highlight_style,
|
||||
info_style, primary_style,
|
||||
accent_style, border_style, dimmed_style, highlight_style, info_style,
|
||||
primary_style,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
};
|
||||
|
||||
/// Render the private-message chat box: title is the conversation partner,
|
||||
@@ -28,7 +28,7 @@ pub fn render_chat_pane(
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style(true))
|
||||
.border_type(border_type(true))
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(title);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
@@ -229,7 +229,12 @@ fn build_info_lines(
|
||||
speed_bytes_per_sec,
|
||||
} => {
|
||||
lines.push(Line::from(""));
|
||||
push_progress_lines(&mut lines, *bytes_downloaded, *total_bytes);
|
||||
push_transfer_progress(
|
||||
&mut lines,
|
||||
"Downloaded",
|
||||
*bytes_downloaded,
|
||||
*total_bytes,
|
||||
);
|
||||
lines.push(label_value(
|
||||
"Speed",
|
||||
&format_speed(*speed_bytes_per_sec),
|
||||
@@ -246,7 +251,12 @@ fn build_info_lines(
|
||||
total_bytes,
|
||||
} => {
|
||||
lines.push(Line::from(""));
|
||||
push_progress_lines(&mut lines, *bytes_downloaded, *total_bytes);
|
||||
push_transfer_progress(
|
||||
&mut lines,
|
||||
"Downloaded",
|
||||
*bytes_downloaded,
|
||||
*total_bytes,
|
||||
);
|
||||
}
|
||||
DownloadStatus::Failed(reason) => {
|
||||
if let Some(reason) = reason {
|
||||
@@ -268,10 +278,7 @@ fn build_info_lines(
|
||||
}
|
||||
|
||||
fn label_value(label: &str, value: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{label:<LABEL_WIDTH$}"), dimmed_style()),
|
||||
Span::styled(value.to_string(), primary_style()),
|
||||
])
|
||||
label_value_styled(label, value.to_string(), primary_style())
|
||||
}
|
||||
|
||||
fn label_value_styled(
|
||||
@@ -285,14 +292,6 @@ fn label_value_styled(
|
||||
])
|
||||
}
|
||||
|
||||
fn push_progress_lines(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
bytes_downloaded: u64,
|
||||
total_bytes: u64,
|
||||
) {
|
||||
push_transfer_progress(lines, "Downloaded", bytes_downloaded, total_bytes);
|
||||
}
|
||||
|
||||
/// A progress bar plus a `<label> <done> / <total>` line, shared by the
|
||||
/// download ("Downloaded") and upload ("Sent") views.
|
||||
fn push_transfer_progress(
|
||||
|
||||
@@ -56,7 +56,16 @@ pub fn render_downloads_pane(
|
||||
|
||||
let progress_text = match &download.status {
|
||||
DownloadStatus::Queued => "Queued".to_string(),
|
||||
DownloadStatus::InProgress { .. } => {
|
||||
DownloadStatus::InProgress { .. }
|
||||
| DownloadStatus::Paused { .. } => {
|
||||
let prefix = if matches!(
|
||||
download.status,
|
||||
DownloadStatus::Paused { .. }
|
||||
) {
|
||||
"Paused "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let percent = if download.size > 0 {
|
||||
(download.bytes_downloaded() as f64
|
||||
/ download.size as f64
|
||||
@@ -65,22 +74,7 @@ pub fn render_downloads_pane(
|
||||
0
|
||||
};
|
||||
format!(
|
||||
"{}/{} ({}%)",
|
||||
format_bytes(download.bytes_downloaded()),
|
||||
format_bytes(download.size),
|
||||
percent
|
||||
)
|
||||
}
|
||||
DownloadStatus::Paused { .. } => {
|
||||
let percent = if download.size > 0 {
|
||||
(download.bytes_downloaded() as f64
|
||||
/ download.size as f64
|
||||
* 100.0) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
format!(
|
||||
"Paused {}/{} ({}%)",
|
||||
"{prefix}{}/{} ({}%)",
|
||||
format_bytes(download.bytes_downloaded()),
|
||||
format_bytes(download.size),
|
||||
percent
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Reusable styles and colors for consistent UI appearance
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -103,14 +102,6 @@ pub fn unfocused_border_style() -> Style {
|
||||
Style::default().fg(DUST)
|
||||
}
|
||||
|
||||
pub const fn focused_border_type() -> BorderType {
|
||||
BorderType::Rounded
|
||||
}
|
||||
|
||||
pub const fn unfocused_border_type() -> BorderType {
|
||||
BorderType::Rounded
|
||||
}
|
||||
|
||||
pub fn border_style(focused: bool) -> Style {
|
||||
if focused {
|
||||
focused_border_style()
|
||||
@@ -119,14 +110,6 @@ pub fn border_style(focused: bool) -> Style {
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn border_type(focused: bool) -> BorderType {
|
||||
if focused {
|
||||
focused_border_type()
|
||||
} else {
|
||||
unfocused_border_type()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title_style(focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default().fg(PHOSPHOR).add_modifier(Modifier::BOLD)
|
||||
@@ -159,7 +142,7 @@ pub fn pane_block(focused: bool) -> Block<'static> {
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style(focused))
|
||||
.border_type(border_type(focused))
|
||||
.border_type(BorderType::Rounded)
|
||||
.padding(PANE_PADDING)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user