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
+2368 -68

No files matched your search

+175 -49
View File
@@ -1722,6 +1722,7 @@ export function goToView(view, opts = {}) {
const VIEW_LABELS = {
home: "Home",
soulseek: "Soulseek",
community: "Community",
search: "Search",
library: "Your Library",
downloads: "Downloads",
@@ -1733,6 +1734,7 @@ const VIEW_LABELS = {
const VIEW_GLYPHS = {
home: "home",
soulseek: "globe",
community: "users",
search: "search",
library: "library",
downloads: "download",
@@ -5447,27 +5449,68 @@ export async function saveCatalogAlbum(album) {
return true;
}
export const search = $state({
token: null,
query: "",
searching: false,
responses: 0,
// key: `${username}|${folder}` -> group summary (scalars only — the file
// lists live outside reactive state, see searchFiles below).
groups: {},
/* --------------------------------------------------------------- searching
* Qt keeps every search alive in its own tab, and so do we: `searchTabs` is
* the strip, each entry a self-contained session (its own token, groups and
* raw file map). `search` below is the ACTIVE session's face — the whole UI
* reads through it, so a tab switch is a one-line assignment here rather than
* a rewrite there. */
export const SEARCH_TABS_MAX = 8;
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
// up to 300 × 500 file objects, and wrapping each in a $state proxy is what
// used to freeze the webview (GC storm at ~1GB+). Components re-render off
// the group's reactive fileCount and read the list through filesForGroup().
const searchFiles = new Map();
let searchStopTimer = null;
let searchRequest = 0;
let searchSeq = 0;
// Raw per-group file lists, per session. Deliberately NOT reactive: a broad
// search stores up to 300 × 500 file objects, and wrapping each in a $state
// proxy is what used to freeze the webview (GC storm at ~1GB+). Components
// re-render off the group's reactive fileCount and read the list through
// filesForGroup(). Keyed by session id so closing a tab frees the whole map.
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(
Object.values(search.groups)
Object.values(session.groups)
.sort(
(a, b) =>
(b.hasFreeSlot - a.hasFreeSlot)
@@ -5476,13 +5519,15 @@ function trimHiddenSearchFiles() {
.slice(0, 120)
.map((group) => group.key),
);
for (const key of searchFiles.keys()) {
if (!visibleKeys.has(key)) searchFiles.delete(key);
const files = filesOf(session.id);
for (const key of files.keys()) {
if (!visibleKeys.has(key)) files.delete(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
@@ -5530,6 +5575,9 @@ export const slsk = $state({
buddies: [],
/** username → { status?, avg_speed?, file_count?, dir_count?, exists? }. */
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;
@@ -6243,20 +6291,25 @@ export function initEvents() {
// The backend batches search responses (~150ms ticks) so a broad search
// can't flood the IPC bridge or trigger a reactive flush per response.
api.onEvent("soulseek:search-results", (batch) => {
let matched = 0;
for (const r of batch) {
if (!search.searching || r.token !== search.token) continue;
matched += 1;
// Every tab is live at once; the token says whose results these
// 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) {
const key = `${r.username}|${f.folder}`;
let g = search.groups[key];
let g = sess.groups[key];
let raw = searchFiles.get(key);
if (!g) {
// Cap group count so a popular search can't flood the DOM.
if (searchFiles.size >= 300) continue;
raw = { files: [], exts: new Map() };
searchFiles.set(key, raw);
g = search.groups[key] = {
g = sess.groups[key] = {
key,
username: r.username,
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) {
const request = ++searchRequest;
clearTimeout(searchStopTimer);
const previousToken = search.token;
// 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(() => {});
/** Bring a tab forward. The whole Search UI reads through `search`, so this
* assignment IS the switch. */
export function selectSearchTab(id) {
if (sessionById(id)) searchTabs.activeId = id;
}
/** Close one tab: stop its search on the wire and free its file map. */
export function closeSearchTab(id) {
const i = searchTabs.list.findIndex((s) => s.id === id);
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;
}
search.query = query;
search.groups = {};
searchFiles.clear();
search.responses = 0;
search.searching = true;
}
/** 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);
if (request !== searchRequest) {
if (!sessionById(sess.id)) {
// Closed while we awaited the token — nothing left to route into.
api.stopSearch(token).catch(() => {});
return;
}
search.token = token;
sess.token = token;
// Soulseek searches trickle in; stop routing after 45s.
searchStopTimer = setTimeout(() => {
if (search.token === token) {
search.searching = false;
clearTimeout(searchStopTimers.get(sess.id));
searchStopTimers.set(
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
// lists and discard invisible raw payloads after routing closes;
// scalar summaries remain available for the result count.
trimHiddenSearchFiles();
trimHiddenSearchFiles(sess);
api.stopSearch(token).catch(() => {});
}
}, 45_000);
}, 45_000),
);
}
// 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,
playlistStore, refreshPlaylists, goToView, peekNextIndices,
slsk, search, runSearch, uploadToast, announceUpload,
searchTabs, selectSearchTab, closeSearchTab, filesForGroup,
downloads, network, sendDm, openChat,
};
}