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
+24 -2
View File
@@ -131,8 +131,12 @@ export function testCloudConnection(connectionId) {
* D-02: Provider credentials and raw provider URLs are never exposed.
* Returns {kind: 'open', url: '<authorized-docuvault-url>'}.
* The caller must navigate to the returned URL — never window.open to a provider URL.
*
* @param {string} connectionId - Connection UUID
* @param {string} itemId - Provider item ID (opaque reference)
* @param {object} [fileContext] - Optional file metadata context (name, contentType) for display
*/
export function openCloudFile(connectionId, itemId) {
export function openCloudFile(connectionId, itemId, fileContext) {
return request(`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/open`)
}
@@ -222,14 +226,32 @@ export function deleteCloudItem(connectionId, itemId) {
* @param {string} parentRef - Parent folder provider_item_id
* @param {string} filename - Target filename
* @param {File|Blob} file - File bytes
* @param {'keep_both'|'replace'|undefined} conflictAction - Optional conflict resolution action
*/
export function uploadCloudFile(connectionId, parentRef, filename, file) {
export function uploadCloudFile(connectionId, parentRef, filename, file, conflictAction) {
const formData = new FormData()
formData.append('file', file, filename)
formData.append('parent_ref', parentRef ?? '')
formData.append('filename', filename)
if (conflictAction) formData.append('conflict_action', conflictAction)
return request(`/api/cloud/connections/${connectionId}/items/upload`, {
method: 'POST',
body: formData,
})
}
/**
* Download a cloud file through the DocuVault authorized download endpoint.
*
* D-02: No raw provider URL or credential is exposed.
* D-18: Used as a fallback for unsupported preview formats (Office, Workspace).
* The authorized endpoint serves bytes through DocuVault's own proxy.
*
* Returns {kind: 'download', url: '<docuvault-relative-url>'}.
*/
export function downloadCloudFile(connectionId, itemId) {
return request(
`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/download`,
{ method: 'GET' },
)
}
@@ -104,7 +104,100 @@
<div class="px-4 sm:px-6 pt-5 pb-3">
<DropZone ref="dropZoneRef" @files-selected="$emit('upload', $event)" />
<UploadProgress :items="uploadQueue" />
<!--
UploadProgress shows item-by-item progress in local mode.
In cloud mode the sequential queue dialogs below replace this component.
-->
<UploadProgress v-if="mode !== 'cloud'" :items="uploadQueue" />
<!-- Sequential cloud upload queue dialogs (D-03 / D-04) -->
<!-- Only rendered in cloud mode when the queue has a paused item -->
<template v-if="mode === 'cloud'">
<!-- Conflict dialog: shown when current queue item is paused_conflict -->
<div
v-if="pausedConflictItem"
data-test="upload-conflict-dialog"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 space-y-2"
role="dialog"
aria-modal="false"
aria-label="Upload conflict"
>
<p class="text-sm font-semibold text-amber-800">
A file named <strong>{{ pausedConflictItem.conflictBody?.existing_name ?? pausedConflictItem.file?.name }}</strong> already exists.
</p>
<p class="text-xs text-amber-700">Choose how to handle this conflict before the upload can continue.</p>
<div class="flex flex-wrap gap-2 pt-1">
<button
data-test="conflict-keep-both"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white border border-amber-300 text-amber-800 hover:bg-amber-100 active:bg-amber-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
@click="$emit('upload-queue-resolve', { action: 'keep_both', item: pausedConflictItem })"
>Keep both</button>
<button
data-test="conflict-replace"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white border border-amber-300 text-amber-800 hover:bg-amber-100 active:bg-amber-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
@click="$emit('upload-queue-resolve', { action: 'replace', item: pausedConflictItem })"
>Replace</button>
<button
data-test="conflict-skip"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white border border-gray-200 text-gray-600 hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400"
@click="$emit('upload-queue-resolve', { action: 'skip', item: pausedConflictItem })"
>Skip</button>
<button
data-test="conflict-cancel-all"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white border border-red-200 text-red-600 hover:bg-red-50 active:bg-red-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
@click="$emit('upload-queue-resolve', { action: 'cancel_all', item: pausedConflictItem })"
>Cancel all</button>
</div>
</div>
<!-- Error dialog: shown when current queue item is paused_error -->
<div
v-else-if="pausedErrorItem"
data-test="upload-error-dialog"
class="mt-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3 space-y-2"
role="dialog"
aria-modal="false"
aria-label="Upload error"
>
<p class="text-sm font-semibold text-red-800">Upload failed for <strong>{{ pausedErrorItem.file?.name }}</strong>.</p>
<p v-if="pausedErrorItem.errorBody?.message" class="text-xs text-red-700">{{ pausedErrorItem.errorBody.message }}</p>
<div class="flex flex-wrap gap-2 pt-1">
<button
data-test="error-retry"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white border border-red-300 text-red-700 hover:bg-red-100 active:bg-red-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
@click="$emit('upload-queue-resolve', { action: 'retry', item: pausedErrorItem })"
>Retry</button>
<button
data-test="error-skip"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white border border-gray-200 text-gray-600 hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400"
@click="$emit('upload-queue-resolve', { action: 'skip', item: pausedErrorItem })"
>Skip</button>
<button
data-test="error-cancel-all"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white border border-red-200 text-red-600 hover:bg-red-50 active:bg-red-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
@click="$emit('upload-queue-resolve', { action: 'cancel_all', item: pausedErrorItem })"
>Cancel all</button>
</div>
</div>
<!-- Remaining queue items list (D-04: items preserved during pause) -->
<div
v-if="queuedRemainingItems.length > 0"
data-test="upload-queue-list"
class="mt-2 rounded-xl border border-gray-100 divide-y divide-gray-100 bg-white overflow-hidden"
>
<div
v-for="(item, idx) in queuedRemainingItems"
:key="idx"
data-test="upload-queue-item"
class="px-3 py-2 flex items-center gap-2 text-xs text-gray-600"
>
<AppIcon name="document" class="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span class="truncate">{{ item.file?.name ?? item.name }}</span>
<span class="ml-auto text-gray-400 shrink-0">Queued</span>
</div>
</div>
</template>
</div>
<div class="mx-4 sm:mx-6 px-4 py-2 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none" data-test="list-header">
@@ -461,6 +554,7 @@ const emit = defineEmits([
'sort-change',
'search-change',
'upload',
'upload-queue-resolve',
'folder-navigate',
'folder-create',
'folder-rename',
@@ -531,6 +625,29 @@ function showCapabilityNotice({ message, type }) {
const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
/**
* D-04: Sequential cloud upload queue computed helpers.
*
* pausedConflictItem — the first item in the queue that is paused for conflict resolution.
* pausedErrorItem — the first item that is paused due to an upload error.
* queuedRemainingItems — all items still waiting (not paused, not done, not failed).
*
* Only one pause state can be active at once: conflict takes priority over error.
*/
const pausedConflictItem = computed(() =>
props.uploadQueue.find(i => i.state === 'paused_conflict') ?? null
)
const pausedErrorItem = computed(() =>
!pausedConflictItem.value
? (props.uploadQueue.find(i => i.state === 'paused_error') ?? null)
: null
)
const queuedRemainingItems = computed(() =>
props.uploadQueue.filter(i => i.state === 'queued')
)
function topicColor(name) {
return props.topicColorFn(name)
}
+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 () => {
@@ -39,6 +39,9 @@ vi.mock('../../api/client.js', () => ({
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [], capabilities: null }),
uploadToCloud: vi.fn(),
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
uploadCloudFile: vi.fn().mockResolvedValue({ kind: 'uploaded', provider_item_id: 'new-item-ref' }),
openCloudFile: vi.fn().mockResolvedValue({ kind: 'open', url: '/api/cloud/preview/tok' }),
downloadCloudFile: vi.fn().mockResolvedValue({ kind: 'download', url: '/api/cloud/download/tok' }),
}))
vi.mock('../../stores/toast.js', () => ({
@@ -529,6 +532,7 @@ describe('cloud_folder_view_remains_thin_data_provider_phase13', () => {
let capturedUploadQueue = undefined
const CapturingStub = {
name: 'StorageBrowser',
template: '<div data-test="storage-browser" />',
props: [
'mode', 'folders', 'files', 'breadcrumb', 'uploadQueue', 'loading',