community: one area for the people on both networks, and the hub grows search tabs, a folder column and a queue column

This commit is contained in:
KeelrLeek committed 2026-09-02 13:43:16 -05:00
1 parent 6dce4e2ac7
commit 8c25f93afe
14 files changed
+2367 -67

No files matched your search

+3
View File
@@ -857,6 +857,9 @@ pub async fn connect_and_login(
"id": u.id, "id": u.id,
"username": u.username, "username": u.username,
"name": u.filename.rsplit('\\').next().unwrap_or(&u.filename), "name": u.filename.rsplit('\\').next().unwrap_or(&u.filename),
// Full shared path too — Transfers shows the
// parent folder ahead of the name, Qt-style.
"path": u.filename,
"state": state, "state": state,
"sent": u.sent, "sent": u.sent,
"total": u.total, "total": u.total,
+89
View File
@@ -0,0 +1,89 @@
//! The Community area's own small backend: the local contact metadata
//! (petnames, groups, notes, the two local marks) and the file dialogs the
//! You section needs. Everything Soulseek lives in commands.rs, everything
//! Freenet in freenet.rs / persona.rs / profile.rs — this is the glue that
//! is neither.
use serde::Deserialize;
use crate::config::{self, ContactMeta};
#[tauri::command]
pub fn contacts_meta_list(app: tauri::AppHandle) -> Vec<ContactMeta> {
config::load(&app).contacts
}
/// Upsert one contact's local metadata. An entry with nothing set is
/// dropped rather than stored empty, so the list only ever holds people the
/// user actually said something about.
#[tauri::command]
pub fn contact_meta_set(app: tauri::AppHandle, meta: ContactMeta) -> Vec<ContactMeta> {
let blank = |s: &Option<String>| s.as_deref().map_or(true, |v| v.trim().is_empty());
let mut cfg = config::load(&app);
cfg.contacts.retain(|c| c.username != meta.username);
let empty = blank(&meta.petname)
&& blank(&meta.group)
&& blank(&meta.note)
&& !meta.trusted
&& !meta.favorite;
if !empty {
let trim = |s: Option<String>| s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
cfg.contacts.push(ContactMeta {
username: meta.username,
petname: trim(meta.petname),
group: trim(meta.group),
note: trim(meta.note),
trusted: meta.trusted,
favorite: meta.favorite,
});
}
config::save(&app, &cfg);
cfg.contacts
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PickKind {
/// A source image for the avatar pipeline.
Image,
/// An existing recovery artifact to import.
RecoveryOpen,
/// Where to write a new recovery artifact.
RecoverySave,
}
const RECOVERY_EXT: &str = "ssrecovery";
/// Native file dialogs for the You section. Paths only — the bytes behind
/// them are read and written by Rust (persona.rs, profile.rs), never by the
/// webview.
#[tauri::command]
pub async fn community_pick_file(
app: tauri::AppHandle,
kind: PickKind,
) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
let done = move |p: Option<tauri_plugin_dialog::FilePath>| {
let _ = tx.send(p.map(|p| p.to_string()));
};
match kind {
PickKind::Image => app
.dialog()
.file()
.add_filter("Image", &["jpg", "jpeg", "png", "webp"])
.pick_file(done),
PickKind::RecoveryOpen => app
.dialog()
.file()
.add_filter("SoulStream recovery file", &[RECOVERY_EXT])
.pick_file(done),
PickKind::RecoverySave => app
.dialog()
.file()
.add_filter("SoulStream recovery file", &[RECOVERY_EXT])
.set_file_name(&format!("soulstream-identity.{RECOVERY_EXT}"))
.save_file(done),
}
Ok(rx.await.unwrap_or(None))
}
+28
View File
@@ -1,7 +1,10 @@
mod commands; mod commands;
mod community;
mod config; mod config;
mod cover_logo; mod cover_logo;
mod diagnostics; mod diagnostics;
mod freenet;
mod persona;
mod hero_crop; mod hero_crop;
mod import; mod import;
mod hero_detector; mod hero_detector;
@@ -11,6 +14,7 @@ mod lyric_prep;
mod lyrics; mod lyrics;
mod pipeline; mod pipeline;
mod playback; mod playback;
mod profile;
mod playlist_formats; mod playlist_formats;
mod playlists; mod playlists;
mod provider_playlist; mod provider_playlist;
@@ -57,6 +61,10 @@ pub fn run() {
preview::init(app.handle()); preview::init(app.handle());
loudness::init(app.handle()); loudness::init(app.handle());
diagnostics::init(app.handle()); diagnostics::init(app.handle());
// The experimental Freenet layer: applies the saved opt-in (off
// by default) and forwards adapter status to the webview. Fully
// isolated — its absence or failure touches nothing else.
freenet::init(app.handle());
if let Err(e) = pipeline::init(app.handle()) { if let Err(e) = pipeline::init(app.handle()) {
log::error!("library init failed: {e}"); log::error!("library init failed: {e}");
} }
@@ -215,6 +223,26 @@ pub fn run() {
commands::preload_cached_stream, commands::preload_cached_stream,
commands::stream_waveform, commands::stream_waveform,
commands::stream_cache_ready, commands::stream_cache_ready,
freenet::freenet_status,
freenet::freenet_set_enabled,
freenet::freenet_detect,
freenet::freenet_set_load_remote_images,
freenet::freenet_node_info,
freenet::freenet_node_start,
freenet::freenet_node_stop,
community::contacts_meta_list,
community::contact_meta_set,
community::community_pick_file,
persona::persona_status,
persona::persona_create,
persona::persona_export,
persona::persona_import,
persona::persona_rotate,
persona::persona_remove,
profile::profile_get,
profile::profile_publish,
profile::profile_set_avatar,
profile::profile_clear_avatar,
preview::preview_resolve, preview::preview_resolve,
preview::preview_prewarm, preview::preview_prewarm,
preview::preview_load, preview::preview_load,
+4
View File
@@ -13,6 +13,7 @@
import { previewUi } from "./lib/preview/previewSession.svelte.js"; import { previewUi } from "./lib/preview/previewSession.svelte.js";
import Home from "./views/Home.svelte"; import Home from "./views/Home.svelte";
import Soulseek from "./views/Soulseek.svelte"; import Soulseek from "./views/Soulseek.svelte";
import Community from "./views/Community.svelte";
import Library from "./views/Library.svelte"; import Library from "./views/Library.svelte";
import Settings from "./views/Settings.svelte"; import Settings from "./views/Settings.svelte";
import Playlist from "./views/Playlist.svelte"; import Playlist from "./views/Playlist.svelte";
@@ -50,6 +51,7 @@
soulseek: Soulseek, soulseek: Soulseek,
search: Soulseek, search: Soulseek,
downloads: Soulseek, downloads: Soulseek,
community: Community,
library: Library, library: Library,
settings: Settings, settings: Settings,
playlist: Playlist, playlist: Playlist,
@@ -89,6 +91,8 @@
"soulseek", "soulseek",
"search", "search",
"downloads", "downloads",
// Community is the hub's sibling: same "start BELOW the bar" shape.
"community",
// The Library follows the discography's exception: its blurred // The Library follows the discography's exception: its blurred
// ambience pins behind the glass and must begin at the very top of // ambience pins behind the glass and must begin at the very top of
// the pane, so the page carries its own top inset instead of taking // the pane, so the page carries its own top inset instead of taking
+1 -1
View File
@@ -31,7 +31,7 @@
// The Soulseek hub carries Qt's connection status in the bar's right — // The Soulseek hub carries Qt's connection status in the bar's right —
// "online as X · sharing N files" — the way the Qt titlebar did. // "online as X · sharing N files" — the way the Qt titlebar did.
const HUB_VIEWS = new Set(["soulseek", "search", "downloads"]); const HUB_VIEWS = new Set(["soulseek", "search", "downloads", "community"]);
const showConn = $derived(HUB_VIEWS.has(ui.view)); const showConn = $derived(HUB_VIEWS.has(ui.view));
import { canGoBack, canGoForward, trail } from "../lib/navigation.svelte.js"; import { canGoBack, canGoForward, trail } from "../lib/navigation.svelte.js";
import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js"; import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js";
+16
View File
@@ -38,6 +38,22 @@
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>', trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
expand: '<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>', expand: '<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>',
shrink: '<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/>', shrink: '<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/>',
// The Community area's glyphs (2026-09-02).
hash: '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>',
inbox: '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
compass: '<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
sparkle: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>',
message: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
send: '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
more: '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
devices: '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
"chevron-left": '<path d="m15 18-6-6 6-6"/>', "chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>', "chevron-right": '<path d="m9 18 6-6-6-6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>', "chevron-down": '<path d="m6 9 6 6 6-6"/>',
+133
View File
@@ -0,0 +1,133 @@
<script module>
function h32(s) {
let h = 0x811c9dc5;
for (const c of String(s)) {
h ^= c.charCodeAt(0);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
function rng(seed) {
let x = seed || 7;
return () => {
x ^= x << 13;
x >>>= 0;
x ^= x >>> 17;
x ^= x << 5;
x >>>= 0;
return x / 4294967296;
};
}
function arc(cx, cy, r, a0, a1) {
const p = (a) => [cx + r * Math.cos((a * Math.PI) / 180), cy + r * Math.sin((a * Math.PI) / 180)];
const [x0, y0] = p(a0);
const [x1, y1] = p(a1);
return `M${x0.toFixed(2)} ${y0.toFixed(2)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
}
/** The hue this seed paints with — the persona ambience borrows it. */
export function hueOf(seed) {
return h32(seed) % 360;
}
</script>
<script>
// The SoulStream ID made visible: a record whose grooves are cut from
// the key. Deterministic — the same seed draws the same face on every
// machine, so a person is recognisable without ever uploading anything.
// Approved on the Community mockup (research/community-mockups,
// 2026-09-02, "Grooves"). A verified avatar photo replaces the grooves;
// the presence dot is Soulseek's only — Freenet never says "online".
let {
seed = "",
size = 40,
/** A verified, sanitized avatar as a data URL — replaces the grooves. */
photo = null,
/** "online" | "away" | "off" | null (no dot at all). */
presence = null,
/** Accent ring — the identity strip's own face. */
ring = false,
/** Rounded square instead of a disc (Spaces, rooms). */
square = false,
} = $props();
const svg = $derived.by(() => {
const r = rng(h32(seed));
const hue = h32(seed) % 360;
const hue2 = (hue + 150 + Math.floor(r() * 60)) % 360;
const c1 = `hsl(${hue} 64% 60%)`;
const c2 = `hsl(${hue2} 58% 64%)`;
const bg = `hsl(${hue} 34% 13%)`;
const bg2 = `hsl(${hue} 30% 20%)`;
let inner = "";
[11, 16, 21, 26].forEach((rad, i) => {
const segs = 6 + i * 2;
const gap = 8;
for (let s = 0; s < segs; s++) {
if (r() < 0.38) continue;
const a0 = (s * 360) / segs + gap / 2;
const a1 = ((s + 1) * 360) / segs - gap / 2;
inner += `<path d="${arc(32, 32, rad, a0, a1)}" stroke="${r() > 0.72 ? c2 : c1}" stroke-width="3.6" fill="none" stroke-linecap="round"/>`;
}
});
return `<svg viewBox="0 0 64 64" aria-hidden="true"><rect width="64" height="64" fill="${bg}"/><circle cx="32" cy="32" r="30.5" fill="${bg2}"/>${inner}<circle cx="32" cy="32" r="5.2" fill="${bg}"/><circle cx="32" cy="32" r="1.6" fill="${c1}"/></svg>`;
});
</script>
<span class="av" class:ring class:square style="width:{size}px;height:{size}px">
{#if photo}
<img src={photo} alt="" draggable="false" />
{:else}
{@html svg}
{/if}
{#if presence}
<span class="pd {presence}" style="--d:{Math.max(8, Math.round(size * 0.27))}px"></span>
{/if}
</span>
<style>
.av {
position: relative;
flex: none;
display: inline-block;
border-radius: 50%;
vertical-align: middle;
}
.av.square {
border-radius: 22%;
}
.av :global(svg),
.av img {
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
display: block;
}
.av img {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.av.ring {
box-shadow:
0 0 0 2px var(--bg-base),
0 0 0 3.5px var(--accent);
}
/* The dot sits on the pane, so its cut-out ring is the pane's own base
colour; under glass that is a solid dark ring on the frost — read as a
socket, which is fine. */
.pd {
position: absolute;
right: -1px;
bottom: -1px;
width: var(--d);
height: var(--d);
border-radius: 50%;
background: #3ddc84;
box-shadow: 0 0 0 2.5px var(--bg-base);
}
.pd.away {
background: #e8a33d;
}
.pd.off {
background: rgba(255, 255, 255, 0.28);
}
</style>
+1
View File
@@ -191,6 +191,7 @@
const items = [ const items = [
{ id: "home", label: "Home", icon: "home" }, { id: "home", label: "Home", icon: "home" },
{ id: "soulseek", label: "Soulseek", icon: "globe" }, { id: "soulseek", label: "Soulseek", icon: "globe" },
{ id: "community", label: "Community", icon: "users" },
{ id: "library", label: "Your Library", icon: "library" }, { id: "library", label: "Your Library", icon: "library" },
{ id: "settings", label: "Settings", icon: "settings" }, { id: "settings", label: "Settings", icon: "settings" },
{ id: "playlists", label: "Playlists", icon: "playlist" }, { id: "playlists", label: "Playlists", icon: "playlist" },
+24
View File
@@ -488,3 +488,27 @@ export function onEvent(name, handler) {
const un = listen(name, (e) => handler(e.payload)).catch(() => () => {}); const un = listen(name, (e) => handler(e.payload)).catch(() => () => {});
return () => un.then((f) => f()); return () => un.then((f) => f());
} }
// ---- the Community area: Freenet layer, persona, profile, local contacts ----
export const freenetStatus = () => invoke("freenet_status");
export const freenetSetEnabled = (enabled) => invoke("freenet_set_enabled", { enabled });
export const freenetDetect = () => invoke("freenet_detect");
export const freenetSetLoadRemoteImages = (on) => invoke("freenet_set_load_remote_images", { on });
export const freenetNodeInfo = () => invoke("freenet_node_info");
export const freenetNodeStart = () => invoke("freenet_node_start");
export const freenetNodeStop = () => invoke("freenet_node_stop");
export const personaStatus = () => invoke("persona_status");
export const personaCreate = () => invoke("persona_create");
export const personaExport = (passphrase, path) => invoke("persona_export", { passphrase, path });
export const personaImport = (passphrase, path, force = false) =>
invoke("persona_import", { passphrase, path, force });
export const personaRotate = () => invoke("persona_rotate");
export const personaRemove = () => invoke("persona_remove");
export const profileGet = (persona = null, force = false) => invoke("profile_get", { persona, force });
export const profilePublish = (displayName, bio) => invoke("profile_publish", { displayName, bio });
export const profileSetAvatar = (imagePath) => invoke("profile_set_avatar", { imagePath });
export const profileClearAvatar = () => invoke("profile_clear_avatar");
export const contactsMetaList = () => invoke("contacts_meta_list");
export const contactMetaSet = (meta) => invoke("contact_meta_set", { meta });
/** kind: "image" | "recovery-open" | "recovery-save" -> a path, or null. */
export const communityPickFile = (kind) => invoke("community_pick_file", { kind });
+301
View File
@@ -0,0 +1,301 @@
// The Community area's own store: the Freenet layer's status, the persona,
// the published profile, the local contact metadata and the two device-local
// memories (last seen, favourite rooms). Everything Soulseek-live — chats,
// rooms, buddies — stays in state.svelte.js's `slsk` and is only READ here.
//
// Two words the labels below are careful about, from the design's truthful
// list (FREENET-SUPPLEMENT-DESIGN §10.3): a successful fetch is "synced as of
// <time>", never "online"; and the adapter's states are named for what they
// are (node stopped, incompatible, connecting), never softened.
import * as api from "./api.js";
const LS_SEEN = "community.lastSeen";
const LS_FAVS = "community.roomFavs";
const LS_SECTION = "community.section";
function readJson(key, fallback) {
try {
const v = JSON.parse(localStorage.getItem(key) ?? "null");
return v ?? fallback;
} catch {
return fallback;
}
}
function writeJson(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// storage full or unavailable — a lost memory, never a failure
}
}
export const SECTIONS = ["inbox", "rooms", "people", "discover", "spaces", "you"];
export const community = $state({
section: SECTIONS.includes(readJson(LS_SECTION, "")) ? readJson(LS_SECTION, "") : "inbox",
/** freenet_status — null until first asked. */
freenet: null,
/** freenet_node_info — the installed peer, if any, and whether we run it. */
nodeInfo: null,
/** persona_status — null until first asked. */
persona: null,
/** profile_get for our own persona — null when none published. */
profile: null,
/** ms of the last successful profile read; what "synced as of" shows. */
profileAt: null,
profileError: "",
/** username → ContactMeta (petname, group, note, trusted, favorite). */
contacts: {},
/** username → ms this device last saw them online or away. */
lastSeen: readJson(LS_SEEN, {}),
/** Favourite room names, device-local. */
roomFavs: readJson(LS_FAVS, []),
/** Which person the drawer shows, or null. */
drawer: null,
/** key → true while an action runs (buttons disable on it). */
busy: {},
/** The last action's error, shown where it happened. */
error: "",
});
export function setSection(s) {
if (!SECTIONS.includes(s)) return;
community.section = s;
writeJson(LS_SECTION, s);
}
/** The adapter's status on the wire: `{kind: "connected"}` — serde's
* internally tagged, kebab-case form (detail-carrying variants add
* `detail`). Older shapes (a bare string) are folded in for safety. */
export function statusKind(status) {
if (!status) return "";
if (typeof status === "string") return status.toLowerCase();
return String(status.kind ?? Object.keys(status)[0] ?? "").toLowerCase();
}
/** Human words for an AdapterStatus, from the design's truthful list. */
export function adapterLabel(status) {
switch (statusKind(status)) {
case "disabled":
return { text: "local only", tone: "off" };
case "detecting":
return { text: "connecting", tone: "acc" };
case "nodeabsent":
case "node-absent":
return { text: "node not installed or stopped", tone: "warn" };
case "nodeoccupied":
case "node-occupied":
return { text: "port in use by something else", tone: "warn" };
case "nodeincompatible":
case "node-incompatible":
return { text: "incompatible node", tone: "err" };
case "connected":
return { text: "connected", tone: "ok" };
case "lost":
return { text: "connection lost · retrying", tone: "warn" };
default:
return { text: "unknown", tone: "off" };
}
}
export const adapterConnected = (status) => statusKind(status) === "connected";
async function run(key, fn) {
community.busy[key] = true;
community.error = "";
try {
return await fn();
} catch (e) {
community.error = String(e?.message ?? e);
throw e;
} finally {
delete community.busy[key];
}
}
// ---- the Freenet layer ----------------------------------------------------
export async function refreshFreenet() {
try {
community.freenet = await api.freenetStatus();
} catch (e) {
community.error = String(e);
}
await refreshNodeInfo();
}
export async function refreshNodeInfo() {
try {
community.nodeInfo = await api.freenetNodeInfo();
} catch {
// no node info is not an error state — the card shows what it has
}
}
export const startNode = () =>
run("node", async () => {
community.nodeInfo = await api.freenetNodeStart();
});
export const stopNode = () =>
run("node", async () => {
community.nodeInfo = await api.freenetNodeStop();
});
export const setFreenetEnabled = (on) =>
run("freenet", async () => {
community.freenet = await api.freenetSetEnabled(on);
if (!on) {
community.persona = null;
community.profile = null;
community.profileAt = null;
}
});
export const setLoadRemoteImages = (on) =>
run("images", async () => {
community.freenet = await api.freenetSetLoadRemoteImages(on);
});
export const detectNode = () =>
run("detect", async () => {
const status = await api.freenetDetect();
if (community.freenet) community.freenet.status = status;
return status;
});
// ---- persona ----------------------------------------------------------------
export async function refreshPersona() {
try {
community.persona = await api.personaStatus();
} catch (e) {
community.error = String(e);
return;
}
if (community.persona?.persona) await loadProfile();
else {
community.profile = null;
community.profileAt = null;
}
}
export const personaKey = () => community.persona?.persona?.public_key ?? null;
export const createPersona = () =>
run("persona", async () => {
await api.personaCreate();
await refreshPersona();
});
export const exportPersona = (passphrase) =>
run("export", async () => {
const path = await api.communityPickFile("recovery-save");
if (!path) return null;
const bytes = await api.personaExport(passphrase, path);
return { path, bytes };
});
export const importPersona = (passphrase, force = false) =>
run("import", async () => {
const path = await api.communityPickFile("recovery-open");
if (!path) return null;
const info = await api.personaImport(passphrase, path, force);
await refreshPersona();
return info;
});
export const rotatePersona = () =>
run("rotate", async () => {
const info = await api.personaRotate();
await refreshPersona();
return info;
});
export const removePersona = () =>
run("remove", async () => {
await api.personaRemove();
await refreshPersona();
});
// ---- profile ----------------------------------------------------------------
/** Our own profile: instant from the device's ledger; `force` asks the
* network, which can take a minute when nothing is published. */
export async function loadProfile(force = false) {
community.profileError = "";
community.busy.profile = true;
try {
community.profile = await api.profileGet(null, force);
community.profileAt = Date.now();
} catch (e) {
community.profileError = String(e);
} finally {
delete community.busy.profile;
}
}
export const publishProfile = (displayName, bio) =>
run("publish", async () => {
community.profile = await api.profilePublish(displayName, bio);
community.profileAt = Date.now();
});
export const pickAndSetAvatar = () =>
run("avatar", async () => {
const path = await api.communityPickFile("image");
if (!path) return false;
community.profile = await api.profileSetAvatar(path);
community.profileAt = Date.now();
return true;
});
export const clearAvatar = () =>
run("avatar", async () => {
community.profile = await api.profileClearAvatar();
community.profileAt = Date.now();
});
// ---- local contact metadata --------------------------------------------------
function indexContacts(list) {
community.contacts = Object.fromEntries(list.map((c) => [c.username, c]));
}
export async function loadContacts() {
try {
indexContacts(await api.contactsMetaList());
} catch (e) {
community.error = String(e);
}
}
export const metaOf = (username) =>
community.contacts[username] ?? {
username,
petname: null,
group: null,
note: null,
trusted: false,
favorite: false,
};
export const setContactMeta = (meta) =>
run("meta:" + meta.username, async () => {
indexContacts(await api.contactMetaSet(meta));
});
// ---- device-local memories ------------------------------------------------------
let seenFlush = 0;
export function noteSeen(username) {
community.lastSeen[username] = Date.now();
clearTimeout(seenFlush);
seenFlush = setTimeout(() => writeJson(LS_SEEN, community.lastSeen), 1500);
}
export function toggleRoomFav(name) {
const i = community.roomFavs.indexOf(name);
if (i >= 0) community.roomFavs.splice(i, 1);
else community.roomFavs.push(name);
writeJson(LS_FAVS, community.roomFavs);
}
// ---- boot -------------------------------------------------------------------------
let booted = false;
/** Idempotent: the first Community mount wires the listeners and pulls the
* initial state; later mounts only refresh. */
export async function initCommunity() {
if (!booted) {
booted = true;
api.onEvent("freenet:status", (status) => {
if (community.freenet) community.freenet.status = status;
else void refreshFreenet();
void refreshNodeInfo();
// A fresh connection may carry a persona we have not read yet;
// a lost one invalidates what we had.
if (adapterConnected(status)) void refreshPersona();
else if (community.persona) community.persona.available = false;
});
api.onEvent("soulseek:user-state", (p) => {
if (p.status === 1 || p.status === 2) noteSeen(p.username);
});
}
await Promise.all([refreshFreenet(), loadContacts()]);
await refreshPersona();
}
+1
View File
@@ -55,6 +55,7 @@ function entryKey(e) {
* back to — "Search" as a bare destination is a box you have already left. */ * back to — "Search" as a bare destination is a box you have already left. */
const ROOTS = new Set([ const ROOTS = new Set([
"home", "home",
"community",
"search", "search",
"library", "library",
"downloads", "downloads",
+175 -49
View File
@@ -1722,6 +1722,7 @@ export function goToView(view, opts = {}) {
const VIEW_LABELS = { const VIEW_LABELS = {
home: "Home", home: "Home",
soulseek: "Soulseek", soulseek: "Soulseek",
community: "Community",
search: "Search", search: "Search",
library: "Your Library", library: "Your Library",
downloads: "Downloads", downloads: "Downloads",
@@ -1733,6 +1734,7 @@ const VIEW_LABELS = {
const VIEW_GLYPHS = { const VIEW_GLYPHS = {
home: "home", home: "home",
soulseek: "globe", soulseek: "globe",
community: "users",
search: "search", search: "search",
library: "library", library: "library",
downloads: "download", downloads: "download",
@@ -5447,27 +5449,68 @@ export async function saveCatalogAlbum(album) {
return true; return true;
} }
export const search = $state({ /* --------------------------------------------------------------- searching
token: null, * Qt keeps every search alive in its own tab, and so do we: `searchTabs` is
query: "", * the strip, each entry a self-contained session (its own token, groups and
searching: false, * raw file map). `search` below is the ACTIVE session's face — the whole UI
responses: 0, * reads through it, so a tab switch is a one-line assignment here rather than
// key: `${username}|${folder}` -> group summary (scalars only — the file * a rewrite there. */
// lists live outside reactive state, see searchFiles below). export const SEARCH_TABS_MAX = 8;
groups: {},
export const searchTabs = $state({
/** Newest last. Each: {id, token, query, searching, responses, groups} */
list: [],
activeId: null,
/** What searchNetworkFor is about to look for, shown by the box in the
* gap before the first tab exists (and if login interrupts). */
seeded: "",
}); });
// Raw per-group file lists. Deliberately NOT reactive: a broad search stores let searchSeq = 0;
// up to 300 × 500 file objects, and wrapping each in a $state proxy is what // Raw per-group file lists, per session. Deliberately NOT reactive: a broad
// used to freeze the webview (GC storm at ~1GB+). Components re-render off // search stores up to 300 × 500 file objects, and wrapping each in a $state
// the group's reactive fileCount and read the list through filesForGroup(). // proxy is what used to freeze the webview (GC storm at ~1GB+). Components
const searchFiles = new Map(); // re-render off the group's reactive fileCount and read the list through
let searchStopTimer = null; // filesForGroup(). Keyed by session id so closing a tab frees the whole map.
let searchRequest = 0; const searchFilesById = new Map();
// Session id -> its 45s routing-close timer.
const searchStopTimers = new Map();
const EMPTY_GROUPS = Object.freeze({});
function trimHiddenSearchFiles() { function sessionById(id) {
return searchTabs.list.find((s) => s.id === id) ?? null;
}
function activeSession() {
return sessionById(searchTabs.activeId);
}
function filesOf(id) {
let m = searchFilesById.get(id);
if (!m) searchFilesById.set(id, (m = new Map()));
return m;
}
/** The active session, as the single object every existing reader expects. */
export const search = {
get token() { return activeSession()?.token ?? null; },
set token(v) { const s = activeSession(); if (s) s.token = v; },
get query() { return activeSession()?.query ?? searchTabs.seeded; },
// Seed ONLY. searchNetworkFor assigns this before it calls runSearch, and
// a write-through would rename the live tab — which runSearch would then
// find by query and re-run in place, wiping the results the user was
// looking at. A tab's query is its identity; only runSearch sets it.
set query(v) { searchTabs.seeded = v; },
get searching() { return activeSession()?.searching ?? false; },
set searching(v) { const s = activeSession(); if (s) s.searching = v; },
get responses() { return activeSession()?.responses ?? 0; },
set responses(v) { const s = activeSession(); if (s) s.responses = v; },
get groups() { return activeSession()?.groups ?? EMPTY_GROUPS; },
set groups(v) { const s = activeSession(); if (s) s.groups = v; },
};
function trimHiddenSearchFiles(session) {
if (!session) return;
const visibleKeys = new Set( const visibleKeys = new Set(
Object.values(search.groups) Object.values(session.groups)
.sort( .sort(
(a, b) => (a, b) =>
(b.hasFreeSlot - a.hasFreeSlot) (b.hasFreeSlot - a.hasFreeSlot)
@@ -5476,13 +5519,15 @@ function trimHiddenSearchFiles() {
.slice(0, 120) .slice(0, 120)
.map((group) => group.key), .map((group) => group.key),
); );
for (const key of searchFiles.keys()) { const files = filesOf(session.id);
if (!visibleKeys.has(key)) searchFiles.delete(key); for (const key of files.keys()) {
if (!visibleKeys.has(key)) files.delete(key);
} }
} }
export function filesForGroup(key) { export function filesForGroup(key) {
return searchFiles.get(key)?.files ?? []; // Read-only: never creates a map (a null activeId would leave one behind).
return searchFilesById.get(searchTabs.activeId)?.get(key)?.files ?? [];
} }
// Mirrors crates/audio-formats: everything recognised as audio, playable or // Mirrors crates/audio-formats: everything recognised as audio, playable or
@@ -5530,6 +5575,9 @@ export const slsk = $state({
buddies: [], buddies: [],
/** username → { status?, avg_speed?, file_count?, dir_count?, exists? }. */ /** username → { status?, avg_speed?, file_count?, dir_count?, exists? }. */
buddyState: {}, buddyState: {},
/** A username another area (Community) wants browsed in the hub's Browse
* tab; the hub consumes and clears it on arrival. */
browseRequest: null,
}); });
const SLSK_FEED_MAX = 500; const SLSK_FEED_MAX = 500;
@@ -6243,20 +6291,25 @@ export function initEvents() {
// The backend batches search responses (~150ms ticks) so a broad search // The backend batches search responses (~150ms ticks) so a broad search
// can't flood the IPC bridge or trigger a reactive flush per response. // can't flood the IPC bridge or trigger a reactive flush per response.
api.onEvent("soulseek:search-results", (batch) => { api.onEvent("soulseek:search-results", (batch) => {
let matched = 0;
for (const r of batch) { for (const r of batch) {
if (!search.searching || r.token !== search.token) continue; // Every tab is live at once; the token says whose results these
matched += 1; // are. A background tab fills in exactly like the visible one.
const sess = searchTabs.list.find(
(s) => s.searching && s.token != null && s.token === r.token,
);
if (!sess) continue;
sess.responses += 1;
const searchFiles = filesOf(sess.id);
for (const f of r.files) { for (const f of r.files) {
const key = `${r.username}|${f.folder}`; const key = `${r.username}|${f.folder}`;
let g = search.groups[key]; let g = sess.groups[key];
let raw = searchFiles.get(key); let raw = searchFiles.get(key);
if (!g) { if (!g) {
// Cap group count so a popular search can't flood the DOM. // Cap group count so a popular search can't flood the DOM.
if (searchFiles.size >= 300) continue; if (searchFiles.size >= 300) continue;
raw = { files: [], exts: new Map() }; raw = { files: [], exts: new Map() };
searchFiles.set(key, raw); searchFiles.set(key, raw);
g = search.groups[key] = { g = sess.groups[key] = {
key, key,
username: r.username, username: r.username,
folder: f.folder, folder: f.folder,
@@ -6290,7 +6343,6 @@ export function initEvents() {
} }
} }
} }
if (matched > 0) search.responses += matched;
}); });
} }
@@ -7315,39 +7367,111 @@ if (import.meta.env.DEV && typeof window !== "undefined") {
}; };
} }
export async function runSearch(query) { /** Bring a tab forward. The whole Search UI reads through `search`, so this
const request = ++searchRequest; * assignment IS the switch. */
clearTimeout(searchStopTimer); export function selectSearchTab(id) {
const previousToken = search.token; if (sessionById(id)) searchTabs.activeId = id;
// Invalidate routing before awaiting the new backend token, otherwise
// late batches from the previous search can populate the freshly-cleared
// result set during this gap.
search.token = null;
if (previousToken != null) {
api.stopSearch(previousToken).catch(() => {});
} }
search.query = query;
search.groups = {}; /** Close one tab: stop its search on the wire and free its file map. */
searchFiles.clear(); export function closeSearchTab(id) {
search.responses = 0; const i = searchTabs.list.findIndex((s) => s.id === id);
search.searching = true; if (i < 0) return;
const [sess] = searchTabs.list.splice(i, 1);
clearTimeout(searchStopTimers.get(id));
searchStopTimers.delete(id);
searchFilesById.delete(id);
if (sess.token != null) api.stopSearch(sess.token).catch(() => {});
if (searchTabs.activeId === id) {
// Prefer the neighbour to the left — Qt's behaviour, and it keeps a
// rapid close-close-close from jumping across the strip.
const next = searchTabs.list[Math.max(0, i - 1)];
searchTabs.activeId = next ? next.id : null;
}
}
/** Retire the oldest tab that is no longer live, so the strip stays bounded
* without ever killing a search that is still trickling in. */
function evictOldestSearchTab() {
if (searchTabs.list.length < SEARCH_TABS_MAX) return;
const victim =
searchTabs.list.find((s) => !s.searching && s.id !== searchTabs.activeId)
?? searchTabs.list.find((s) => s.id !== searchTabs.activeId)
?? searchTabs.list[0];
if (victim) closeSearchTab(victim.id);
}
export async function runSearch(query) {
// Re-running a query that already owns a tab focuses it instead of
// opening a duplicate — Qt does the same, and it keeps the strip honest.
const existing = searchTabs.list.find((s) => s.query === query);
if (existing) {
searchTabs.activeId = existing.id;
if (existing.searching || existing.token != null) return;
// A closed-out tab re-runs in place, keeping its position.
existing.groups = {};
filesOf(existing.id).clear();
existing.responses = 0;
existing.searching = true;
try {
await armSearch(existing, query);
} catch (err) {
// Keep the tab — it still holds the previous run's results.
existing.searching = false;
throw err;
}
return;
}
evictOldestSearchTab();
searchTabs.list.push({
id: ++searchSeq,
token: null,
query,
searching: true,
responses: 0,
// key: `${username}|${folder}` -> group summary (scalars only — the
// file lists live outside reactive state, see searchFilesById).
groups: {},
});
// Read the entry back out: THIS is the reactive proxy the store holds, and
// the one every later write (token, searching) has to land on.
const sess = searchTabs.list[searchTabs.list.length - 1];
searchTabs.activeId = sess.id;
searchTabs.seeded = "";
try {
await armSearch(sess, query);
} catch (err) {
// The wire refused (offline, mid-reconnect). A brand-new tab holds
// nothing worth keeping, and a tab stuck "live" forever is a lie.
closeSearchTab(sess.id);
throw err;
}
}
/** Ask the wire for a token and hold this session's 45s routing window. */
async function armSearch(sess, query) {
const token = await api.startSearch(query); const token = await api.startSearch(query);
if (request !== searchRequest) { if (!sessionById(sess.id)) {
// Closed while we awaited the token — nothing left to route into.
api.stopSearch(token).catch(() => {}); api.stopSearch(token).catch(() => {});
return; return;
} }
search.token = token; sess.token = token;
// Soulseek searches trickle in; stop routing after 45s. // Soulseek searches trickle in; stop routing after 45s.
searchStopTimer = setTimeout(() => { clearTimeout(searchStopTimers.get(sess.id));
if (search.token === token) { searchStopTimers.set(
search.searching = false; sess.id,
setTimeout(() => {
searchStopTimers.delete(sess.id);
if (sess.token !== token) return;
sess.searching = false;
// The UI exposes only the top 120 groups. Keep their full file // The UI exposes only the top 120 groups. Keep their full file
// lists and discard invisible raw payloads after routing closes; // lists and discard invisible raw payloads after routing closes;
// scalar summaries remain available for the result count. // scalar summaries remain available for the result count.
trimHiddenSearchFiles(); trimHiddenSearchFiles(sess);
api.stopSearch(token).catch(() => {}); api.stopSearch(token).catch(() => {});
} }, 45_000),
}, 45_000); );
} }
// Dev-only: expose the LIVE module instance for the CDP verification loop. // Dev-only: expose the LIVE module instance for the CDP verification loop.
@@ -7655,5 +7779,7 @@ if (import.meta.env.DEV) {
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs, autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
playlistStore, refreshPlaylists, goToView, peekNextIndices, playlistStore, refreshPlaylists, goToView, peekNextIndices,
slsk, search, runSearch, uploadToast, announceUpload, slsk, search, runSearch, uploadToast, announceUpload,
searchTabs, selectSearchTab, closeSearchTab, filesForGroup,
downloads, network, sendDm, openChat,
}; };
} }
File diff suppressed because it is too large. Load diff
+124 -17
View File
@@ -17,6 +17,9 @@
ui, ui,
app, app,
search, search,
searchTabs,
selectSearchTab,
closeSearchTab,
runSearch, runSearch,
downloadFile, downloadFile,
downloadFolder, downloadFolder,
@@ -91,10 +94,15 @@
default: return { cls: "off", label: d.state + "…" }; default: return { cls: "off", label: d.state + "…" };
} }
} }
function splitPath(name) { function splitPath(path, name) {
// Qt shows "folder\file"; our items carry the bare name — the dir // Qt shows "folder\file". The remote path is backslash separated and
// column simply stays empty until items carry their remote folder. // usually deep (@@share\Music\Artist\Album [FLAC]\05 track.flac); only
return { dir: "", file: name }; // the immediate parent earns column width — the file name is what the
// row is FOR, so the dir must never be allowed to ellipsize it away.
// The full path stays reachable in the cell's title.
const parts = String(path ?? "").split("\\").filter(Boolean);
const file = name ?? parts[parts.length - 1] ?? "";
return { dir: parts.length > 1 ? parts[parts.length - 2] + "\\" : "", file };
} }
function clearComplete() { function clearComplete() {
for (const d of dlFinished) delete downloads.items[d.id]; for (const d of dlFinished) delete downloads.items[d.id];
@@ -171,6 +179,13 @@
app.showLogin = true; app.showLogin = true;
} }
} }
/** Bring another live search forward. Folder expansions are per-tab —
* they key off username|folder, which means nothing in the new tab. */
function pickTab(id) {
if (id === searchTabs.activeId) return;
selectSearchTab(id);
expanded = {};
}
// ---- browse ------------------------------------------------------- // ---- browse -------------------------------------------------------
let browseName = $state(""); let browseName = $state("");
@@ -215,6 +230,14 @@
tab = "browse"; tab = "browse";
void runBrowse(); void runBrowse();
} }
// The Community area hands a username over through the store and
// navigates here; consume it once, on arrival.
$effect(() => {
const u = slsk.browseRequest;
if (!u) return;
slsk.browseRequest = null;
untrack(() => browseFrom(u));
});
/** The short tail of a backslashed remote folder, for display. */ /** The short tail of a backslashed remote folder, for display. */
const dirTail = (name) => name.split("\\").filter(Boolean).slice(-2).join("\\"); const dirTail = (name) => name.split("\\").filter(Boolean).slice(-2).join("\\");
@@ -290,6 +313,24 @@
} }
} }
const buddyRows = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] ?? {} }))); const buddyRows = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] ?? {} })));
/** Qt's Users "Queue" column: where WE stand in that user's upload line.
* That is exactly what a queued download from them already reports, so
* the best (lowest) place across our transfers with them is the answer —
* no per-buddy PlaceInQueue poll needed. Offline users show nothing;
* online with nothing queued is a genuine 0. */
function queuePlace(username, st) {
// Only online/away (1/2) can hold a line. Offline — or a status we
// simply haven't been told yet — shows nothing rather than a
// confident zero.
if (st.status !== 1 && st.status !== 2) return "—";
let best = null;
for (const d of Object.values(downloads.items)) {
if (d.username !== username || d.state !== "queued") continue;
if (d.queue_place == null) continue;
if (best == null || d.queue_place < best) best = d.queue_place;
}
return best ?? 0;
}
function statusChip(st) { function statusChip(st) {
if (st.exists === false) return { cls: "err", label: "unknown" }; if (st.exists === false) return { cls: "err", label: "unknown" };
if (st.status === 2) return { cls: "ok", label: "online" }; if (st.status === 2) return { cls: "ok", label: "online" };
@@ -362,9 +403,12 @@
</div> </div>
{#each dlItems as d (d.id)} {#each dlItems as d (d.id)}
{@const chip = stateChip(d)} {@const chip = stateChip(d)}
{@const fp = splitPath(d.path, d.name)}
<div class="tgrid rowh"> <div class="tgrid rowh">
<span class="u"><span class="avatar"></span>{d.username}</span> <span class="u"><span class="avatar"></span>{d.username}</span>
<span class="f" title={d.name}>{splitPath(d.name).file}</span> <span class="f" title={d.path || d.name}
>{#if fp.dir}<span class="dir">{fp.dir}</span>{/if}{fp.file}</span
>
<span class="prog" class:done={d.state === "complete"}> <span class="prog" class:done={d.state === "complete"}>
<i style="width: {d.state === 'transferring' ? pct(d) : d.state === 'complete' ? 100 : 0}%"></i> <i style="width: {d.state === 'transferring' ? pct(d) : d.state === 'complete' ? 100 : 0}%"></i>
</span> </span>
@@ -405,9 +449,12 @@
<span>ETA</span><span>Speed</span><span>Size</span><span>Place</span> <span>ETA</span><span>Speed</span><span>Size</span><span>Place</span>
</div> </div>
{#each uploads as u (u.id)} {#each uploads as u (u.id)}
{@const fp = splitPath(u.path, u.name)}
<div class="tgrid rowh"> <div class="tgrid rowh">
<span class="u"><span class="avatar"></span>{u.username}</span> <span class="u"><span class="avatar"></span>{u.username}</span>
<span class="f" title={u.name}>{u.name}</span> <span class="f" title={u.path || u.name}
>{#if fp.dir}<span class="dir">{fp.dir}</span>{/if}{fp.file}</span
>
<span class="prog"><i style="width: {u.total > 0 ? (u.sent / u.total) * 100 : 0}%"></i></span> <span class="prog"><i style="width: {u.total > 0 ? (u.sent / u.total) * 100 : 0}%"></i></span>
<span class="chipwrap"><span class="chip {u.state === 'complete' ? 'ok' : u.state === 'failed' ? 'err' : 'up'}">{u.state === "transferring" ? "uploading" : u.state}</span></span> <span class="chipwrap"><span class="chip {u.state === 'complete' ? 'ok' : u.state === 'failed' ? 'err' : 'up'}">{u.state === "transferring" ? "uploading" : u.state}</span></span>
<span class="num">—</span><span class="num">—</span> <span class="num">—</span><span class="num">—</span>
@@ -444,7 +491,7 @@
<button type="button" class="fchip" class:on={hideQueued} onclick={() => (hideQueued = !hideQueued)}>Hide queued</button> <button type="button" class="fchip" class:on={hideQueued} onclick={() => (hideQueued = !hideQueued)}>Hide queued</button>
</form> </form>
{#if search.token == null} {#if searchTabs.list.length === 0}
<div class="empty"> <div class="empty">
<p class="big">Search the Soulseek network</p> <p class="big">Search the Soulseek network</p>
<p class="sub"> <p class="sub">
@@ -454,12 +501,29 @@
</p> </p>
</div> </div>
{:else} {:else}
<!-- Every search keeps its own tab and keeps filling in the
background, Qt-style. The count on the ACTIVE tab is what the
filters left on screen; a background tab shows its own total. -->
<div class="stabs"> <div class="stabs">
<span class="stab on"> {#each searchTabs.list as s (s.id)}
{#if search.searching}<span class="live"></span>{/if} {@const on = s.id === searchTabs.activeId}
{search.query} <span class="stab" class:on>
<span class="scount">{filtered.length}</span> <button class="stabpick" onclick={() => pickTab(s.id)} title={s.query}>
{#if s.searching}<span class="live"></span>{/if}
<span class="stabq">{s.query}</span>
<span class="scount">
{on ? filtered.length : Object.keys(s.groups).length}
</span> </span>
</button>
<button
class="stabx"
title="Close this search"
onclick={() => closeSearchTab(s.id)}
>
<Icon name="x" size={11} />
</button>
</span>
{/each}
</div> </div>
<div class="sgrid thead"> <div class="sgrid thead">
<span>User / folder / file</span><span>Speed</span><span>Queue</span> <span>User / folder / file</span><span>Speed</span><span>Queue</span>
@@ -732,7 +796,7 @@
</div> </div>
{:else} {:else}
<div class="ugrid thead"> <div class="ugrid thead">
<span>Buddy</span><span>Status</span><span>Shared</span><span>Speed</span><span></span> <span>Buddy</span><span>Status</span><span>Shared</span><span>Speed</span><span>Queue</span><span></span>
</div> </div>
{#each buddyRows as row (row.u)} {#each buddyRows as row (row.u)}
{@const chip = statusChip(row.st)} {@const chip = statusChip(row.st)}
@@ -744,6 +808,7 @@
<span><span class="chip {chip.cls}">{chip.label}</span></span> <span><span class="chip {chip.cls}">{chip.label}</span></span>
<span class="num">{row.st.file_count != null ? `${row.st.file_count.toLocaleString()} files` : "—"}</span> <span class="num">{row.st.file_count != null ? `${row.st.file_count.toLocaleString()} files` : "—"}</span>
<span class="num">{row.st.avg_speed ? fmtSpeed(row.st.avg_speed) : "—"}</span> <span class="num">{row.st.avg_speed ? fmtSpeed(row.st.avg_speed) : "—"}</span>
<span class="num">{queuePlace(row.u, row.st)}</span>
<span class="uacts"> <span class="uacts">
<button class="toolbtn" onclick={() => browseFrom(row.u)}>Browse</button> <button class="toolbtn" onclick={() => browseFrom(row.u)}>Browse</button>
<button class="toolbtn" onclick={() => chatFrom(row.u)}>Chat</button> <button class="toolbtn" onclick={() => chatFrom(row.u)}>Chat</button>
@@ -945,6 +1010,9 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Qt's "folder\file": the parent folder leads in muted tone, the file
name carries the row. */
.tgrid .f .dir { color: var(--text-muted); }
.tgrid .chipwrap { justify-self: end; } .tgrid .chipwrap { justify-self: end; }
.prog { .prog {
position: relative; position: relative;
@@ -1016,19 +1084,56 @@
background: var(--accent-soft); background: var(--accent-soft);
color: var(--accent-hover); color: var(--accent-hover);
} }
.stabs { display: flex; gap: 4px; margin-bottom: 8px; } .stabs { display: flex; gap: 4px; margin-bottom: 8px; flex-wrap: wrap; }
/* One pill per live search. Same on/off vocabulary as .subtab: the active
tab wears the surface, the rest sit bare and quiet. */
.stab { .stab {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 7px;
height: 28px; height: 28px;
padding: 0 12px; padding: 0 4px 0 0;
border-radius: 8px; border-radius: 8px;
font-size: 11.5px; font-size: 11.5px;
font-weight: 800; font-weight: 800;
color: var(--text-secondary);
max-width: 240px;
}
.stab.on {
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-highlight); background: var(--bg-highlight);
} }
.stabpick {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
height: 28px;
padding: 0 7px 0 12px;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.stabq { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.stabx {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--text-muted);
cursor: pointer;
flex: none;
}
.stabx:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.stab .live { .stab .live {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -1415,7 +1520,9 @@
gap: 0 10px; gap: 0 10px;
align-items: center; align-items: center;
padding: 0 10px; padding: 0 10px;
grid-template-columns: minmax(160px, 1fr) 90px 110px 90px 200px; /* Mock's columns exactly, with a wider action cell — we carry three
buttons (Browse / Chat / remove) where the mock drew two. */
grid-template-columns: minmax(160px, 1fr) 90px 110px 90px 90px 200px;
} }
.ugrid > span:nth-child(n + 2) { text-align: right; } .ugrid > span:nth-child(n + 2) { text-align: right; }
.ugrid .c1 { .ugrid .c1 {
@@ -1466,7 +1573,7 @@
background: var(--glass-well); background: var(--glass-well);
box-shadow: var(--glass-ring); box-shadow: var(--glass-ring);
} }
:global(:root.glass) .stab, :global(:root.glass) .stab.on,
:global(:root.glass) .chip.off, :global(:root.glass) .chip.off,
:global(:root.glass) .dlall.queued { :global(:root.glass) .dlall.queued {
background: var(--glass-surface); background: var(--glass-surface);