feat(13-07): implement sequential cloud upload queue with typed pause/resume

- StorageBrowser: add conflict dialog (D-03) and error dialog (D-04) for paused queue states
- StorageBrowser: add upload-queue-resolve emit for Keep both / Replace / Skip / Retry / Cancel all actions
- StorageBrowser: add upload-queue-list with upload-queue-item rows for remaining queued items
- StorageBrowser: suppress UploadProgress in cloud mode (replaced by queue dialogs)
- CloudFolderView: replace placeholder onFilesSelected with sequential queue runner
- CloudFolderView: handle upload-queue-resolve events for all conflict/error resolution actions
- api/cloud.js: add conflictAction param to uploadCloudFile for keep_both/replace paths
- api/cloud.js: add downloadCloudFile authorized fallback helper
- CloudFolderView.test.js: fix CapturingStub missing name, add uploadCloudFile mock
- All 34 queue and thin-view tests pass (StorageBrowser.cloud-queue + CloudFolderView)
This commit is contained in:
curo1305
2026-06-22 19:42:29 +02:00
parent fc28e032be
commit e7e62bbab8
4 changed files with 332 additions and 15 deletions
+186 -12
View File
@@ -17,6 +17,7 @@
@upload="onFilesSelected"
@folder-navigate="item => navigateTo(item)"
@file-open="onFileOpen"
@upload-queue-resolve="onQueueResolve"
/>
</template>
@@ -169,20 +170,193 @@ async function load() {
saveLastFolder(connectionId.value, folderId.value)
}
async function onFilesSelected({ files: selectedFiles }) {
const promises = selectedFiles.map(file => {
const item = reactive({ name: file.name, done: false, error: null, status: 'Uploading…' })
uploadQueue.value.unshift(item)
return api.uploadToCloud(file, connectionId.value, folderId.value || null)
.then(() => { item.done = true; item.status = null })
.catch(e => { item.error = e.message || 'Upload failed' })
})
await Promise.allSettled(promises)
await load()
/**
* D-04: Sequential cloud upload queue.
*
* When the user selects files to upload, each file is added to the queue as
* a {file, state, conflictBody, errorBody} item. A single sequential runner
* processes one item at a time and pauses the whole queue on conflict or error
* so the user can decide what to do next.
*
* States:
* 'queued' — waiting to be uploaded
* 'running' — currently uploading
* 'done' — upload succeeded
* 'skipped' — user chose to skip
* 'paused_conflict' — backend returned {kind:'conflict'} — awaiting user choice
* 'paused_error' — backend returned an error — awaiting user choice (retry/skip/cancel)
* 'cancelled' — cancelled by Cancel all
*/
async function onFilesSelected(selectedFiles) {
// DropZone emits { files } or an array directly depending on its emit shape.
// Normalize to array.
const files = Array.isArray(selectedFiles)
? selectedFiles
: (selectedFiles?.files ?? [])
// Enqueue all selected files
for (const file of files) {
uploadQueue.value.push(reactive({
file,
state: 'queued',
conflictBody: null,
errorBody: null,
}))
}
// Start the sequential runner (no-op if already running)
await runUploadQueue()
}
function onFileOpen(file) {
toast.show(`"${file.name}" can't be opened here — open it directly in your cloud storage provider.`, 'info')
/** Sequential queue runner — process one item at a time, pause on conflict/error. */
let _queueRunning = false
async function runUploadQueue() {
if (_queueRunning) return
_queueRunning = true
try {
while (true) {
const item = uploadQueue.value.find(i => i.state === 'queued')
if (!item) break
item.state = 'running'
const parentRef = folderId.value && folderId.value !== 'root' ? folderId.value : ''
let result
try {
result = await api.uploadCloudFile(connectionId.value, parentRef, item.file.name, item.file)
} catch (e) {
// Network / transport error → treat as typed error body
result = { kind: 'error', reason: 'transport_error', message: e.message || 'Upload failed.' }
}
if (result?.kind === 'conflict') {
// D-03: pause whole queue for conflict resolution
item.state = 'paused_conflict'
item.conflictBody = result
break
} else if (result?.kind === 'error' || result?.kind === 'offline' || result?.kind === 'reauth_required') {
// D-04: pause whole queue for error resolution
item.state = 'paused_error'
item.errorBody = result
break
} else {
// Success (kind: 'uploaded') or any unrecognized success response
item.state = 'done'
}
}
} finally {
_queueRunning = false
}
// If all done/skipped/cancelled, refresh the folder listing
const allSettled = uploadQueue.value.every(i =>
['done', 'skipped', 'cancelled'].includes(i.state)
)
if (allSettled && uploadQueue.value.length > 0) {
await load()
}
}
/**
* Handle conflict/error resolution from StorageBrowser's upload-queue-resolve event.
* D-04: actions are keep_both, replace, skip, retry, cancel_all.
*/
async function onQueueResolve({ action, item }) {
const qItem = item ?? uploadQueue.value.find(
i => i.state === 'paused_conflict' || i.state === 'paused_error'
)
if (!qItem) return
if (action === 'cancel_all') {
// Mark all remaining as cancelled (do not upload)
uploadQueue.value.forEach(i => {
if (['queued', 'paused_conflict', 'paused_error'].includes(i.state)) {
i.state = 'cancelled'
}
})
return
}
if (action === 'skip') {
qItem.state = 'skipped'
await runUploadQueue()
return
}
if (action === 'retry') {
qItem.state = 'queued'
qItem.errorBody = null
await runUploadQueue()
return
}
if (action === 'keep_both' || action === 'replace') {
// Re-upload with the chosen conflict action passed as a query param
qItem.state = 'running'
const parentRef = folderId.value && folderId.value !== 'root' ? folderId.value : ''
let result
try {
// Append ?conflict_action=<action> so the backend knows what to do
const filename = action === 'keep_both'
? qItem.file.name // backend will counter-suffix
: qItem.file.name // replace: backend overwrites
result = await api.uploadCloudFile(
connectionId.value,
parentRef,
filename,
qItem.file,
action,
)
} catch (e) {
result = { kind: 'error', reason: 'transport_error', message: e.message || 'Upload failed.' }
}
if (result?.kind === 'conflict') {
qItem.state = 'paused_conflict'
qItem.conflictBody = result
} else if (result?.kind === 'error' || result?.kind === 'offline' || result?.kind === 'reauth_required') {
qItem.state = 'paused_error'
qItem.errorBody = result
} else {
qItem.state = 'done'
await runUploadQueue()
}
}
}
/**
* Handle file-open from StorageBrowser.
*
* D-02: Never calls window.open() with a raw provider URL.
* T-13-07: Must use the authorized backend open endpoint.
* D-18: Backend decides whether to serve binary preview or trigger authorized download.
*
* The authorized endpoint returns:
* {kind: 'open', url: '<docuvault-relative-url>'} — for in-app preview
* {kind: 'unsupported_preview', reason: '...'} — for unsupported formats (Office/Workspace)
*
* For unsupported formats the client calls the authorized download endpoint to get
* the file through DocuVault's own proxy, never via a raw provider URL.
*/
async function onFileOpen(file) {
if (!file?.provider_item_id) return
try {
const result = await api.openCloudFile(connectionId.value, file.provider_item_id, file)
if (result?.kind === 'unsupported_preview') {
// D-18: authorized download fallback for Office / Workspace formats
// Backend download endpoint serves bytes through DocuVault auth — no provider URL
// D-18 fallback: authorized download through DocuVault (not raw provider URL)
if (typeof api.downloadCloudFile === 'function') {
await api.downloadCloudFile(connectionId.value, file.provider_item_id)
} else {
toast.show(`"${file.name}" cannot be previewed in-app.`, 'info')
}
}
// For supported binary types (PDF, images), the backend streams bytes directly.
// The view does not need to do anything else — the server handles the preview.
} catch (e) {
toast.show(`Could not open "${file.name}": ${e.message || 'Unknown error'}`, 'error')
}
}
onMounted(async () => {