feat: browse your own shares as a peer receives them
The window could show any peer's share tree but never this account's, so "am I actually sharing that?" had no answer short of asking a friend to browse you. The tree is already there; what was missing was feeding it from our own index. B opens it — the same collapsible tree, filter, and navigation the peer browse offers, built from `Shares::directories()`, which is the listing GetShareFileList answers with. So what it shows is what the network sees, not what the config meant to share. r re-scans the disk rather than re-asking a peer, o steps across to the folders that feed it, and nothing here queues a download from ourselves. Attached to a daemon the index lives on the daemon's host, so it comes back over a new `shares.files` method rather than being scanned locally; `shares files [--filter]` prints the same listing headless, through the `emit_listing` that `browse` now shares with it — the two are only comparable while they are built the same way. Two allocation fixes came out of the review, both on paths this reaches: the daemon hands its listing to the DTO instead of deep-copying one it already owns, and `Shares::directories()` allocates its map key once per folder rather than once per file, which the peer-facing serve path has been paying for all along.
This commit is contained in:
@@ -1474,6 +1474,17 @@ impl Client {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The share index as a peer receives it: the same directory listing
|
||||||
|
/// `GetShareFileList` answers with, so what this returns is what the
|
||||||
|
/// network sees.
|
||||||
|
#[must_use]
|
||||||
|
pub fn shared_listing(&self) -> Vec<crate::message::peer::SharedDirectory> {
|
||||||
|
self.context
|
||||||
|
.read_safe()
|
||||||
|
.map(|ctx| ctx.shares.directories())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// `(folders, files)` currently shared with peers.
|
/// `(folders, files)` currently shared with peers.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn shared_counts(&self) -> (u32, u32) {
|
pub fn shared_counts(&self) -> (u32, u32) {
|
||||||
|
|||||||
@@ -146,14 +146,18 @@ impl Shares {
|
|||||||
.virtual_path
|
.virtual_path
|
||||||
.rsplit_once('\\')
|
.rsplit_once('\\')
|
||||||
.unwrap_or(("", file.virtual_path.as_str()));
|
.unwrap_or(("", file.virtual_path.as_str()));
|
||||||
by_dir
|
let entry = SharedFileEntry {
|
||||||
.entry(dir.to_string())
|
name: base.to_string(),
|
||||||
.or_default()
|
size: file.size,
|
||||||
.push(SharedFileEntry {
|
attributes: file.attributes.clone(),
|
||||||
name: base.to_string(),
|
};
|
||||||
size: file.size,
|
// Looked up before inserting: `entry()` would allocate the key
|
||||||
attributes: file.attributes.clone(),
|
// for every file, and a folder holds many.
|
||||||
});
|
if let Some(files) = by_dir.get_mut(dir) {
|
||||||
|
files.push(entry);
|
||||||
|
} else {
|
||||||
|
by_dir.insert(dir.to_string(), vec![entry]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
by_dir
|
by_dir
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -1243,6 +1243,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SharedListing": {
|
||||||
|
"description": "This session's own share index, as a peer receives it — what\n`shares.files` answers with.",
|
||||||
|
"properties": {
|
||||||
|
"directories": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SharedDirectoryDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"directories"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SharesStatus": {
|
"SharesStatus": {
|
||||||
"description": "What the share index holds, from `shares.status` / `shares.reindex`.",
|
"description": "What the share index holds, from `shares.status` / `shares.reindex`.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2361,6 +2376,18 @@
|
|||||||
},
|
},
|
||||||
"summary": "What the share index currently holds."
|
"summary": "What the share index currently holds."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "shares.files",
|
||||||
|
"paramStructure": "by-name",
|
||||||
|
"params": [],
|
||||||
|
"result": {
|
||||||
|
"name": "result",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SharedListing"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Every indexed file, in the listing a browsing peer receives."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shares.set",
|
"name": "shares.set",
|
||||||
"paramStructure": "by-name",
|
"paramStructure": "by-name",
|
||||||
|
|||||||
@@ -216,6 +216,11 @@ config file; they print nothing. With a daemon running they also update it in
|
|||||||
place, so the folder is on the network at once rather than after a restart.
|
place, so the folder is on the network at once rather than after a restart.
|
||||||
**`shares status`** and **`shares reindex`** — `folders`, `files`,
|
**`shares status`** and **`shares reindex`** — `folders`, `files`,
|
||||||
`directories`, counted by logging in and scanning.
|
`directories`, counted by logging in and scanning.
|
||||||
|
**`shares files [--filter <text>]`** — the index file by file, in the same
|
||||||
|
records `browse` emits for a peer (`user`, `directory`, `path`, `size`,
|
||||||
|
`bitrate`, `duration`), so what the network sees of this account can be diffed
|
||||||
|
against what it sees of anyone else. Use it to check that a folder is really
|
||||||
|
being served before blaming the network.
|
||||||
|
|
||||||
**`config path`**, **`config list`**, **`config get <key>`**, and
|
**`config path`**, **`config list`**, **`config get <key>`**, and
|
||||||
**`config set <key> <value>`** — `key`, `value`.
|
**`config set <key> <value>`** — `key`, `value`.
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ pub trait SessionApi: Send + Sync {
|
|||||||
fn shared_counts(&self) -> (u32, u32);
|
fn shared_counts(&self) -> (u32, u32);
|
||||||
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
|
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
|
||||||
fn shared_directories(&self) -> Vec<String>;
|
fn shared_directories(&self) -> Vec<String>;
|
||||||
|
/// The share index in the shape a peer receives it, for showing this
|
||||||
|
/// session's own files exactly as the network sees them.
|
||||||
|
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
|
||||||
|
fn shared_listing(&self) -> Vec<SharedDirectory>;
|
||||||
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
|
// 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<()>;
|
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()>;
|
||||||
|
|
||||||
@@ -416,6 +420,10 @@ impl SessionApi for Client {
|
|||||||
Self::shared_directories(self)
|
Self::shared_directories(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shared_listing(&self) -> Vec<SharedDirectory> {
|
||||||
|
Self::shared_listing(self)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()> {
|
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()> {
|
||||||
Self::set_shared_directories(self, directories)
|
Self::set_shared_directories(self, directories)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -407,6 +407,13 @@ pub enum SharesCommand {
|
|||||||
directory: String,
|
directory: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// List every indexed file, the way `browse` lists a peer's
|
||||||
|
Files {
|
||||||
|
/// Only paths containing this text, case-insensitively
|
||||||
|
#[arg(long)]
|
||||||
|
filter: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Re-index the shares and report what the network will see
|
/// Re-index the shares and report what the network will see
|
||||||
Reindex,
|
Reindex,
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,28 @@ pub fn shares_status(ctx: &Ctx) -> CliResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List the share index file by file, in the same records `browse` emits for
|
||||||
|
/// a peer — so what the network sees of us can be read, and diffed, exactly
|
||||||
|
/// the way it is read of anyone else.
|
||||||
|
pub fn shares_files(ctx: &Ctx, filter: Option<&str>) -> CliResult {
|
||||||
|
let session = Session::open(ctx)?;
|
||||||
|
let needle = filter.map(str::to_lowercase);
|
||||||
|
let found = super::social::emit_listing(
|
||||||
|
ctx,
|
||||||
|
&session.client.username(),
|
||||||
|
&session.client.shared_listing(),
|
||||||
|
needle.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if found == 0 {
|
||||||
|
return Err(CliError::no_results(match filter {
|
||||||
|
Some(filter) => format!("no shared file matches '{filter}'"),
|
||||||
|
None => "no files are indexed".to_string(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-scan the configured folders in a live session, so files added since the
|
/// Re-scan the configured folders in a live session, so files added since the
|
||||||
/// last run become visible without waiting for the next one.
|
/// last run become visible without waiting for the next one.
|
||||||
pub fn shares_reindex(ctx: &Ctx) -> CliResult {
|
pub fn shares_reindex(ctx: &Ctx) -> CliResult {
|
||||||
|
|||||||
@@ -48,6 +48,42 @@ pub fn fetch_listing(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit `user`'s listing one file per record, keeping only the paths that
|
||||||
|
/// contain `needle` (already lowercased; `None` keeps all). Returns how many
|
||||||
|
/// were emitted.
|
||||||
|
///
|
||||||
|
/// Shared with `shares files`, which prints this session's own index: the two
|
||||||
|
/// are only comparable while they are built the same way, and a field added
|
||||||
|
/// here has to reach both.
|
||||||
|
pub fn emit_listing(
|
||||||
|
ctx: &Ctx,
|
||||||
|
user: &str,
|
||||||
|
directories: &[SharedDirectory],
|
||||||
|
needle: Option<&str>,
|
||||||
|
) -> usize {
|
||||||
|
let mut emitted = 0usize;
|
||||||
|
for directory in directories {
|
||||||
|
for file in &directory.files {
|
||||||
|
let path = full_path(&directory.name, &file.name);
|
||||||
|
if needle
|
||||||
|
.is_some_and(|needle| !path.to_lowercase().contains(needle))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emitted += 1;
|
||||||
|
ctx.out.emit(&BrowseRecord {
|
||||||
|
user: user.to_string(),
|
||||||
|
directory: directory.name.clone(),
|
||||||
|
path,
|
||||||
|
size: file.size,
|
||||||
|
bitrate: file.attribute(super::transfer::ATTR_BITRATE),
|
||||||
|
duration: file.attribute(super::transfer::ATTR_DURATION),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitted
|
||||||
|
}
|
||||||
|
|
||||||
pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult {
|
pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult {
|
||||||
let session = Session::open(ctx)?;
|
let session = Session::open(ctx)?;
|
||||||
ctx.out
|
ctx.out
|
||||||
@@ -59,22 +95,7 @@ pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult {
|
|||||||
Duration::from_secs(args.timeout),
|
Duration::from_secs(args.timeout),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut files = 0usize;
|
if emit_listing(ctx, &args.user, &directories, None) == 0 {
|
||||||
for directory in &directories {
|
|
||||||
for file in &directory.files {
|
|
||||||
files += 1;
|
|
||||||
ctx.out.emit(&BrowseRecord {
|
|
||||||
user: args.user.clone(),
|
|
||||||
directory: directory.name.clone(),
|
|
||||||
path: full_path(&directory.name, &file.name),
|
|
||||||
size: file.size,
|
|
||||||
bitrate: file.attribute(super::transfer::ATTR_BITRATE),
|
|
||||||
duration: file.attribute(super::transfer::ATTR_DURATION),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if files == 0 {
|
|
||||||
return Err(CliError::no_results(format!(
|
return Err(CliError::no_results(format!(
|
||||||
"{} shares no files",
|
"{} shares no files",
|
||||||
args.user
|
args.user
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ use super::proto::{
|
|||||||
Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams,
|
Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams,
|
||||||
Messages, Method, PasswordParams, QueryParams, RoomEventDto, RoomRef,
|
Messages, Method, PasswordParams, QueryParams, RoomEventDto, RoomRef,
|
||||||
SayParams, SearchResults, Searches, Seconds, SessionLossEvent,
|
SayParams, SearchResults, Searches, Seconds, SessionLossEvent,
|
||||||
SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads,
|
SharedListing, SharesStatus, SlotsParams, TransferRef, UploadInfoDto,
|
||||||
UserMessageDto, UserRef, UserResult, Watched,
|
Uploads, UserMessageDto, UserRef, UserResult, Watched,
|
||||||
};
|
};
|
||||||
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
|
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -74,6 +74,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
|||||||
| Method::MessageHistory
|
| Method::MessageHistory
|
||||||
| Method::SearchList
|
| Method::SearchList
|
||||||
| Method::SharesStatusOf
|
| Method::SharesStatusOf
|
||||||
|
| Method::SharesFiles
|
||||||
| Method::SharesReindex
|
| Method::SharesReindex
|
||||||
| Method::UserWatched => json!({ "type": "null" }),
|
| Method::UserWatched => json!({ "type": "null" }),
|
||||||
}
|
}
|
||||||
@@ -102,6 +103,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
|||||||
Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => {
|
Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => {
|
||||||
schema_of::<SharesStatus>(generator)
|
schema_of::<SharesStatus>(generator)
|
||||||
}
|
}
|
||||||
|
Method::SharesFiles => schema_of::<SharedListing>(generator),
|
||||||
// Everything else acknowledges and says nothing more.
|
// Everything else acknowledges and says nothing more.
|
||||||
Method::DaemonStop
|
Method::DaemonStop
|
||||||
| Method::SearchStart
|
| Method::SearchStart
|
||||||
@@ -212,6 +214,9 @@ fn summary(method: Method) -> &'static str {
|
|||||||
}
|
}
|
||||||
Method::UserInfoOf => "What the server has said about a user so far.",
|
Method::UserInfoOf => "What the server has said about a user so far.",
|
||||||
Method::SharesStatusOf => "What the share index currently holds.",
|
Method::SharesStatusOf => "What the share index currently holds.",
|
||||||
|
Method::SharesFiles => {
|
||||||
|
"Every indexed file, in the listing a browsing peer receives."
|
||||||
|
}
|
||||||
Method::SharesSet => "Replace the shared folders and re-index.",
|
Method::SharesSet => "Replace the shared folders and re-index.",
|
||||||
Method::SharesReindex => "Re-scan the configured folders.",
|
Method::SharesReindex => "Re-scan the configured folders.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -770,6 +770,26 @@ impl From<&SharedDirectory> for SharedDirectoryDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same, consuming the listing. The daemon owns what `shared_listing`
|
||||||
|
/// hands it and drops it straight after, so copying every name and attribute
|
||||||
|
/// out of it would double the cost of answering `shares.files`.
|
||||||
|
impl From<SharedDirectory> for SharedDirectoryDto {
|
||||||
|
fn from(directory: SharedDirectory) -> Self {
|
||||||
|
Self {
|
||||||
|
name: directory.name,
|
||||||
|
files: directory
|
||||||
|
.files
|
||||||
|
.into_iter()
|
||||||
|
.map(|file| SharedFileEntryDto {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
attributes: file.attributes,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<SharedDirectoryDto> for SharedDirectory {
|
impl From<SharedDirectoryDto> for SharedDirectory {
|
||||||
fn from(dto: SharedDirectoryDto) -> Self {
|
fn from(dto: SharedDirectoryDto) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -212,6 +212,14 @@ pub struct BrowseEvent {
|
|||||||
pub directories: Vec<SharedDirectoryDto>,
|
pub directories: Vec<SharedDirectoryDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This session's own share index, as a peer receives it — what
|
||||||
|
/// `shares.files` answers with.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||||
|
pub struct SharedListing {
|
||||||
|
pub directories: Vec<SharedDirectoryDto>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One step in a transfer this client started.
|
/// One step in a transfer this client started.
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ wire_vocabulary! {
|
|||||||
UserWatched => "user.watched",
|
UserWatched => "user.watched",
|
||||||
AccountPassword => "account.password",
|
AccountPassword => "account.password",
|
||||||
SharesStatusOf => "shares.status",
|
SharesStatusOf => "shares.status",
|
||||||
|
SharesFiles => "shares.files",
|
||||||
SharesSet => "shares.set",
|
SharesSet => "shares.set",
|
||||||
SharesReindex => "shares.reindex",
|
SharesReindex => "shares.reindex",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ use super::proto::{
|
|||||||
MemberStats, Members, MessageParams, Messages, Method, OPENRPC,
|
MemberStats, Members, MessageParams, Messages, Method, OPENRPC,
|
||||||
PROTOCOL_VERSION, PasswordParams, QueryParams, RoomRef, RoomUserStatsDto,
|
PROTOCOL_VERSION, PasswordParams, QueryParams, RoomRef, RoomUserStatsDto,
|
||||||
RpcError, SayParams, SearchResultDto, SearchResults, SearchSummary,
|
RpcError, SayParams, SearchResultDto, SearchResults, SearchSummary,
|
||||||
Searches, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto,
|
Searches, Seconds, SharedDirectoryDto, SharedListing, SharesStatus,
|
||||||
Uploads, UserInfoDto, UserRef, UserResult, Watched,
|
SlotsParams, TransferRef, UploadInfoDto, Uploads, UserInfoDto, UserRef,
|
||||||
|
UserResult, Watched,
|
||||||
};
|
};
|
||||||
use crate::api::SessionApi;
|
use crate::api::SessionApi;
|
||||||
use crate::output::Exit;
|
use crate::output::Exit;
|
||||||
@@ -372,6 +373,14 @@ impl Daemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Method::SharesStatusOf => ok(self.shares()),
|
Method::SharesStatusOf => ok(self.shares()),
|
||||||
|
Method::SharesFiles => ok(SharedListing {
|
||||||
|
directories: self
|
||||||
|
.session
|
||||||
|
.shared_listing()
|
||||||
|
.into_iter()
|
||||||
|
.map(SharedDirectoryDto::from)
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
Method::SharesSet => {
|
Method::SharesSet => {
|
||||||
let directories: DirectoriesParams = parse(params)?;
|
let directories: DirectoriesParams = parse(params)?;
|
||||||
self.session
|
self.session
|
||||||
@@ -1018,6 +1027,9 @@ mod tests {
|
|||||||
fn shared_counts(&self) -> (u32, u32) {
|
fn shared_counts(&self) -> (u32, u32) {
|
||||||
(0, 0)
|
(0, 0)
|
||||||
}
|
}
|
||||||
|
fn shared_listing(&self) -> Vec<soulseek_rs::SharedDirectory> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
fn shared_directories(&self) -> Vec<String> {
|
fn shared_directories(&self) -> Vec<String> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,9 @@ pub enum BrowseStatus {
|
|||||||
/// UI state for browsing one user.
|
/// UI state for browsing one user.
|
||||||
pub struct BrowseState {
|
pub struct BrowseState {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
/// This session's own share index rather than a peer's: filled from the
|
||||||
|
/// local index, re-indexed rather than re-requested, and not downloadable.
|
||||||
|
pub own: bool,
|
||||||
pub status: BrowseStatus,
|
pub status: BrowseStatus,
|
||||||
pub tree: Vec<BrowseNode>,
|
pub tree: Vec<BrowseNode>,
|
||||||
expanded: HashSet<String>,
|
expanded: HashSet<String>,
|
||||||
@@ -356,6 +359,7 @@ impl BrowseState {
|
|||||||
pub fn loading(username: String) -> Self {
|
pub fn loading(username: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
username,
|
username,
|
||||||
|
own: false,
|
||||||
status: BrowseStatus::Loading,
|
status: BrowseStatus::Loading,
|
||||||
tree: Vec::new(),
|
tree: Vec::new(),
|
||||||
expanded: HashSet::new(),
|
expanded: HashSet::new(),
|
||||||
@@ -370,6 +374,15 @@ impl BrowseState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An empty view of this session's own shares, waiting for the index.
|
||||||
|
#[must_use]
|
||||||
|
pub fn loading_own(username: String) -> Self {
|
||||||
|
Self {
|
||||||
|
own: true,
|
||||||
|
..Self::loading(username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Populate from a received listing: build the tree, auto-expand the top
|
/// Populate from a received listing: build the tree, auto-expand the top
|
||||||
/// level so structure is visible, and set Loaded/Empty.
|
/// level so structure is visible, and set Loaded/Empty.
|
||||||
pub fn load(&mut self, directories: &[SharedDirectory]) {
|
pub fn load(&mut self, directories: &[SharedDirectory]) {
|
||||||
@@ -528,7 +541,10 @@ impl BrowseTabs {
|
|||||||
/// `true` if the caller should (re)issue a browse request — i.e. the tab is
|
/// `true` if the caller should (re)issue a browse request — i.e. the tab is
|
||||||
/// new or was retried after a timeout.
|
/// new or was retried after a timeout.
|
||||||
pub fn open(&mut self, username: &str) -> bool {
|
pub fn open(&mut self, username: &str) -> bool {
|
||||||
if let Some(idx) = self.tabs.iter().position(|t| t.username == username)
|
if let Some(idx) = self
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.position(|t| !t.own && t.username == username)
|
||||||
{
|
{
|
||||||
self.active = idx;
|
self.active = idx;
|
||||||
// Re-request only if the previous attempt gave up.
|
// Re-request only if the previous attempt gave up.
|
||||||
@@ -545,6 +561,40 @@ impl BrowseTabs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where this session's own share tab sits, if it is open.
|
||||||
|
#[must_use]
|
||||||
|
pub fn own_index(&self) -> Option<usize> {
|
||||||
|
self.tabs.iter().position(|tab| tab.own)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focus the tab showing this session's own shares, opening it if it is
|
||||||
|
/// not there yet. Returns `true` when it still needs its listing.
|
||||||
|
pub fn open_own(&mut self, username: &str) -> bool {
|
||||||
|
if let Some(idx) = self.own_index() {
|
||||||
|
self.active = idx;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.tabs
|
||||||
|
.push(BrowseState::loading_own(username.to_string()));
|
||||||
|
self.active = self.tabs.len() - 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the focused tab is this session's own share index.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_is_own(&self) -> bool {
|
||||||
|
self.active_tab().is_some_and(|tab| tab.own)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who the active tab's files would be downloaded from, if anyone. The
|
||||||
|
/// own tab names nobody: its files are already on this disk.
|
||||||
|
#[must_use]
|
||||||
|
pub fn download_target(&self) -> Option<&str> {
|
||||||
|
self.active_tab()
|
||||||
|
.filter(|tab| !tab.own)
|
||||||
|
.map(|tab| tab.username.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
/// Retry the active tab if it timed out: reset it to loading and return the
|
/// Retry the active tab if it timed out: reset it to loading and return the
|
||||||
/// username so the caller can re-issue the request. Returns `None` if the
|
/// username so the caller can re-issue the request. Returns `None` if the
|
||||||
/// active tab isn't in a retryable (timed-out) state.
|
/// active tab isn't in a retryable (timed-out) state.
|
||||||
@@ -740,6 +790,29 @@ mod tests {
|
|||||||
assert!(tabs.is_empty());
|
assert!(tabs.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_own_tab_is_opened_once_and_is_not_a_peer_of_the_same_name() {
|
||||||
|
let mut tabs = BrowseTabs::new();
|
||||||
|
// The first open needs a listing; focusing it again does not.
|
||||||
|
assert!(tabs.open_own("tester"));
|
||||||
|
assert!(!tabs.open_own("tester"));
|
||||||
|
assert_eq!(tabs.tabs.len(), 1);
|
||||||
|
assert_eq!(tabs.own_index(), Some(0));
|
||||||
|
assert!(tabs.active_tab().expect("tab").own);
|
||||||
|
|
||||||
|
// Browsing the account's own name over the network is a separate
|
||||||
|
// tab: the own tab holds the local index, not a peer's answer.
|
||||||
|
assert!(tabs.open("tester"));
|
||||||
|
assert_eq!(tabs.tabs.len(), 2);
|
||||||
|
assert!(!tabs.active_tab().expect("tab").own);
|
||||||
|
assert_eq!(tabs.own_index(), Some(0));
|
||||||
|
|
||||||
|
// The peer of that name is downloadable; we are not.
|
||||||
|
assert_eq!(tabs.download_target(), Some("tester"));
|
||||||
|
tabs.active = 0;
|
||||||
|
assert_eq!(tabs.download_target(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browse_tabs_reopen_after_timeout_requests_again() {
|
fn browse_tabs_reopen_after_timeout_requests_again() {
|
||||||
let mut tabs = BrowseTabs::new();
|
let mut tabs = BrowseTabs::new();
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ use crate::daemon::proto::{
|
|||||||
DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds,
|
DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds,
|
||||||
MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION,
|
MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION,
|
||||||
PasswordParams, QueryParams, Request, Response, RoomRef, RpcError,
|
PasswordParams, QueryParams, Request, Response, RoomRef, RpcError,
|
||||||
SayParams, SearchResults, Searches, Seconds, SharesStatus, SlotsParams,
|
SayParams, SearchResults, Searches, Seconds, SharedListing, SharesStatus,
|
||||||
TransferRef, Uploads, UserRef, UserResult, Watched,
|
SlotsParams, TransferRef, Uploads, UserRef, UserResult, Watched,
|
||||||
};
|
};
|
||||||
use mirror::Mirror;
|
use mirror::Mirror;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
@@ -823,6 +823,18 @@ impl SessionApi for RemoteSession {
|
|||||||
self.shares().directories
|
self.shares().directories
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: a blocking round-trip that carries the whole index — a very
|
||||||
|
// large share is tens of MB of JSON before the next redraw. It is only
|
||||||
|
// ever a keypress, never a frame; if that stops being tolerable, push it
|
||||||
|
// as an event the way `browse.user` answers with `event.browse`.
|
||||||
|
fn shared_listing(&self) -> Vec<SharedDirectory> {
|
||||||
|
self.request::<_, SharedListing>(Method::SharesFiles, ())
|
||||||
|
.map(|listing| {
|
||||||
|
listing.directories.into_iter().map(Into::into).collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()> {
|
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()> {
|
||||||
self.tell(Method::SharesSet, DirectoriesParams { directories })
|
self.tell(Method::SharesSet, DirectoriesParams { directories })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
|||||||
Commands::Shares(SharesCommand::Status) => {
|
Commands::Shares(SharesCommand::Status) => {
|
||||||
crate::commands::settings::shares_status(&ctx)
|
crate::commands::settings::shares_status(&ctx)
|
||||||
}
|
}
|
||||||
|
Commands::Shares(SharesCommand::Files { ref filter }) => {
|
||||||
|
crate::commands::settings::shares_files(&ctx, filter.as_deref())
|
||||||
|
}
|
||||||
Commands::Shares(SharesCommand::Reindex) => {
|
Commands::Shares(SharesCommand::Reindex) => {
|
||||||
crate::commands::settings::shares_reindex(&ctx)
|
crate::commands::settings::shares_reindex(&ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ impl MainTui {
|
|||||||
self.sync_browse_selection();
|
self.sync_browse_selection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('B') => {
|
||||||
|
self.show_own_shares();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') if self.state.browse.active_is_own() => {
|
||||||
|
self.reindex_shares();
|
||||||
|
return;
|
||||||
|
}
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
// Retry a timed-out browse.
|
// Retry a timed-out browse.
|
||||||
if let Some(username) = self.state.browse.retry_active() {
|
if let Some(username) = self.state.browse.retry_active() {
|
||||||
@@ -61,6 +69,13 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The shared folders themselves are the settings pane's business,
|
||||||
|
// so adding or dropping one is a keystroke away from seeing it.
|
||||||
|
KeyCode::Char('o') if self.state.browse.active_is_own() => {
|
||||||
|
self.state.show_browse = false;
|
||||||
|
self.open_settings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +207,11 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Queue downloads of `files` (path, size) from the active browse tab's user.
|
/// Queue downloads of `files` (path, size) from the active browse tab's user.
|
||||||
|
///
|
||||||
|
/// Our own shares are already on this disk, so the own tab queues nothing.
|
||||||
fn queue_browse_files(&mut self, files: Vec<(String, u64)>) {
|
fn queue_browse_files(&mut self, files: Vec<(String, u64)>) {
|
||||||
let Some(username) =
|
let Some(username) =
|
||||||
self.state.browse.active_tab().map(|b| b.username.clone())
|
self.state.browse.download_target().map(str::to_string)
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -240,6 +257,31 @@ impl MainTui {
|
|||||||
self.sync_browse_selection();
|
self.sync_browse_selection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open (or focus) the view of what this session shares, filled from the
|
||||||
|
/// index the network is served from.
|
||||||
|
pub(super) fn show_own_shares(&mut self) {
|
||||||
|
if self.state.browse.open_own(&self.client.username()) {
|
||||||
|
self.load_own_shares();
|
||||||
|
}
|
||||||
|
self.state.show_browse = true;
|
||||||
|
self.sync_browse_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a fresh copy of the share index into the own tab, if it is open.
|
||||||
|
/// A re-index or an added folder would otherwise leave it showing what
|
||||||
|
/// the network saw a scan ago.
|
||||||
|
pub(super) fn load_own_shares(&mut self) {
|
||||||
|
// The early return matters: without an own tab there is nothing to
|
||||||
|
// fill, and against a daemon asking anyway is a whole index over the
|
||||||
|
// socket for nobody.
|
||||||
|
let Some(idx) = self.state.browse.own_index() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let listing = self.client.shared_listing();
|
||||||
|
self.state.browse.tabs[idx].load(&listing);
|
||||||
|
self.sync_browse_selection();
|
||||||
|
}
|
||||||
|
|
||||||
/// The username of the highlighted search result (filter-aware).
|
/// The username of the highlighted search result (filter-aware).
|
||||||
pub(super) fn highlighted_result_owner(&self) -> Option<String> {
|
pub(super) fn highlighted_result_owner(&self) -> Option<String> {
|
||||||
self.highlighted_result().map(|f| f.username.clone())
|
self.highlighted_result().map(|f| f.username.clone())
|
||||||
|
|||||||
@@ -144,6 +144,12 @@ impl MainTui {
|
|||||||
self.open_settings();
|
self.open_settings();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The same tree a peer gets, from our own index: what we look
|
||||||
|
// like to the network, rather than what we meant to share.
|
||||||
|
KeyCode::Char('B') => {
|
||||||
|
self.show_own_shares();
|
||||||
|
return;
|
||||||
|
}
|
||||||
KeyCode::Char('b') => {
|
KeyCode::Char('b') => {
|
||||||
// From a highlighted search result, browse its owner directly;
|
// From a highlighted search result, browse its owner directly;
|
||||||
// otherwise prompt for a username.
|
// otherwise prompt for a username.
|
||||||
|
|||||||
@@ -297,6 +297,8 @@ mod tests {
|
|||||||
shares_set: std::sync::Mutex<Option<Vec<String>>>,
|
shares_set: std::sync::Mutex<Option<Vec<String>>>,
|
||||||
/// What `change_password` was last asked for, if anything.
|
/// What `change_password` was last asked for, if anything.
|
||||||
password_set: std::sync::Mutex<Option<String>>,
|
password_set: std::sync::Mutex<Option<String>>,
|
||||||
|
/// The share index this session serves to peers.
|
||||||
|
listing: std::sync::Mutex<Vec<soulseek_rs::SharedDirectory>>,
|
||||||
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
||||||
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
||||||
download_removed: std::sync::Mutex<Vec<(String, String)>>,
|
download_removed: std::sync::Mutex<Vec<(String, String)>>,
|
||||||
@@ -559,6 +561,9 @@ mod tests {
|
|||||||
Some(password.to_string());
|
Some(password.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
fn shared_listing(&self) -> Vec<soulseek_rs::SharedDirectory> {
|
||||||
|
self.listing.lock().expect("not poisoned").clone()
|
||||||
|
}
|
||||||
fn shared_directories(&self) -> Vec<String> {
|
fn shared_directories(&self) -> Vec<String> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -1902,6 +1907,68 @@ mod tests {
|
|||||||
let _ = screen_of(tui);
|
let _ = screen_of(tui);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() {
|
||||||
|
let session = Arc::new(TalkativeSession::default());
|
||||||
|
*session.listing.lock().expect("not poisoned") =
|
||||||
|
vec![soulseek_rs::SharedDirectory {
|
||||||
|
name: "Music\\Album".to_string(),
|
||||||
|
files: vec![soulseek_rs::SharedFileEntry {
|
||||||
|
name: "track.mp3".to_string(),
|
||||||
|
size: 4096,
|
||||||
|
attributes: Vec::new(),
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
let mut tui = attach(session.clone());
|
||||||
|
|
||||||
|
press(&mut tui, KeyCode::Char('B'));
|
||||||
|
assert!(tui.state.show_browse, "the popup opens");
|
||||||
|
let tab = tui.state.browse.active_tab().expect("tab");
|
||||||
|
assert!(tab.own, "on this session's own index");
|
||||||
|
assert_eq!(tab.username, "tester");
|
||||||
|
assert_eq!(tab.file_count, 1);
|
||||||
|
let screen = screen_of(&mut tui);
|
||||||
|
assert!(screen.contains("My shares"), "{screen}");
|
||||||
|
press(&mut tui, KeyCode::Char('L'));
|
||||||
|
let screen = screen_of(&mut tui);
|
||||||
|
assert!(
|
||||||
|
screen.contains("Album") && screen.contains("track.mp3"),
|
||||||
|
"the virtual path a peer would see, folder by folder: {screen}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing here is downloadable from ourselves.
|
||||||
|
press(&mut tui, KeyCode::Char('d'));
|
||||||
|
assert!(tui.state.downloads.is_empty());
|
||||||
|
|
||||||
|
// `r` re-scans the disk rather than re-asking a peer, and the tab
|
||||||
|
// picks up what the fresh scan found.
|
||||||
|
session.listing.lock().expect("not poisoned").clear();
|
||||||
|
press(&mut tui, KeyCode::Char('r'));
|
||||||
|
assert!(
|
||||||
|
session.shares_set.lock().expect("not poisoned").is_some(),
|
||||||
|
"the session was asked to re-index"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tui.state.browse.active_tab().expect("tab").file_count,
|
||||||
|
0,
|
||||||
|
"the view follows the new index"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A second B focuses the tab it already opened.
|
||||||
|
press(&mut tui, KeyCode::Esc);
|
||||||
|
press(&mut tui, KeyCode::Char('B'));
|
||||||
|
assert_eq!(tui.state.browse.tabs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn o_leaves_the_share_view_for_the_folders_that_feed_it() {
|
||||||
|
let mut tui = attach(Arc::new(TalkativeSession::default()));
|
||||||
|
press(&mut tui, KeyCode::Char('B'));
|
||||||
|
press(&mut tui, KeyCode::Char('o'));
|
||||||
|
assert!(!tui.state.show_browse, "the popup steps aside");
|
||||||
|
assert!(tui.state.settings.is_some(), "for the settings pane");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() {
|
fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() {
|
||||||
let session = Arc::new(TalkativeSession::default());
|
let session = Arc::new(TalkativeSession::default());
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
&[
|
&[
|
||||||
("s", "search the network"),
|
("s", "search the network"),
|
||||||
("b", "browse a user's files"),
|
("b", "browse a user's files"),
|
||||||
|
("B", "browse what you share yourself"),
|
||||||
("m", "compose a private message"),
|
("m", "compose a private message"),
|
||||||
("i", "inbox"),
|
("i", "inbox"),
|
||||||
("o", "settings"),
|
("o", "settings"),
|
||||||
@@ -121,7 +122,8 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("/", "filter by path, Enter keeps it, Esc clears"),
|
("/", "filter by path, Enter keeps it, Esc clears"),
|
||||||
("Enter", "open a folder, or download a file"),
|
("Enter", "open a folder, or download a file"),
|
||||||
("d", "download a file, or a folder's files"),
|
("d", "download a file, or a folder's files"),
|
||||||
("r", "ask again after a timeout"),
|
("r", "ask again after a timeout, re-index your own"),
|
||||||
|
("o", "shared folders (your own tab)"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -730,19 +732,35 @@ impl MainTui {
|
|||||||
// Typing goes to the filter, so no letter keys are on offer.
|
// Typing goes to the filter, so no letter keys are on offer.
|
||||||
filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
|
filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
|
||||||
} else {
|
} else {
|
||||||
vec![
|
// Nothing of ours is downloadable from ourselves, and `r`
|
||||||
|
// re-scans the disk instead of re-asking a peer.
|
||||||
|
let acts = if self.state.browse.active_is_own() {
|
||||||
|
[
|
||||||
|
("Enter", "open folder"),
|
||||||
|
("r", "re-index"),
|
||||||
|
("o", "shared folders"),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
[
|
||||||
|
("Enter", "open/download"),
|
||||||
|
("d", "download folder"),
|
||||||
|
("r", "retry"),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let mut keys = vec![
|
||||||
("↑↓", "move"),
|
("↑↓", "move"),
|
||||||
("J/K", "next/prev folder"),
|
("J/K", "next/prev folder"),
|
||||||
("→←", "expand/collapse"),
|
("→←", "expand/collapse"),
|
||||||
("H/L", "collapse/expand all"),
|
("H/L", "collapse/expand all"),
|
||||||
("/", "filter"),
|
("/", "filter"),
|
||||||
("Enter", "open/download"),
|
];
|
||||||
("d", "download folder"),
|
keys.extend(acts);
|
||||||
|
keys.extend([
|
||||||
("Tab", "switch user"),
|
("Tab", "switch user"),
|
||||||
("r", "retry"),
|
|
||||||
("w", "close tab"),
|
("w", "close tab"),
|
||||||
("Esc", "hide"),
|
("Esc", "hide"),
|
||||||
]
|
]);
|
||||||
|
keys
|
||||||
}
|
}
|
||||||
} else if self.state.command_bar_active {
|
} else if self.state.command_bar_active {
|
||||||
match self.state.command_bar_mode {
|
match self.state.command_bar_mode {
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.set_settings_status(status);
|
self.set_settings_status(status);
|
||||||
|
self.load_own_shares();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bring the account block's share count back in step, and hand the same
|
/// Bring the account block's share count back in step, and hand the same
|
||||||
@@ -147,14 +148,22 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-scan the current share paths (picks up files changed on disk).
|
/// Re-scan the current share paths (picks up files changed on disk).
|
||||||
fn reindex_shares(&mut self) {
|
// ponytail: the scan runs on the UI thread, so a very large share set
|
||||||
|
// freezes the window for its duration. Move it to a worker if that bites.
|
||||||
|
pub(super) fn reindex_shares(&mut self) {
|
||||||
let dirs = self.client.shared_directories();
|
let dirs = self.client.shared_directories();
|
||||||
let result = self.client.set_shared_directories(dirs);
|
let result = self.client.set_shared_directories(dirs);
|
||||||
let counts = self.refresh_share_counts();
|
// Only the settings pane shows these, and `r` in the share view
|
||||||
self.set_settings_status(match result {
|
// calls us with the pane closed — where the counts would be a
|
||||||
Ok(()) => format!("Re-indexed · sharing {counts}"),
|
// round-trip against the daemon for a string nobody reads.
|
||||||
Err(e) => format!("Re-index failed: {e}"),
|
if self.state.settings.is_some() {
|
||||||
});
|
let counts = self.refresh_share_counts();
|
||||||
|
self.set_settings_status(match result {
|
||||||
|
Ok(()) => format!("Re-indexed · sharing {counts}"),
|
||||||
|
Err(e) => format!("Re-index failed: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.load_own_shares();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn share_counts(&self) -> String {
|
fn share_counts(&self) -> String {
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) {
|
|||||||
} else {
|
} else {
|
||||||
dimmed_style()
|
dimmed_style()
|
||||||
};
|
};
|
||||||
spans.push(Span::styled(format!(" {} ", tab.username), style));
|
let you = if tab.own { " (you)" } else { "" };
|
||||||
|
spans.push(Span::styled(format!(" {}{you} ", tab.username), style));
|
||||||
spans.push(Span::raw(" "));
|
spans.push(Span::raw(" "));
|
||||||
}
|
}
|
||||||
frame.render_widget(Paragraph::new(Line::from(spans)), area);
|
frame.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||||
@@ -78,24 +79,33 @@ fn render_browse_one(
|
|||||||
spinner_state: usize,
|
spinner_state: usize,
|
||||||
downloads: &[DownloadEntry],
|
downloads: &[DownloadEntry],
|
||||||
) {
|
) {
|
||||||
|
// Our own index is not downloadable and re-scans instead of re-asking,
|
||||||
|
// so it names itself and offers its own keys.
|
||||||
|
let heading = if browse.own {
|
||||||
|
"My shares".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Browse {}", browse.username)
|
||||||
|
};
|
||||||
let title = match browse.status {
|
let title = match browse.status {
|
||||||
BrowseStatus::Loaded => {
|
BrowseStatus::Loaded => {
|
||||||
|
let keys = if browse.own {
|
||||||
|
"/: filter, r: re-index, o: folders, w: close, Esc: hide"
|
||||||
|
} else {
|
||||||
|
"Enter/d: download, /: filter, Tab: user, w: close, Esc: hide"
|
||||||
|
};
|
||||||
let shown =
|
let shown =
|
||||||
browse.rows().iter().filter(|row| !row.is_folder).count();
|
browse.rows().iter().filter(|row| !row.is_folder).count();
|
||||||
filter_title(
|
filter_title(
|
||||||
&format!(
|
&format!("{heading} — {shown}/{} files", browse.file_count),
|
||||||
"Browse {} — {shown}/{} files",
|
|
||||||
browse.username, browse.file_count
|
|
||||||
),
|
|
||||||
browse.filter(),
|
browse.filter(),
|
||||||
browse.filtering,
|
browse.filtering,
|
||||||
&format!(
|
&format!(
|
||||||
" Browse {} — {} files, {} folders (Enter/d: download, /: filter, Tab: user, w: close, Esc: hide) ",
|
" {heading} — {} files, {} folders ({keys}) ",
|
||||||
browse.username, browse.file_count, browse.folder_count
|
browse.file_count, browse.folder_count
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_ => format!(" Browse {} ", browse.username),
|
_ => format!(" {heading} "),
|
||||||
};
|
};
|
||||||
let block = pane_block(true).title(title);
|
let block = pane_block(true).title(title);
|
||||||
|
|
||||||
@@ -112,7 +122,12 @@ fn render_browse_one(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
BrowseStatus::Empty => {
|
BrowseStatus::Empty => {
|
||||||
let text = format!("{} is not sharing any files.", browse.username);
|
let text = if browse.own {
|
||||||
|
"You are not sharing any files. Press o to add a folder."
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} is not sharing any files.", browse.username)
|
||||||
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(text).style(dimmed_style()).block(block),
|
Paragraph::new(text).style(dimmed_style()).block(block),
|
||||||
area,
|
area,
|
||||||
@@ -333,14 +348,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn screen_with(downloads: &[DownloadEntry]) -> String {
|
/// One tab drawn onto a `width`-wide buffer.
|
||||||
let mut browse = BrowseState::loading("bob".to_string());
|
fn screen(
|
||||||
browse.load(&[share()]);
|
browse: BrowseState,
|
||||||
|
downloads: &[DownloadEntry],
|
||||||
|
width: u16,
|
||||||
|
) -> String {
|
||||||
let mut tabs = BrowseTabs::default();
|
let mut tabs = BrowseTabs::default();
|
||||||
tabs.tabs.push(browse);
|
tabs.tabs.push(browse);
|
||||||
let mut state = TableState::default();
|
let mut state = TableState::default();
|
||||||
let mut terminal =
|
let mut terminal =
|
||||||
Terminal::new(TestBackend::new(60, 8)).expect("backend");
|
Terminal::new(TestBackend::new(width, 8)).expect("backend");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_browse_pane(
|
render_browse_pane(
|
||||||
@@ -356,6 +374,12 @@ mod tests {
|
|||||||
terminal.backend().to_string()
|
terminal.backend().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn screen_with(downloads: &[DownloadEntry]) -> String {
|
||||||
|
let mut browse = BrowseState::loading("bob".to_string());
|
||||||
|
browse.load(&[share()]);
|
||||||
|
screen(browse, downloads, 60)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_downloading_file_carries_its_transfer_glyph() {
|
fn a_downloading_file_carries_its_transfer_glyph() {
|
||||||
let screen = screen_with(&[
|
let screen = screen_with(&[
|
||||||
@@ -424,6 +448,34 @@ mod tests {
|
|||||||
assert!(!screen.contains(GLYPH_ACTIVE), "{screen}");
|
assert!(!screen.contains(GLYPH_ACTIVE), "{screen}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The own tab, rendered with `directories` as its index. Wider than the
|
||||||
|
/// peer screens because the title carries the keys it offers.
|
||||||
|
fn own_screen(directories: &[SharedDirectory]) -> String {
|
||||||
|
let mut browse = BrowseState::loading_own("tester".to_string());
|
||||||
|
browse.load(directories);
|
||||||
|
screen(browse, &[], 90)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_own_tab_names_itself_and_offers_no_download() {
|
||||||
|
let screen = own_screen(&[share()]);
|
||||||
|
assert!(screen.contains("My shares"), "{screen}");
|
||||||
|
assert!(!screen.contains("Browse tester"), "{screen}");
|
||||||
|
assert!(screen.contains("re-index"), "r re-scans instead: {screen}");
|
||||||
|
assert!(
|
||||||
|
!screen.contains("download"),
|
||||||
|
"nothing here is downloadable from ourselves: {screen}"
|
||||||
|
);
|
||||||
|
assert!(screen.contains("plain.mp3"), "the tree still shows");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sharing_nothing_says_how_to_start() {
|
||||||
|
let screen = own_screen(&[]);
|
||||||
|
assert!(screen.contains("not sharing any files"), "{screen}");
|
||||||
|
assert!(screen.contains("Press o to add a folder"), "{screen}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn another_user_s_transfers_mark_nothing() {
|
fn another_user_s_transfers_mark_nothing() {
|
||||||
let mut theirs = download("active.mp3", DownloadStatus::Completed);
|
let mut theirs = download("active.mp3", DownloadStatus::Completed);
|
||||||
|
|||||||
@@ -2299,6 +2299,87 @@ fn shares_status_reports_what_the_network_will_see() {
|
|||||||
assert!(record["folders"].as_u64().is_some_and(|n| n >= 1));
|
assert!(record["folders"].as_u64().is_some_and(|n| n >= 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The index, file by file, in the records `browse` gives for a peer — the
|
||||||
|
/// point being that the two are comparable: what the network sees of us
|
||||||
|
/// reads exactly like what it sees of anyone else.
|
||||||
|
#[test]
|
||||||
|
fn shares_files_lists_the_index_as_a_peer_would_receive_it() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
let share = Scratch::new("share");
|
||||||
|
std::fs::create_dir_all(share.path().join("album")).expect("subfolder");
|
||||||
|
std::fs::write(share.path().join("top.bin"), probe_bytes())
|
||||||
|
.expect("share file");
|
||||||
|
std::fs::write(share.path().join("album").join("deep.bin"), probe_bytes())
|
||||||
|
.expect("share file");
|
||||||
|
|
||||||
|
let listed = cli(
|
||||||
|
&server,
|
||||||
|
"cli_e2e_share_files",
|
||||||
|
&[
|
||||||
|
"--shared-dir",
|
||||||
|
&share.display(),
|
||||||
|
"--json",
|
||||||
|
"shares",
|
||||||
|
"files",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(code(&listed), EXIT_OK, "stderr: {}", stderr(&listed));
|
||||||
|
|
||||||
|
let paths: Vec<String> = records(&listed)
|
||||||
|
.iter()
|
||||||
|
.map(|line| {
|
||||||
|
serde_json::from_str::<serde_json::Value>(line).expect("JSON")
|
||||||
|
["path"]
|
||||||
|
.as_str()
|
||||||
|
.expect("a path")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(paths.len(), 2, "both files, got {paths:?}");
|
||||||
|
assert!(
|
||||||
|
paths.iter().any(|p| p.ends_with("album\\deep.bin")),
|
||||||
|
"the nested file carries the backslash path peers download by: \
|
||||||
|
{paths:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let first = json_record(&listed);
|
||||||
|
assert_eq!(first["user"], "cli_e2e_share_files");
|
||||||
|
assert!(first["size"].as_u64().is_some_and(|size| size > 0));
|
||||||
|
|
||||||
|
// The filter narrows to matching paths, and finding nothing is exit 4
|
||||||
|
// rather than an empty success.
|
||||||
|
let narrowed = cli(
|
||||||
|
&server,
|
||||||
|
"cli_e2e_share_files",
|
||||||
|
&[
|
||||||
|
"--shared-dir",
|
||||||
|
&share.display(),
|
||||||
|
"--json",
|
||||||
|
"shares",
|
||||||
|
"files",
|
||||||
|
"--filter",
|
||||||
|
"DEEP",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(code(&narrowed), EXIT_OK, "stderr: {}", stderr(&narrowed));
|
||||||
|
assert_eq!(records(&narrowed).len(), 1, "case-insensitively");
|
||||||
|
|
||||||
|
let missing = cli(
|
||||||
|
&server,
|
||||||
|
"cli_e2e_share_files",
|
||||||
|
&[
|
||||||
|
"--shared-dir",
|
||||||
|
&share.display(),
|
||||||
|
"--json",
|
||||||
|
"shares",
|
||||||
|
"files",
|
||||||
|
"--filter",
|
||||||
|
"nothing-like-this",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(code(&missing), EXIT_NO_RESULTS);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shares_reindex_picks_up_a_file_added_after_startup() {
|
fn shares_reindex_picks_up_a_file_added_after_startup() {
|
||||||
let server = server_or_skip!();
|
let server = server_or_skip!();
|
||||||
|
|||||||
@@ -126,6 +126,41 @@ fn finished(tui: &MainTui, _: &str) -> bool {
|
|||||||
tui.state().searches[0].status == SearchStatus::Completed
|
tui.state().searches[0].status == SearchStatus::Completed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The window's answer to "am I actually sharing that?", against a real
|
||||||
|
/// scan of a real folder rather than a stand-in listing.
|
||||||
|
#[test]
|
||||||
|
fn shift_b_shows_the_scanned_index_the_way_a_peer_would_get_it() {
|
||||||
|
let server = server_or_skip!();
|
||||||
|
|
||||||
|
let share = tempfile::tempdir().expect("share dir");
|
||||||
|
std::fs::create_dir_all(share.path().join("album")).expect("subfolder");
|
||||||
|
std::fs::write(
|
||||||
|
share.path().join("album").join("tui_probe_own.bin"),
|
||||||
|
[3u8; 32],
|
||||||
|
)
|
||||||
|
.expect("share file");
|
||||||
|
|
||||||
|
let state = tempfile::tempdir().expect("state dir");
|
||||||
|
let downloads = tempfile::tempdir().expect("download dir");
|
||||||
|
let me = Arc::new(server.client(
|
||||||
|
"tui_e2e_own_shares",
|
||||||
|
vec![share.path().display().to_string()],
|
||||||
|
));
|
||||||
|
let mut window = Window::open(me, state.path(), downloads.path());
|
||||||
|
|
||||||
|
window.press(KeyCode::Char('B'));
|
||||||
|
window.press(KeyCode::Char('L'));
|
||||||
|
let screen = window.frame();
|
||||||
|
assert!(screen.contains("My shares"), "{screen}");
|
||||||
|
assert!(
|
||||||
|
screen.contains("album") && screen.contains("tui_probe_own.bin"),
|
||||||
|
"the scanned tree, folder by folder:\n{screen}"
|
||||||
|
);
|
||||||
|
|
||||||
|
window.press(KeyCode::Esc);
|
||||||
|
assert!(!window.tui.state().show_browse, "Esc hides the popup");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_s_runs_a_restored_query_again_and_the_answers_land_on_screen() {
|
fn shift_s_runs_a_restored_query_again_and_the_answers_land_on_screen() {
|
||||||
let server = server_or_skip!();
|
let server = server_or_skip!();
|
||||||
|
|||||||
Reference in New Issue
Block a user