);
};
// Hidden files/folders (dotfiles like .claude, .git) never belong in the library listing.
const visibleDirs = (r: LsResult) =>
r.entries
.filter((e) => e.type === 'directory' && !e.name.startsWith('.'))
.map((e) => e.name)
.sort();
// Left panel of the /music workspace — a single-column drill-down LIST navigator (libraries →
// artists → albums as list items; never a grid). Every row is a link to `/music?path=…`; MusicDetail
// (right panel) reads the same param and renders the rich detail (covers/grids/tracklist).
export const MusicBrowser = () => {
const { get, post, token } = useClient();
const cwd = useMusicCwd();
const [favOpen, setFavOpen] = usePanelChannel(MUSIC_FAV_CHANNEL, false);
const [resync, setResync] = usePanelChannel(MUSIC_RESYNC_CHANNEL, 0);
const [manifest, setManifest] = useState>({});
const [libraries, setLibraries] = useState([]);
const [folders, setFolders] = useState([]);
const [query, setQuery] = useState('');
const [reindexing, setReindexing] = useState(false);
// Refetch manifest + libraries on mount and whenever a reindex bumps the resync nonce.
useEffect(() => {
get('/music/manifest')
.then((m) => setManifest(m.albums))
.catch(() => setManifest({}));
get(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
.then((r) => setLibraries(visibleDirs(r)))
.catch(() => setLibraries([]));
}, [resync]);
// The container folder whose children we list = the current folder, or its parent when the current
// path is an album leaf (so its siblings stay listed while the right shows the tracklist).
const rel = toRel(cwd);
const isAlbum = (manifest[rel]?.tracks ?? 0) > 0;
const navFolder = !cwd ? null : isAlbum ? cwd.split('/').slice(0, -1).join('/') : cwd;
const selected = cwd ? cwd.split('/').pop() : null;
useEffect(() => {
if (!navFolder) {
setFolders([]);
return;
}
let cancelled = false;
get(`/file-browser/ls?path=${encodeURIComponent(navFolder)}`)
.then((r) => {
if (!cancelled) setFolders(visibleDirs(r));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [navFolder, resync]);
// Start each folder unfiltered.
useEffect(() => setQuery(''), [navFolder]);
// Trigger a server-side library rebuild, then bump the resync nonce so BOTH panels refetch their
// manifest / listings / meta (a fresh Date.now() value guarantees the effects re-run).
const reindex = async () => {
if (reindexing) return;
setReindexing(true);
try {
await post('/music/reindex');
setResync(Date.now());
} finally {
setReindexing(false);
}
};
const crumbs = navFolder ? navFolder.slice(MUSIC_ROOT.length + 1).split('/') : [];
const coverFor = (childRel: string) => (manifest[childRel]?.cover ? coverUrl(childRel, token) : null);
const shownLibraries = libraries.filter((l) => fuzzyMatch(query, l));
const shownFolders = folders.filter((f) => fuzzyMatch(query, f));
return (
{/* Favorites is a view of this panel, not a location, so it stays a channel — but going home
has to close it explicitly: the route doesn't change when you are already at the root. */}
setFavOpen(false)}
className="flex flex-1 cursor-pointer items-center gap-2 px-2 text-left text-foreground"
>
Music