[READ-ONLY] Mirror of https://github.com/mrgnw/dlna-rs. Single-binary DLNA media server in Rust. Auto-discovered by VLC on Apple TV.
0

Configure Feed

Select the types of activity you want to include in your feed.

plan: browse view implementation

+884
+884
docs/superpowers/plans/2026-05-11-browse-view.md
··· 1 + # Browse view + folder list redesign — implementation plan 2 + 3 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. Repo enforces TABS for indentation via `.editorconfig` + `rustfmt.toml` — match. 4 + 5 + **Goal:** Add a per-folder browse view (filename / path sorts, collapsible subdirs), and redesign the folder list row to make the folder name the primary navigation target. 6 + 7 + **Architecture:** Extend `Library` to collect `Vec<FileMeta>` during the existing walkdir pass (no extra IO). Add `GET /api/files?path=…`. Split the web UI into `FolderList` + `BrowseView` driven by a top-level `view` state in `App.svelte`. 8 + 9 + **Tech Stack:** Rust (axum 0.8, walkdir, anyhow), Svelte 5 (runes), bun build. 10 + 11 + **Spec:** `docs/superpowers/specs/2026-05-11-browse-view-design.md` 12 + 13 + **Branch:** continue on `dev`. 14 + 15 + --- 16 + 17 + ## Files 18 + 19 + Backend: 20 + - `src/library.rs` — add `FileMeta`, extend `Folder.files`, replace `count_files` with `walk_files`. 21 + - `src/api.rs` — add `files` handler + route. Update `FolderView` to keep file list off the folders endpoint (don't include `files`). 22 + - `tests/library.rs` — extend existing tests. 23 + - `tests/api.rs` — add `/api/files` tests. 24 + 25 + Frontend: 26 + - `web/src/format.ts` (new) — `formatSize(bytes)`. 27 + - `web/src/api.ts` — add `listFiles(folder)`. 28 + - `web/src/FolderList.svelte` (new) — extracted from App.svelte. Redesigned row. 29 + - `web/src/BrowseView.svelte` (new) — sort + list. 30 + - `web/src/App.svelte` — top-level view switch. 31 + 32 + --- 33 + 34 + ## Task 1: Extend Library with file metadata 35 + 36 + **Files:** 37 + - Modify: `src/library.rs` 38 + - Modify: `tests/library.rs` 39 + 40 + - [ ] **Step 1: Update `tests/library.rs` — add failing test alongside existing ones** 41 + 42 + Append to `tests/library.rs`: 43 + 44 + ```rust 45 + #[test] 46 + fn add_folder_populates_files_with_relative_paths_and_sizes() { 47 + let dir = tempdir().unwrap(); 48 + make_tree(dir.path(), &["a.mp4", "sub/b.mp4", "sub/deep/c.mp4"]); 49 + std::fs::write(dir.path().join("a.mp4"), b"hello").unwrap(); 50 + 51 + let mut lib = Library::new(); 52 + let f = lib.add_folder(dir.path().to_path_buf(), true).unwrap(); 53 + 54 + assert_eq!(f.files.len(), 3); 55 + let rels: Vec<String> = f.files.iter().map(|m| m.rel.to_string_lossy().into_owned()).collect(); 56 + assert!(rels.contains(&"a.mp4".to_string())); 57 + assert!(rels.contains(&"sub/b.mp4".to_string()) || rels.contains(&"sub\\b.mp4".to_string())); 58 + let a = f.files.iter().find(|m| m.rel.to_string_lossy() == "a.mp4").unwrap(); 59 + assert_eq!(a.size, 5); 60 + } 61 + 62 + #[test] 63 + fn files_are_sorted_by_relative_path() { 64 + let dir = tempdir().unwrap(); 65 + make_tree(dir.path(), &["z.mp4", "a.mp4", "m.mp4"]); 66 + 67 + let mut lib = Library::new(); 68 + let f = lib.add_folder(dir.path().to_path_buf(), true).unwrap(); 69 + 70 + let rels: Vec<String> = f.files.iter().map(|m| m.rel.to_string_lossy().into_owned()).collect(); 71 + assert_eq!(rels, vec!["a.mp4", "m.mp4", "z.mp4"]); 72 + } 73 + 74 + #[test] 75 + fn non_recursive_files_only_top_level() { 76 + let dir = tempdir().unwrap(); 77 + make_tree(dir.path(), &["a.mp4", "sub/b.mp4"]); 78 + 79 + let mut lib = Library::new(); 80 + let f = lib.add_folder(dir.path().to_path_buf(), false).unwrap(); 81 + 82 + assert_eq!(f.files.len(), 1); 83 + assert_eq!(f.files[0].rel.to_string_lossy(), "a.mp4"); 84 + } 85 + ``` 86 + 87 + - [ ] **Step 2: Run tests, confirm failures** 88 + 89 + ```bash 90 + cargo test --test library 2>&1 | tail -15 91 + ``` 92 + 93 + Expect: 3 new tests fail (no `files` field). 94 + 95 + - [ ] **Step 3: Edit `src/library.rs`** 96 + 97 + Add `FileMeta` near the top (above `Folder`): 98 + 99 + ```rust 100 + #[derive(Debug, Clone, PartialEq, Eq)] 101 + pub struct FileMeta { 102 + pub rel: std::path::PathBuf, 103 + pub size: u64, 104 + } 105 + ``` 106 + 107 + Add `files` to `Folder`: 108 + 109 + ```rust 110 + #[derive(Debug, Clone, PartialEq, Eq)] 111 + pub struct Folder { 112 + pub path: PathBuf, 113 + pub recursive: bool, 114 + pub file_count: usize, 115 + pub files: Vec<FileMeta>, 116 + } 117 + ``` 118 + 119 + Replace `fn count_files(root: &Path, recursive: bool) -> usize` with: 120 + 121 + ```rust 122 + fn walk_files(root: &Path, recursive: bool) -> Vec<FileMeta> { 123 + let max_depth = if recursive { usize::MAX } else { 1 }; 124 + let mut out: Vec<FileMeta> = walkdir::WalkDir::new(root) 125 + .max_depth(max_depth) 126 + .follow_links(false) 127 + .into_iter() 128 + .filter_map(|e| e.ok()) 129 + .filter(|e| e.file_type().is_file()) 130 + .filter_map(|e| { 131 + let rel = e.path().strip_prefix(root).ok()?.to_path_buf(); 132 + let size = e.metadata().ok()?.len(); 133 + Some(FileMeta { rel, size }) 134 + }) 135 + .collect(); 136 + out.sort_by(|a, b| a.rel.cmp(&b.rel)); 137 + out 138 + } 139 + ``` 140 + 141 + In `add_folder`: 142 + 143 + ```rust 144 + pub fn add_folder(&mut self, path: PathBuf, recursive: bool) -> Result<Folder> { 145 + let canonical = path.canonicalize() 146 + .with_context(|| format!("canonicalize {}", path.display()))?; 147 + if self.folders.iter().any(|f| f.path == canonical) { 148 + anyhow::bail!("folder already added: {}", canonical.display()); 149 + } 150 + let files = Self::walk_files(&canonical, recursive); 151 + let folder = Folder { 152 + path: canonical, 153 + recursive, 154 + file_count: files.len(), 155 + files, 156 + }; 157 + self.folders.push(folder.clone()); 158 + Ok(folder) 159 + } 160 + ``` 161 + 162 + In `refresh_count`: 163 + 164 + ```rust 165 + pub fn refresh_count(&mut self, path: &Path) -> Option<usize> { 166 + let target = path.canonicalize().ok()?; 167 + let f = self.folders.iter_mut().find(|f| f.path == target)?; 168 + let files = Self::walk_files(&f.path, f.recursive); 169 + f.file_count = files.len(); 170 + f.files = files; 171 + Some(f.file_count) 172 + } 173 + ``` 174 + 175 + - [ ] **Step 4: Run tests** 176 + 177 + ```bash 178 + cargo test --test library 2>&1 | tail -15 179 + ``` 180 + 181 + Expect: 7 passed (4 original + 3 new). 182 + 183 + - [ ] **Step 5: Run full test suite to confirm nothing broke** 184 + 185 + ```bash 186 + cargo test 2>&1 | grep -E '^(test result|running)' | head 187 + ``` 188 + 189 + Expect: every test file reports `0 failed`. 190 + 191 + - [ ] **Step 6: Commit** 192 + 193 + ```bash 194 + git add src/library.rs tests/library.rs 195 + git commit -m "library: collect FileMeta during walk" 196 + ``` 197 + 198 + --- 199 + 200 + ## Task 2: Drop `files` from the folders JSON response 201 + 202 + The `FolderView` returned by `/api/folders` should NOT include `files` (keeps that endpoint light). Confirm and lock that behavior with a test. 203 + 204 + **Files:** 205 + - Modify: `tests/api.rs` 206 + 207 + - [ ] **Step 1: Append a regression test to `tests/api.rs`** 208 + 209 + ```rust 210 + #[tokio::test] 211 + async fn list_folders_does_not_include_file_list() { 212 + let dir = tempdir().unwrap(); 213 + std::fs::write(dir.path().join("a.mp4"), b"x").unwrap(); 214 + std::fs::write(dir.path().join("b.mp4"), b"x").unwrap(); 215 + 216 + let lib = Arc::new(RwLock::new(Library::new())); 217 + lib.write().await.add_folder(dir.path().to_path_buf(), true).unwrap(); 218 + let state = dlna_rs::api::AppState { 219 + lib, 220 + cfg_path: Arc::new(tempfile::NamedTempFile::new().unwrap().path().to_path_buf()), 221 + }; 222 + let app = dlna_rs::api::router(state); 223 + 224 + let res = app.oneshot(Request::builder().uri("/api/folders").body(Body::empty()).unwrap()) 225 + .await.unwrap(); 226 + let bytes = res.into_body().collect().await.unwrap().to_bytes(); 227 + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); 228 + assert!(v[0].get("files").is_none(), "folders list must not include file array: got {v}"); 229 + } 230 + ``` 231 + 232 + - [ ] **Step 2: Run tests** 233 + 234 + ```bash 235 + cargo test --test api 2>&1 | tail 236 + ``` 237 + 238 + Expect: pass (since `FolderView` already only serializes `path, recursive, file_count`). 239 + 240 + If it fails, edit `src/api.rs::FolderView` to ensure no `files` field is included. 241 + 242 + - [ ] **Step 3: Commit** 243 + 244 + ```bash 245 + git add tests/api.rs 246 + git commit -m "api: lock folder-list payload (no files field)" 247 + ``` 248 + 249 + --- 250 + 251 + ## Task 3: `GET /api/files?path=…` 252 + 253 + **Files:** 254 + - Modify: `src/api.rs` 255 + - Modify: `tests/api.rs` 256 + 257 + - [ ] **Step 1: Write failing tests in `tests/api.rs`** 258 + 259 + Append: 260 + 261 + ```rust 262 + #[tokio::test] 263 + async fn list_files_for_configured_folder() { 264 + let dir = tempdir().unwrap(); 265 + std::fs::create_dir(dir.path().join("sub")).unwrap(); 266 + std::fs::write(dir.path().join("a.mp4"), b"hello").unwrap(); 267 + std::fs::write(dir.path().join("sub/b.mp4"), b"world!").unwrap(); 268 + 269 + let lib = Arc::new(RwLock::new(Library::new())); 270 + let canonical = lib.write().await.add_folder(dir.path().to_path_buf(), true).unwrap().path; 271 + let state = dlna_rs::api::AppState { 272 + lib, 273 + cfg_path: Arc::new(tempfile::NamedTempFile::new().unwrap().path().to_path_buf()), 274 + }; 275 + let app = dlna_rs::api::router(state); 276 + 277 + let uri = format!("/api/files?path={}", urlencoding_simple(canonical.to_str().unwrap())); 278 + let res = app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) 279 + .await.unwrap(); 280 + assert_eq!(res.status(), StatusCode::OK); 281 + let bytes = res.into_body().collect().await.unwrap().to_bytes(); 282 + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); 283 + assert_eq!(v["folder"], canonical.to_string_lossy().as_ref()); 284 + assert_eq!(v["recursive"], true); 285 + let files = v["files"].as_array().unwrap(); 286 + assert_eq!(files.len(), 2); 287 + assert_eq!(files[0]["rel"], "a.mp4"); 288 + assert_eq!(files[0]["size"], 5); 289 + assert_eq!(files[1]["rel"], "sub/b.mp4"); 290 + } 291 + 292 + #[tokio::test] 293 + async fn list_files_404_for_unknown_folder() { 294 + let lib = Arc::new(RwLock::new(Library::new())); 295 + let state = dlna_rs::api::AppState { 296 + lib, 297 + cfg_path: Arc::new(tempfile::NamedTempFile::new().unwrap().path().to_path_buf()), 298 + }; 299 + let app = dlna_rs::api::router(state); 300 + 301 + let res = app.oneshot(Request::builder() 302 + .uri("/api/files?path=/nope/nada") 303 + .body(Body::empty()).unwrap()) 304 + .await.unwrap(); 305 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 306 + } 307 + 308 + #[tokio::test] 309 + async fn list_files_400_without_path_param() { 310 + let lib = Arc::new(RwLock::new(Library::new())); 311 + let state = dlna_rs::api::AppState { 312 + lib, 313 + cfg_path: Arc::new(tempfile::NamedTempFile::new().unwrap().path().to_path_buf()), 314 + }; 315 + let app = dlna_rs::api::router(state); 316 + 317 + let res = app.oneshot(Request::builder() 318 + .uri("/api/files") 319 + .body(Body::empty()).unwrap()) 320 + .await.unwrap(); 321 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 322 + } 323 + 324 + fn urlencoding_simple(s: &str) -> String { 325 + let mut out = String::new(); 326 + for b in s.bytes() { 327 + match b { 328 + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char), 329 + _ => out.push_str(&format!("%{:02X}", b)), 330 + } 331 + } 332 + out 333 + } 334 + ``` 335 + 336 + - [ ] **Step 2: Run tests, confirm failures** 337 + 338 + ```bash 339 + cargo test --test api 2>&1 | tail 340 + ``` 341 + 342 + Expect: 3 new tests fail (404 because route missing, or other). 343 + 344 + - [ ] **Step 3: Add the route handler in `src/api.rs`** 345 + 346 + Add types near other request/response types: 347 + 348 + ```rust 349 + #[derive(Deserialize)] 350 + pub struct FilesQuery { 351 + pub path: Option<String>, 352 + } 353 + 354 + #[derive(Serialize)] 355 + pub struct FileMetaView { 356 + pub rel: String, 357 + pub size: u64, 358 + } 359 + 360 + #[derive(Serialize)] 361 + pub struct FilesResp { 362 + pub folder: String, 363 + pub recursive: bool, 364 + pub files: Vec<FileMetaView>, 365 + } 366 + ``` 367 + 368 + Add the route in `router(state: AppState)`: 369 + 370 + ```rust 371 + .route("/api/files", get(list_files)) 372 + ``` 373 + 374 + Handler: 375 + 376 + ```rust 377 + async fn list_files(State(state): State<AppState>, Query(q): Query<FilesQuery>) -> Response { 378 + let raw = match q.path { 379 + Some(p) if !p.is_empty() => p, 380 + _ => return (StatusCode::BAD_REQUEST, "missing path query parameter").into_response(), 381 + }; 382 + let target = match std::path::PathBuf::from(&raw).canonicalize() { 383 + Ok(p) => p, 384 + Err(_) => return (StatusCode::NOT_FOUND, "folder not configured").into_response(), 385 + }; 386 + let g = state.lib.read().await; 387 + let folder = match g.folders().iter().find(|f| f.path == target) { 388 + Some(f) => f, 389 + None => return (StatusCode::NOT_FOUND, "folder not configured").into_response(), 390 + }; 391 + let files: Vec<FileMetaView> = folder.files.iter() 392 + .map(|m| FileMetaView { 393 + rel: m.rel.to_string_lossy().into_owned(), 394 + size: m.size, 395 + }) 396 + .collect(); 397 + Json(FilesResp { 398 + folder: folder.path.to_string_lossy().into_owned(), 399 + recursive: folder.recursive, 400 + files, 401 + }).into_response() 402 + } 403 + ``` 404 + 405 + - [ ] **Step 4: Run tests** 406 + 407 + ```bash 408 + cargo test --test api 2>&1 | tail 409 + ``` 410 + 411 + Expect: all api tests pass. 412 + 413 + - [ ] **Step 5: Run full suite** 414 + 415 + ```bash 416 + cargo test 2>&1 | grep -E '^test result' 417 + ``` 418 + 419 + All pass. 420 + 421 + - [ ] **Step 6: Commit** 422 + 423 + ```bash 424 + git add src/api.rs tests/api.rs 425 + git commit -m "api: GET /api/files for per-folder file listing" 426 + ``` 427 + 428 + --- 429 + 430 + ## Task 4: Add `format.ts` helper 431 + 432 + **Files:** 433 + - Create: `web/src/format.ts` 434 + 435 + - [ ] **Step 1: Create the file** 436 + 437 + ```ts 438 + export function formatSize(bytes: number): string { 439 + if (bytes < 1024) return `${bytes} B`; 440 + const units = ['KB', 'MB', 'GB', 'TB']; 441 + let v = bytes / 1024; 442 + let i = 0; 443 + while (v >= 1024 && i < units.length - 1) { 444 + v /= 1024; 445 + i++; 446 + } 447 + return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; 448 + } 449 + ``` 450 + 451 + - [ ] **Step 2: Commit** 452 + 453 + ```bash 454 + git add web/src/format.ts 455 + git commit -m "web: formatSize helper" 456 + ``` 457 + 458 + --- 459 + 460 + ## Task 5: Extend `api.ts` with `listFiles` 461 + 462 + **Files:** 463 + - Modify: `web/src/api.ts` 464 + 465 + - [ ] **Step 1: Append to `web/src/api.ts`** 466 + 467 + ```ts 468 + export type FileMeta = { rel: string; size: number }; 469 + export type FilesResp = { folder: string; recursive: boolean; files: FileMeta[] }; 470 + 471 + export async function listFiles(folder: string): Promise<FilesResp> { 472 + const r = await fetch(`/api/files?path=${encodeURIComponent(folder)}`); 473 + if (!r.ok) throw new Error(await r.text()); 474 + return r.json(); 475 + } 476 + ``` 477 + 478 + - [ ] **Step 2: Commit** 479 + 480 + ```bash 481 + git add web/src/api.ts 482 + git commit -m "web: listFiles client" 483 + ``` 484 + 485 + --- 486 + 487 + ## Task 6: Extract `FolderList.svelte` with redesigned row 488 + 489 + **Files:** 490 + - Create: `web/src/FolderList.svelte` 491 + 492 + - [ ] **Step 1: Create the component** 493 + 494 + ```svelte 495 + <script lang="ts"> 496 + import { listFolders, removeFolder, refreshFolder, type Folder } from './api'; 497 + 498 + let { onbrowse }: { onbrowse: (path: string) => void } = $props(); 499 + 500 + let folders = $state<Folder[]>([]); 501 + let loading = $state(true); 502 + let error = $state<string | null>(null); 503 + 504 + async function load() { 505 + try { folders = await listFolders(); error = null; } 506 + catch (e) { error = String(e); } 507 + finally { loading = false; } 508 + } 509 + load(); 510 + 511 + async function onRemove(path: string) { 512 + if (!confirm(`Remove ${path}?`)) return; 513 + await removeFolder(path); 514 + await load(); 515 + } 516 + async function onRefresh(path: string) { 517 + await refreshFolder(path); 518 + await load(); 519 + } 520 + 521 + export async function reload() { await load(); } 522 + 523 + function basename(p: string): string { 524 + const parts = p.split('/').filter(Boolean); 525 + return parts[parts.length - 1] ?? p; 526 + } 527 + </script> 528 + 529 + {#if loading} 530 + <p>Loading…</p> 531 + {:else if error} 532 + <p class="err">{error}</p> 533 + {:else} 534 + <ul> 535 + {#each folders as f (f.path)} 536 + <li> 537 + <button class="name" onclick={() => onbrowse(f.path)} title="Browse files"> 538 + {basename(f.path)} <span class="chev">▸</span> 539 + </button> 540 + <button class="icon" title="Refresh count" onclick={() => onRefresh(f.path)}>↻</button> 541 + <span class="count">{f.file_count} files</span> 542 + {#if f.recursive}<span class="tag">recursive</span>{/if} 543 + <span class="path" title={f.path}>{f.path}</span> 544 + <button class="icon" title="Remove" onclick={() => onRemove(f.path)}>✕</button> 545 + </li> 546 + {/each} 547 + </ul> 548 + {/if} 549 + 550 + <style> 551 + ul { list-style: none; padding: 0; margin: 0; } 552 + li { 553 + display: grid; 554 + grid-template-columns: auto auto auto auto 1fr auto; 555 + gap: 0.75rem; 556 + align-items: center; 557 + padding: 0.45rem 0; 558 + border-bottom: 1px solid #eee; 559 + } 560 + .name { 561 + background: none; 562 + border: none; 563 + padding: 0; 564 + font: inherit; 565 + font-weight: 600; 566 + cursor: pointer; 567 + color: #111; 568 + } 569 + .name:hover { color: #000; } 570 + .chev { color: #999; font-weight: normal; } 571 + .count { font-variant-numeric: tabular-nums; color: #666; font-size: 0.85rem; white-space: nowrap; } 572 + .tag { font-size: 0.7rem; color: #888; background: #f3f3f3; padding: 0.05rem 0.4rem; border-radius: 3px; white-space: nowrap; } 573 + .path { 574 + color: #999; 575 + font-family: ui-monospace, monospace; 576 + font-size: 0.8rem; 577 + overflow: hidden; 578 + text-overflow: ellipsis; 579 + white-space: nowrap; 580 + min-width: 0; 581 + } 582 + .icon { background: none; border: 1px solid transparent; padding: 0.15rem 0.35rem; cursor: pointer; color: #777; font-size: 0.9rem; } 583 + .icon:hover { color: #111; border-color: #ddd; } 584 + .err { color: #c33; } 585 + </style> 586 + ``` 587 + 588 + - [ ] **Step 2: Commit** 589 + 590 + ```bash 591 + git add web/src/FolderList.svelte 592 + git commit -m "web: FolderList component with redesigned row" 593 + ``` 594 + 595 + --- 596 + 597 + ## Task 7: Create `BrowseView.svelte` 598 + 599 + **Files:** 600 + - Create: `web/src/BrowseView.svelte` 601 + 602 + - [ ] **Step 1: Create the component** 603 + 604 + ```svelte 605 + <script lang="ts"> 606 + import { listFiles, type FileMeta } from './api'; 607 + import { formatSize } from './format'; 608 + 609 + let { folder, onback }: { folder: string; onback: () => void } = $props(); 610 + 611 + let recursive = $state(false); 612 + let files = $state<FileMeta[]>([]); 613 + let loading = $state(true); 614 + let error = $state<string | null>(null); 615 + let sortMode = $state<'filename' | 'path'>('filename'); 616 + let collapsed = $state<Record<string, boolean>>({}); 617 + 618 + async function load() { 619 + loading = true; 620 + try { 621 + const r = await listFiles(folder); 622 + files = r.files; 623 + recursive = r.recursive; 624 + error = null; 625 + } catch (e) { 626 + error = String(e); 627 + } finally { 628 + loading = false; 629 + } 630 + } 631 + load(); 632 + 633 + function basename(p: string): string { 634 + const parts = p.split('/').filter(Boolean); 635 + return parts[parts.length - 1] ?? p; 636 + } 637 + 638 + function parentDir(rel: string): string { 639 + const i = rel.lastIndexOf('/'); 640 + return i < 0 ? '' : rel.slice(0, i + 1); 641 + } 642 + 643 + const sortedByFilename = $derived( 644 + [...files].sort((a, b) => basename(a.rel).localeCompare(basename(b.rel))) 645 + ); 646 + 647 + type Group = { dir: string; files: FileMeta[] }; 648 + const groupedByPath = $derived.by<Group[]>(() => { 649 + const map = new Map<string, FileMeta[]>(); 650 + for (const f of [...files].sort((a, b) => a.rel.localeCompare(b.rel))) { 651 + const dir = parentDir(f.rel); 652 + if (!map.has(dir)) map.set(dir, []); 653 + map.get(dir)!.push(f); 654 + } 655 + const dirs = [...map.keys()].sort(); 656 + return dirs.map(dir => ({ dir, files: map.get(dir)! })); 657 + }); 658 + 659 + function toggle(dir: string) { 660 + collapsed = { ...collapsed, [dir]: !collapsed[dir] }; 661 + } 662 + </script> 663 + 664 + <header class="page"> 665 + <button class="back" onclick={onback}>← dlna-rs</button> 666 + </header> 667 + 668 + <section class="hero"> 669 + <div class="row"> 670 + <h2>{basename(folder)}</h2> 671 + {#if recursive}<span class="tag">recursive</span>{/if} 672 + <span class="count">{files.length} files</span> 673 + </div> 674 + <div class="path">{folder}</div> 675 + </section> 676 + 677 + <nav class="sort"> 678 + <span class="label">sort:</span> 679 + <label><input type="radio" name="sort" value="filename" bind:group={sortMode} /> filename</label> 680 + <label><input type="radio" name="sort" value="path" bind:group={sortMode} /> path</label> 681 + </nav> 682 + 683 + {#if loading} 684 + <p>Loading…</p> 685 + {:else if error} 686 + <p class="err">{error}</p> 687 + {:else if sortMode === 'filename'} 688 + <ul class="files"> 689 + {#each sortedByFilename as f (f.rel)} 690 + <li> 691 + <span class="name">{basename(f.rel)}</span> 692 + <span class="size">{formatSize(f.size)}</span> 693 + </li> 694 + {/each} 695 + </ul> 696 + {:else} 697 + {#each groupedByPath as g (g.dir)} 698 + {#if g.dir === ''} 699 + <ul class="files"> 700 + {#each g.files as f (f.rel)} 701 + <li> 702 + <span class="name">{f.rel}</span> 703 + <span class="size">{formatSize(f.size)}</span> 704 + </li> 705 + {/each} 706 + </ul> 707 + {:else} 708 + <button class="group-head" onclick={() => toggle(g.dir)}> 709 + <span class="chev">{collapsed[g.dir] ? '▸' : '▾'}</span> 710 + <code>{g.dir}</code> 711 + <span class="count">{g.files.length} files</span> 712 + </button> 713 + {#if !collapsed[g.dir]} 714 + <ul class="files indent"> 715 + {#each g.files as f (f.rel)} 716 + <li> 717 + <span class="name">{f.rel.slice(g.dir.length)}</span> 718 + <span class="size">{formatSize(f.size)}</span> 719 + </li> 720 + {/each} 721 + </ul> 722 + {/if} 723 + {/if} 724 + {/each} 725 + {/if} 726 + 727 + <style> 728 + .page { margin-bottom: 1rem; } 729 + .back { background: none; border: none; padding: 0; font: inherit; color: #555; cursor: pointer; } 730 + .back:hover { color: #000; } 731 + 732 + .hero { padding: 0.5rem 0 1rem; border-bottom: 1px solid #eee; } 733 + .hero .row { display: flex; align-items: baseline; gap: 0.75rem; } 734 + .hero h2 { font-size: 1.05rem; margin: 0; } 735 + .tag { font-size: 0.7rem; color: #888; background: #f3f3f3; padding: 0.05rem 0.4rem; border-radius: 3px; } 736 + .count { font-variant-numeric: tabular-nums; color: #666; font-size: 0.85rem; margin-left: auto; } 737 + .path { color: #999; font-family: ui-monospace, monospace; font-size: 0.8rem; margin-top: 0.2rem; } 738 + 739 + .sort { display: flex; gap: 1rem; align-items: center; padding: 0.6rem 0; font-size: 0.9rem; } 740 + .sort .label { color: #888; } 741 + 742 + .files { list-style: none; padding: 0; margin: 0; } 743 + .files li { 744 + display: grid; 745 + grid-template-columns: 1fr auto; 746 + gap: 1rem; 747 + padding: 0.2rem 0; 748 + font-size: 0.9rem; 749 + } 750 + .files .name { font-family: ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 751 + .files .size { color: #777; font-variant-numeric: tabular-nums; } 752 + .indent { padding-left: 1rem; } 753 + 754 + .group-head { 755 + display: flex; align-items: baseline; gap: 0.5rem; 756 + background: none; border: none; padding: 0.5rem 0 0.2rem; 757 + font: inherit; cursor: pointer; width: 100%; text-align: left; 758 + color: #444; 759 + } 760 + .group-head:hover { color: #000; } 761 + .group-head .chev { width: 1rem; color: #999; } 762 + .group-head code { font-size: 0.85rem; } 763 + .group-head .count { margin-left: 0; color: #999; font-size: 0.8rem; } 764 + 765 + .err { color: #c33; } 766 + </style> 767 + ``` 768 + 769 + - [ ] **Step 2: Commit** 770 + 771 + ```bash 772 + git add web/src/BrowseView.svelte 773 + git commit -m "web: BrowseView with filename/path sort and collapsible subdirs" 774 + ``` 775 + 776 + --- 777 + 778 + ## Task 8: Wire view switch in `App.svelte` 779 + 780 + **Files:** 781 + - Modify: `web/src/App.svelte` 782 + 783 + - [ ] **Step 1: Replace contents with:** 784 + 785 + ```svelte 786 + <script lang="ts"> 787 + import FolderList from './FolderList.svelte'; 788 + import BrowseView from './BrowseView.svelte'; 789 + import AddFolderModal from './AddFolderModal.svelte'; 790 + 791 + type View = { kind: 'folders' } | { kind: 'browse'; folder: string }; 792 + 793 + let view = $state<View>({ kind: 'folders' }); 794 + let showAdd = $state(false); 795 + let listRef = $state<FolderList | undefined>(); 796 + 797 + async function onAdded() { 798 + showAdd = false; 799 + await listRef?.reload(); 800 + } 801 + </script> 802 + 803 + <main> 804 + {#if view.kind === 'folders'} 805 + <header> 806 + <h1>dlna-rs</h1> 807 + </header> 808 + <FolderList bind:this={listRef} onbrowse={(p) => view = { kind: 'browse', folder: p }} /> 809 + <button class="add" onclick={() => showAdd = true}>+ add folder…</button> 810 + {:else} 811 + <BrowseView folder={view.folder} onback={() => view = { kind: 'folders' }} /> 812 + {/if} 813 + 814 + {#if showAdd} 815 + <AddFolderModal onclose={() => showAdd = false} onadded={onAdded} /> 816 + {/if} 817 + </main> 818 + 819 + <style> 820 + main { max-width: 720px; margin: 2rem auto; font-family: ui-sans-serif, system-ui, sans-serif; padding: 0 1rem; } 821 + header { display: flex; align-items: baseline; justify-content: space-between; } 822 + h1 { font-size: 1.25rem; margin: 0 0 1rem; } 823 + .add { margin-top: 1rem; border: 1px solid #ddd; background: #fafafa; padding: 0.3rem 0.6rem; cursor: pointer; } 824 + .add:hover { border-color: #aaa; } 825 + </style> 826 + ``` 827 + 828 + - [ ] **Step 2: Build the web bundle to confirm it compiles** 829 + 830 + ```bash 831 + cd web && bun run build && cd .. 832 + ``` 833 + 834 + Expect: success. If TS errors about `FolderList`'s `reload` method (Svelte 5 component instance typing), the simplest fix is to drop the `bind:this` typing and use `let listRef: any = $state();` — but try the typed version first. 835 + 836 + - [ ] **Step 3: End-to-end smoke** 837 + 838 + ```bash 839 + cargo build 2>&1 | tail -5 840 + mkdir -p /tmp/dlna-rs-media/sub1 /tmp/dlna-rs-media/sub2 841 + touch /tmp/dlna-rs-media/a.mp4 /tmp/dlna-rs-media/sub1/b.mp4 /tmp/dlna-rs-media/sub2/c.mp4 842 + cargo run -- --folder /tmp/dlna-rs-media & 843 + SERVER_PID=$! 844 + sleep 2 845 + curl -s "http://localhost:9119/api/files?path=/private/tmp/dlna-rs-media" | head -c 400 846 + echo 847 + kill $SERVER_PID 2>/dev/null 848 + wait $SERVER_PID 2>/dev/null 849 + ``` 850 + 851 + Expect: JSON with 3 files. 852 + 853 + (On macOS, `/tmp` canonicalizes to `/private/tmp`, hence the query path.) 854 + 855 + - [ ] **Step 4: Commit** 856 + 857 + ```bash 858 + git add web/src/App.svelte 859 + git commit -m "web: view switch between folder list and browse" 860 + ``` 861 + 862 + --- 863 + 864 + ## Self-review 865 + 866 + **Spec coverage:** 867 + - Folder list row redesign → Task 6 (`FolderList.svelte`) 868 + - Click-to-navigate → Task 6 (`onbrowse` prop) + Task 8 (view switch) 869 + - Browse view with sort filename/path → Task 7 870 + - Collapsible subdirs (default expanded) → Task 7 (`collapsed` defaults `{}`, treated as not collapsed) 871 + - Backend file list captured during walk → Task 1 872 + - `/api/files` endpoint → Task 3 873 + - Folder list endpoint stays light → Task 2 (regression test) 874 + - Out-of-scope (duration) → designed in spec, not in any task ✓ 875 + 876 + **Placeholder scan:** No `TBD`/`TODO`/vague-handling strings. Every step shows complete code or exact commands. 877 + 878 + **Type consistency:** 879 + - `FileMeta { rel: PathBuf, size: u64 }` in `library.rs`, serialized as `FileMetaView { rel: String, size: u64 }` in `api.rs`, typed as `FileMeta { rel: string; size: number }` in `api.ts`. Consistent. 880 + - `Folder.files: Vec<FileMeta>` in Rust; never exposed in `/api/folders` (Task 2 locks). 881 + - `FilesResp` matches across Rust + TS. 882 + - `onbrowse(path: string)` in `FolderList.svelte`, consumed by `App.svelte` as `(p) => view = { kind: 'browse', folder: p }`. ✓ 883 + 884 + One risk: Svelte 5 `bind:this` of a component returns a component instance type. The `reload()` export is a snippet pattern — if Svelte 5 doesn't expose component-script exports through `bind:this`, fall back to triggering reload via a `$state`-shared "reloadCounter" prop or just refetch on every view → `folders` transition. The fallback is noted inline in Task 8 Step 2.