give the soulseek workspace a url

The section is /soulseek/:section — nav entries are NavLinks, the view panel reads the same
URL instead of being told, and the dashboard's tiles and recent searches are real links (a
recent search now opens that search, not the search screen's front page).

The peer went in `?user=<name>` rather than the /soulseek/users/:name the audit sketched: a
second path segment would need a nested route just to keep the nav highlight, and `?search=`
had already set the convention there. That deletes the `soulseek:user` channel and with it a
`{username, nonce}` request the Users panel consumed-once and cleared — the nonce existed so
asking for the same peer twice counted twice. A link is idempotent, so there is nothing to
consume and nothing to disambiguate.

`soulseek:refresh` stays: it is a signal, which is what channels are for.
This commit is contained in:
2026-08-07 11:45:33 +00:00
parent 00332e275a
commit fd923bb9be
12 changed files with 149 additions and 114 deletions
+9 -10
View File
@@ -36,7 +36,7 @@ That fix is the template for the HIGH items below. **Caveat:** the fix only did
## Route map (from `App.tsx`)
**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/projects/:id`, `/email/:emailId`.
**Flat screens:** `/` `/files` `/music` `/soulseek` `/tasks` `/skills` `/processes` `/activity`
**Flat screens:** `/` `/files` `/music` `/tasks` `/skills` `/processes` `/activity`
`/system-monitor` `/task-logs` `/plans` `/terminal` `/desktop` `/browser` `/code-editor`.
The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}`
plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list
@@ -87,12 +87,13 @@ is **one design decision** that cascades across many files:
- **Music** — `Music/{MusicBrowser:182,209 · MusicDetail:138,174,234 · FavoritesView:85,107}` hold the entire
library location in `usePanelChannel('music:cwd')`; every drill-in is `setCwd(path)`. No URL, no deep-link to
an album. → back `music:cwd` with `/music?path=<rel>`; rows become `<Link>`s.
- **Soulseek** — an entire workspace with **no URL at all**: `soulseek:section` nav (`SoulseekNav:40`,
`SoulseekDashboard:88/96/110/121`), `soulseek:user` peer channel (`UserMenu:32`, `SoulseekUsers:42`), and
per-panel `useState` for past-search (`SearchView:160`, a textbook `role="button"` div with `s.id` in the
closure), rooms (`SoulseekRooms:153`), conversations (`SoulseekChat:135`). → decide a nested tree
`/soulseek/:section` + `/soulseek/{users/:name, search/:id, rooms/:name, chat/:name}`. **S1 (the section nav)
decides S2S7.**
- ~~**Soulseek**~~**mostly done.** The section is `/soulseek/:section` (nav entries are `NavLink`s, the
view panel reads the same URL), the peer is `?user=<name>`, and the past-search was already `?search=<id>`.
The `soulseek:section` and `soulseek:user` channels are deleted; `soulseek:refresh` stays, which is a
signal and not selection. The peer went in the query string rather than `/soulseek/users/:name` as
sketched here, because the section nav has to stay a one-segment `NavLink` — a second path segment would
need a nested route just to keep the highlight, and the query string is what `?search=` already uses.
Still on `useState`: rooms (`SoulseekRooms`) and conversations (`SoulseekChat`).
### 🟡 LOW / borderline
@@ -120,8 +121,6 @@ This is the primary surface to convert to URL-driven selection.
| `SELECTED_PROJECT` (`useGlobal`) | selected project | `/projects/:id` | `ProjectListApp.tsx:45`, `ProjectPreview.tsx:386` (H3) |
| `EMAIL_SELECTED` | open email | `/email/:emailId` | `EmailList.tsx:336`, `EmailScreen.tsx` sync (H5) |
| `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` |
| `soulseek:section` | active Soulseek section | `/soulseek/:section` | `SoulseekNav/View/Dashboard/UserMenu` |
| `soulseek:user` | selected peer | `/soulseek/users/:name` | `UserMenu.tsx:28`, `SoulseekUsers.tsx:34` |
| local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` |
**Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`,
@@ -201,7 +200,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
### Phase 3 — Whole-workspace routing decisions (needs a design call first)
- [ ] **Music** — decide `/music?path=` (or nested); back `music:cwd` with the URL; rows → `<Link>` (M-music).
- [ ] **Soulseek**decide the nested tree `/soulseek/:section` + `/soulseek/{users,search,rooms,chat}/:x`; **S1 decides S2S7** (M-slsk).
- [x] **Soulseek**`/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.**
- [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
- [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`).
+10 -2
View File
@@ -646,8 +646,16 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
key now, so there is nothing left to disagree with.
**SystemMonitor is done too** (audit M10) — `/system-monitor/:scope`, scope list as `NavLink`s,
`monitor:scope` deleted. Smallest of the seven and the same shape as `/photos`.
Remaining here: Music, Soulseek, Email, Browser. **Chat detail (`chat:selected-session`) is
chat-owned — leave it; it is written up in `COMMS/`.**
**Soulseek is done** — `/soulseek/:section`, and the peer moved to `?user=<name>` rather than the
`/soulseek/users/:name` the audit sketched: a second path segment would have needed a nested route
just to keep the nav's `NavLink` highlight, and `?search=` was already establishing the convention
for that workspace. Deleting `soulseek:user` also deleted the nonce it carried — a request stamped
`{username, nonce}`, consumed-once by the Users panel so that returning to the section did not
re-run the lookup, and nonce-stamped so that asking for the *same* peer twice still counted twice.
A link needs none of that: it is idempotent, so there is nothing to consume and nothing to
disambiguate. That is the shape of most of this section's remaining work.
Remaining here: Music, Email, Browser. **Chat detail (`chat:selected-session`) is chat-owned —
leave it; it is written up in `COMMS/`.**
### 5.9 The context has grown an app-config section — _(found 2026-08-07)_
+5 -4
View File
@@ -126,10 +126,11 @@ connection status and the header renders it. Panel-scoped by construction, which
### The thirteen channels, honestly
13 channels, 26 call sites. Six carry selection that should be in the URL`music:cwd` (the entire
library location, so no album is linkable), `soulseek:section` and `soulseek:user` (the whole Soulseek
workspace has no URL), `chat:selected-session`, `monitor:scope`. Two are view toggles where a channel is
genuinely fine (`music:favorites`, `music:lyrics`).
Was 13 channels, 26 call sites. Six carried selection that belongs in the URL, and three of those are
gone: `soulseek:section` and `soulseek:user` (the whole Soulseek workspace now has one) and
`monitor:scope`. Still to move: `music:cwd` — the entire library location, so no album is linkable — and
`chat:selected-session`, which is the chat's to move, not this document's. Two are view toggles where a
channel is genuinely fine (`music:favorites`, `music:lyrics`).
Two are wired to nobody: `preview:refresh` and `chat:active-session` have publishers and no subscribers.
A third, `file-viewer:<panelId>`, had a subscriber and no publisher — it is gone, along with the
+1
View File
@@ -59,6 +59,7 @@ export function App() {
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
@@ -1,17 +1,22 @@
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulseekSection } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /soulseek uses the Workspace/Panel system (like /chat and /music): a section nav (soulseek-nav) on
// the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section'
// channel. Both talk to slskd through the /api/slskd auth proxy.
// /soulseek uses the Workspace/Panel system (like /photos): a section nav (soulseek-nav) on the left and
// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection
// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy.
export const SoulseekScreen = () => {
const { section } = useParams();
// v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted
// layout doesn't resurrect as two mismatched panels.
const workspace = useDashboardState<LayoutNode>('screens/soulseek-v2', defaultLayout);
// After the hooks — it returns early.
if (!isSoulseekSection(section)) return <Navigate to={soulseekSectionPath(DEFAULT_SOULSEEK_SECTION)} replace />;
return (
<div className="h-full w-full pt-2">
<WorkspaceView
@@ -1,21 +1,22 @@
import { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { ArrowDownToLine, ArrowUpFromLine, Search, RefreshCw, Radio } from 'lucide-react';
import {
SOULSEEK_SECTION_CHANNEL,
SEARCH_PARAM,
formatWhen,
soulseekSectionPath,
transferPhase,
type SlskdApplication,
type SlskdSearchSummary,
type SlskdTransferUser,
type SoulseekSectionId,
type TransferPhase,
} from './shared';
// Dashboard — the at-a-glance overview for the /soulseek workspace: connection health, live
// download/upload tallies, and the most recent searches. Read-only aggregation over the same slskd
// endpoints the other panels use; tiles publish to the section channel so clicking one jumps the view.
// endpoints the other panels use; every tile and row is a real link into the section it summarises, and
// a recent search links to that search rather than to the search screen's front page.
const POLL_MS = 2000;
@@ -29,7 +30,6 @@ const tally = (users: SlskdTransferUser[]): Tally => {
export const SoulseekDashboard = () => {
const client = useClient();
const [, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
const [app, setApp] = useState<SlskdApplication | null>(null);
const [downloads, setDownloads] = useState<Tally>(emptyTally);
const [uploads, setUploads] = useState<Tally>(emptyTally);
@@ -85,7 +85,7 @@ export const SoulseekDashboard = () => {
</div>
{/* Downloads */}
<Section title="Downloads" icon={<ArrowDownToLine className="h-4 w-4" />} onClick={() => setSection('downloads')}>
<Section title="Downloads" icon={<ArrowDownToLine className="h-4 w-4" />} to={soulseekSectionPath('downloads')}>
<Tile label="Downloading" value={downloads.downloading} dot="bg-blue-500" />
<Tile label="Queued" value={downloads.queued} dot="bg-amber-500" />
<Tile label="Completed" value={downloads.done} dot="bg-green-500" />
@@ -93,7 +93,7 @@ export const SoulseekDashboard = () => {
</Section>
{/* Uploads */}
<Section title="Uploads" icon={<ArrowUpFromLine className="h-4 w-4" />} onClick={() => setSection('uploads')}>
<Section title="Uploads" icon={<ArrowUpFromLine className="h-4 w-4" />} to={soulseekSectionPath('uploads')}>
<Tile label="Uploading" value={uploads.downloading} dot="bg-blue-500" />
<Tile label="Queued" value={uploads.queued} dot="bg-amber-500" />
<Tile label="Completed" value={uploads.done} dot="bg-green-500" />
@@ -105,29 +105,27 @@ export const SoulseekDashboard = () => {
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2.5">
<Search className="h-4 w-4 text-zinc-400" />
<span className="text-sm font-medium text-zinc-100">Recent searches</span>
<button
type="button"
onClick={() => setSection('search')}
<Link
to={soulseekSectionPath('search')}
className="ml-auto text-xs text-zinc-500 transition hover:text-zinc-200"
>
View all
</button>
</Link>
</div>
{recent.length === 0 ? (
<p className="px-4 py-3 text-sm text-zinc-500">No searches yet.</p>
) : (
<div className="divide-y divide-white/5">
{recent.map((s) => (
<button
<Link
key={s.id}
type="button"
onClick={() => setSection('search')}
to={`${soulseekSectionPath('search')}?${SEARCH_PARAM}=${s.id}`}
className="flex w-full items-center gap-3 px-4 py-2 text-left transition hover:bg-white/5"
>
<span className="min-w-0 flex-1 truncate text-sm text-zinc-200">{s.searchText}</span>
<span className="shrink-0 text-xs tabular-nums text-zinc-500">{s.responseCount} responses</span>
<span className="shrink-0 text-xs text-zinc-600">{formatWhen(s.startedAt)}</span>
</button>
</Link>
))}
</div>
)}
@@ -137,19 +135,18 @@ export const SoulseekDashboard = () => {
);
};
type SectionProps = { title: string; icon: React.ReactNode; onClick: () => void; children: React.ReactNode };
type SectionProps = { title: string; icon: React.ReactNode; to: string; children: React.ReactNode };
const Section = ({ title, icon, onClick, children }: SectionProps) => (
const Section = ({ title, icon, to, children }: SectionProps) => (
<div className="rounded-xl border border-white/10 bg-zinc-950">
<button
type="button"
onClick={onClick}
<Link
to={to}
className="flex w-full items-center gap-2 border-b border-white/10 px-4 py-2.5 text-left transition hover:bg-white/5"
>
<span className="text-zinc-400">{icon}</span>
<span className="text-sm font-medium text-zinc-100">{title}</span>
<RefreshCw className="ml-auto h-3 w-3 text-zinc-600" />
</button>
</Link>
<div className="grid grid-cols-2 gap-2 p-3 sm:grid-cols-4">{children}</div>
</div>
);
@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { NavLink } from 'react-router';
import {
LayoutGrid,
Search,
@@ -11,10 +11,11 @@ import {
Server,
Plug,
} from 'lucide-react';
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
import { SOULSEEK_SECTIONS, soulseekSectionPath, type SoulseekSectionId } from './shared';
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Publishes
// the active section to the 'soulseek:section' channel; SoulseekView (right) renders the matching UI.
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Entries are
// react-router NavLinks to /soulseek/<section>, so active state comes from the router and SoulseekView
// (right) learns which section is open by reading the same URL rather than by being told.
const ICONS: Record<SoulseekSectionId, LucideIcon> = {
dashboard: LayoutGrid,
@@ -29,8 +30,6 @@ const ICONS: Record<SoulseekSectionId, LucideIcon> = {
};
export const SoulseekNav = () => {
const [section, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
@@ -46,26 +45,30 @@ export const SoulseekNav = () => {
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{SOULSEEK_SECTIONS.map(({ id, label }) => {
const Icon = ICONS[id];
const active = section === id;
return (
<button
<NavLink
key={id}
type="button"
onClick={() => setSection(id)}
className={`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
to={soulseekSectionPath(id)}
className={({ isActive }) =>
`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{active && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</>
)}
<Icon
className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</button>
</NavLink>
);
})}
</nav>
@@ -1,19 +1,20 @@
import { useState, useEffect } from 'react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useSearchParams } from 'react-router';
import { toast } from 'sonner';
import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react';
import { useSoulseekFavorites } from './useSoulseekFavorites';
import { useSoulseekUser } from './useSoulseekUser';
import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse';
import { SharesBrowser, BrowseStateChip } from './SharesBrowser';
import { SOULSEEK_USER_CHANNEL, type SoulseekUserRequest } from './shared';
import { USER_PARAM } from './shared';
// Users panel — look up a peer: their presence (online/away/offline) and profile info (description,
// upload slots, queue), both cheap live calls (GET /users/{u}/status + /info). Their shared folders come
// from Officer's own cache instead, via <SharesBrowser> — see that file for why it isn't browsed live.
//
// This is also where the workspace's peer actions land: the username dropdown in search results and
// downloads publishes to 'soulseek:user', which we consume once (clearing it) to look the peer up.
// downloads links here with `?user=<name>`, which is the whole mechanism — a peer profile is now
// linkable and survives a reload.
// Favourites — Officer's own data, since slskd has no such concept — get their own section at the top,
// which also serves as this panel's landing content before any lookup.
@@ -29,9 +30,9 @@ const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof C
};
export const SoulseekUsers = () => {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState('');
const [request, setRequest] = usePanelChannel<SoulseekUserRequest | null>(SOULSEEK_USER_CHANNEL, null);
const [params, setParams] = useSearchParams();
const selected = params.get(USER_PARAM)?.trim() ?? '';
const [query, setQuery] = useState(selected);
const { favorites, isFavorite, toggle } = useSoulseekFavorites();
const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots();
@@ -39,23 +40,22 @@ export const SoulseekUsers = () => {
const user = useSoulseekUser(selected);
const peer = user.data ?? null;
// Navigating, not setting state: the peer is `?user=`. Shares are still NOT fetched automatically —
// that's a multi-minute server-side job, so it stays an explicit click in the shares card.
const select = (name: string) => {
const username = name.trim();
if (!username) return;
setQuery(username);
setSelected(username);
setParams((prev) => {
const next = new URLSearchParams(prev);
next.set(USER_PARAM, username);
return next;
});
};
// Consume a peer request from the username dropdown. Cleared as it's handled, so switching away and
// back doesn't re-run the lookup. Shares are NOT fetched automatically — that's a multi-minute
// server-side job, so it stays an explicit click in the shares card.
// Arriving by link (or Back) should show the name in the box that produced it.
useEffect(() => {
if (!request) return;
setRequest(null);
select(request.username);
// select is recreated each render; the effect deliberately runs only when a request arrives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [request]);
if (selected) setQuery(selected);
}, [selected]);
useEffect(() => {
if (user.error) toast.error(user.error.message);
@@ -1,6 +1,5 @@
import { Construction } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
import { SOULSEEK_SECTIONS, useSoulseekSection, type SoulseekSectionId } from './shared';
import { SearchView } from './SearchView';
import { SoulseekTransfers } from './SoulseekTransfers';
import { SoulseekUploads } from './SoulseekUploads';
@@ -12,7 +11,7 @@ import { SoulseekSystem } from './SoulseekSystem';
import { SoulseekConnection } from './SoulseekConnection';
import { useServiceConnection } from '../../hooks/useServiceConnection';
// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. Content
// Right panel of the /soulseek workspace — renders the UI for the section the URL names. Content
// zoom used to live here; it is now a framework-level per-panel control in PanelSlot.
const Placeholder = ({ id }: { id: SoulseekSectionId }) => {
@@ -56,7 +55,7 @@ const sectionView = (section: SoulseekSectionId) => {
};
export const SoulseekView = () => {
const [section] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
const section = useSoulseekSection();
const { data: connection, isLoading } = useServiceConnection('slskd');
// Nothing connected yet: the setup form takes over every section, because none of them can do anything
@@ -1,4 +1,4 @@
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Link } from 'react-router';
import { toast } from 'sonner';
import { FolderOpen, Star, StarOff } from 'lucide-react';
import {
@@ -10,30 +10,19 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { useSoulseekFavorites } from './useSoulseekFavorites';
import {
SOULSEEK_SECTION_CHANNEL,
SOULSEEK_USER_CHANNEL,
type SoulseekSectionId,
type SoulseekUserRequest,
} from './shared';
import { soulseekUserPath } from './shared';
// The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace
// and act on that peer. Opening a peer hands the username to the Users section over a panel channel
// (which owns the lookup and the cached share tree); favouriting goes to the sidecar's favourites route.
// and act on that peer. Opening a peer is a real link to /soulseek/users?user=<name> — the Users section
// still owns the lookup and the cached share tree, it just reads who from the URL. Favouriting is a
// mutation, so it stays a menu item that acts rather than navigates.
type UserMenuProps = { username: string };
export const UserMenu = ({ username }: UserMenuProps) => {
const [, setSection] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
const [, setRequest] = usePanelChannel<SoulseekUserRequest | null>(SOULSEEK_USER_CHANNEL, null);
const { isFavorite, toggle } = useSoulseekFavorites();
const favorited = isFavorite(username);
const openPeer = () => {
setRequest({ username, nonce: Date.now() });
setSection('users');
};
const toggleFavorite = () => {
toggle(username);
toast.success(favorited ? `Removed ${username} from favorites` : `Added ${username} to favorites`);
@@ -54,9 +43,11 @@ export const UserMenu = ({ username }: UserMenuProps) => {
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="truncate">{username}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={openPeer}>
<FolderOpen className="mr-2 h-4 w-4" />
View profile &amp; shares
<DropdownMenuItem asChild>
<Link to={soulseekUserPath(username)}>
<FolderOpen className="mr-2 h-4 w-4" />
View profile &amp; shares
</Link>
</DropdownMenuItem>
<DropdownMenuItem onSelect={toggleFavorite}>
{favorited ? <StarOff className="mr-2 h-4 w-4" /> : <Star className="mr-2 h-4 w-4" />}
@@ -1,5 +1,8 @@
// Shared types/helpers for the /soulseek workspace panels (SoulseekSearch + SoulseekTransfers), which
// talk to slskd through the /api/slskd auth proxy and coordinate over a single panel channel.
// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/slskd
// auth proxy. What is open — the section, the peer, the search — is the URL; the one channel left here
// is a refresh signal, which is what channels are for.
import { useParams } from 'react-router';
// Bumped (to a fresh nonce) whenever a download is enqueued from the search panel, so the transfers
// panel refetches immediately instead of waiting for its next poll tick.
@@ -235,9 +238,8 @@ export type SlskdRoomMessage = {
export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] };
export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean };
// The nav panel (SoulseekNav) publishes the active section here; the view panel (SoulseekView) reads
// it and renders the matching UI. Mirrors slskd's own top-menu sections.
export const SOULSEEK_SECTION_CHANNEL = 'soulseek:section';
// Which section is open is `/soulseek/:section` — the nav panel and the view panel both read the URL and
// neither publishes to the other. Mirrors slskd's own top-menu sections.
export type SoulseekSectionId =
| 'dashboard'
| 'search'
@@ -260,14 +262,34 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
{ id: 'connection', label: 'Connection' },
];
// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users
// section. Nonce-stamped and consumed-once (the Users panel clears it) so revisiting the section doesn't
// re-run the lookup, while two requests for the SAME username still each trigger a fresh one.
export const SOULSEEK_USER_CHANNEL = 'soulseek:user';
export type SoulseekUserRequest = { username: string; nonce: number };
/** Where /soulseek lands, and where an unrecognised section redirects to. */
export const DEFAULT_SOULSEEK_SECTION: SoulseekSectionId = 'dashboard';
export const isSoulseekSection = (value: string | undefined): value is SoulseekSectionId =>
SOULSEEK_SECTIONS.some((s) => s.id === value);
export const soulseekSectionPath = (section: SoulseekSectionId) => `/soulseek/${section}`;
// SoulseekScreen redirects anything unrecognised, so the fallback here only covers the instant before
// that lands.
export const useSoulseekSection = (): SoulseekSectionId => {
const { section } = useParams();
return isSoulseekSection(section) ? section : DEFAULT_SOULSEEK_SECTION;
};
// Which peer the Users section is showing — `/soulseek/users?user=<name>`.
//
// This was a `soulseek:user` channel carrying `{username, nonce}`: the username dropdown published a
// request, the Users panel consumed it once and cleared it, and the nonce existed so that asking for the
// SAME peer twice still counted as two requests. All of that machinery was a workaround for the peer not
// being in the URL — a link is idempotent, so there is nothing to consume and no nonce to disambiguate.
export const USER_PARAM = 'user';
export const soulseekUserPath = (username: string) =>
`${soulseekSectionPath('users')}?${USER_PARAM}=${encodeURIComponent(username)}`;
// A past search, as listed by GET /searches (no responses inlined).
// Which search's results are open — `/soulseek?search=<id>`, read from the URL rather than held in a
// Which search's results are open — `/soulseek/search?search=<id>`, read from the URL rather than held in a
// panel channel or local state. See docs/navigation-audit.md.
export const SEARCH_PARAM = 'search';
+9
View File
@@ -43,6 +43,15 @@ export type { PhotosSectionId } from './apps/Photos/shared';
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
export type { JellyfinSectionId } from './apps/Jellyfin/shared';
// Same for /soulseek, which also puts the open peer in `?user=`.
export {
DEFAULT_SOULSEEK_SECTION,
soulseekSectionPath,
soulseekUserPath,
isSoulseekSection,
} from './apps/Soulseek/shared';
export type { SoulseekSectionId } from './apps/Soulseek/shared';
// Same for /system-monitor, where the param is the scope rather than a section.
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
export type { MonitorScope } from './apps/SystemMonitor/shared';