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:
Michel de Graaf
2026-07-28 12:52:18 +02:00
parent cd56fb3b39
commit f1b667f7f9
62 changed files with 1367 additions and 2319 deletions
+4
View File
@@ -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<()>;
}
+2 -1
View File
@@ -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,
+2 -21
View File
@@ -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::*;
+47 -21
View File
@@ -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 {
+7 -23
View File
@@ -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,
+2 -14
View File
@@ -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::*;
+13 -21
View File
@@ -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(),
+16 -24
View File
@@ -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);
}
+1 -10
View File
@@ -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 {
+12 -12
View File
@@ -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());
}
}
+29 -35
View File
@@ -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
+2 -6
View File
@@ -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" }),
}
}
-1
View File
@@ -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,
-1
View File
@@ -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();
-14
View File
@@ -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 {
+5 -6
View File
@@ -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())
)
}
}
+28 -87
View File
@@ -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();
-8
View File
@@ -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
View File
@@ -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,
+54 -109
View File
@@ -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));
}
+2 -16
View File
@@ -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),
+5 -13
View File
@@ -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.
+1 -5
View File
@@ -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));
}
+4 -4
View File
@@ -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);
+13 -14
View File
@@ -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(
+11 -17
View File
@@ -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 -18
View File
@@ -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)
}