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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user