feat: support parallel multi-file uploads

The browser upload flow was built around one selected file and one global
upload state. That made the existing chunk pool useful for a single file, but
users could not start several selected files at the same time.

Refactor the browser state into per-file upload items. Each selected file now
has its own upload record, completed-chunk set, abort controller, retry state,
progress row, and saved IndexedDB resume record. The picker accepts multiple
files, `Start all` and `Resume all` use a bounded file-level pool, and each file
keeps the existing bounded chunk pool. This keeps parallel uploads useful
without letting one large selection create unbounded request fan-out.

Keep the server API unchanged. Each file still receives a separate server upload
id, and server-side progress remains authoritative before any missing chunks are
scheduled. Terminal conflicts still stop the affected file without overwriting
completed data.

Update the user-facing markup, styles, project docs, and test checklist for the
multi-file scheduler. Add a server regression test that interleaves two uploads
and verifies the completed files contain exactly their own bytes.

Test Plan:
- just check
- git diff --check
This commit is contained in:
2026-05-30 18:32:29 +02:00
parent a7b3abd54a
commit 1923ff2a6f
8 changed files with 635 additions and 192 deletions
+52 -6
View File
@@ -88,16 +88,48 @@ h1 {
clip: rect(0, 0, 0, 0);
}
.file-summary {
.upload-section {
display: grid;
gap: 4px;
padding: 14px;
gap: 12px;
}
.upload-list {
display: grid;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.upload-item {
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
}
.file-summary span {
.upload-item-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
}
.upload-meta {
display: grid;
gap: 4px;
min-width: 0;
}
.upload-meta span {
color: var(--muted);
font-size: 0.875rem;
}
.upload-progress {
display: grid;
gap: 6px;
}
.progress-wrap {
@@ -115,7 +147,6 @@ h1 {
}
.progress-meta {
margin-top: -12px;
color: var(--muted);
font-size: 0.875rem;
}
@@ -126,6 +157,13 @@ h1 {
gap: 10px;
}
.upload-item-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
button {
min-width: 96px;
min-height: 40px;
@@ -184,7 +222,7 @@ h2 {
}
.pending-item strong,
.file-summary strong {
.upload-meta strong {
overflow-wrap: anywhere;
}
@@ -232,4 +270,12 @@ h2 {
.pending-item {
grid-template-columns: 1fr;
}
.upload-item-header {
grid-template-columns: 1fr;
}
.upload-item-actions {
justify-content: flex-start;
}
}