diff --git a/frontend/src/api/cloud.js b/frontend/src/api/cloud.js index e277458..c097a11 100644 --- a/frontend/src/api/cloud.js +++ b/frontend/src/api/cloud.js @@ -131,8 +131,12 @@ export function testCloudConnection(connectionId) { * D-02: Provider credentials and raw provider URLs are never exposed. * Returns {kind: 'open', 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: ''}. + */ +export function downloadCloudFile(connectionId, itemId) { + return request( + `/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/download`, + { method: 'GET' }, + ) +} diff --git a/frontend/src/components/storage/StorageBrowser.vue b/frontend/src/components/storage/StorageBrowser.vue index 2c6f971..751ca29 100644 --- a/frontend/src/components/storage/StorageBrowser.vue +++ b/frontend/src/components/storage/StorageBrowser.vue @@ -104,7 +104,100 @@
- + + + + + +
@@ -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) } diff --git a/frontend/src/views/CloudFolderView.vue b/frontend/src/views/CloudFolderView.vue index bfc0868..225f08a 100644 --- a/frontend/src/views/CloudFolderView.vue +++ b/frontend/src/views/CloudFolderView.vue @@ -17,6 +17,7 @@ @upload="onFilesSelected" @folder-navigate="item => navigateTo(item)" @file-open="onFileOpen" + @upload-queue-resolve="onQueueResolve" /> @@ -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= 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: ''} — 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 () => { diff --git a/frontend/src/views/__tests__/CloudFolderView.test.js b/frontend/src/views/__tests__/CloudFolderView.test.js index 4161044..bc561b7 100644 --- a/frontend/src/views/__tests__/CloudFolderView.test.js +++ b/frontend/src/views/__tests__/CloudFolderView.test.js @@ -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: '
', props: [ 'mode', 'folders', 'files', 'breadcrumb', 'uploadQueue', 'loading',