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