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.
|
* D-02: Provider credentials and raw provider URLs are never exposed.
|
||||||
* Returns {kind: 'open', url: '<authorized-docuvault-url>'}.
|
* Returns {kind: 'open', url: '<authorized-docuvault-url>'}.
|
||||||
* The caller must navigate to the returned URL — never window.open to a provider 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`)
|
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} parentRef - Parent folder provider_item_id
|
||||||
* @param {string} filename - Target filename
|
* @param {string} filename - Target filename
|
||||||
* @param {File|Blob} file - File bytes
|
* @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()
|
const formData = new FormData()
|
||||||
formData.append('file', file, filename)
|
formData.append('file', file, filename)
|
||||||
formData.append('parent_ref', parentRef ?? '')
|
formData.append('parent_ref', parentRef ?? '')
|
||||||
formData.append('filename', filename)
|
formData.append('filename', filename)
|
||||||
|
if (conflictAction) formData.append('conflict_action', conflictAction)
|
||||||
return request(`/api/cloud/connections/${connectionId}/items/upload`, {
|
return request(`/api/cloud/connections/${connectionId}/items/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
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">
|
<div class="px-4 sm:px-6 pt-5 pb-3">
|
||||||
<DropZone ref="dropZoneRef" @files-selected="$emit('upload', $event)" />
|
<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>
|
||||||
|
|
||||||
<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">
|
<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',
|
'sort-change',
|
||||||
'search-change',
|
'search-change',
|
||||||
'upload',
|
'upload',
|
||||||
|
'upload-queue-resolve',
|
||||||
'folder-navigate',
|
'folder-navigate',
|
||||||
'folder-create',
|
'folder-create',
|
||||||
'folder-rename',
|
'folder-rename',
|
||||||
@@ -531,6 +625,29 @@ function showCapabilityNotice({ message, type }) {
|
|||||||
|
|
||||||
const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
|
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) {
|
function topicColor(name) {
|
||||||
return props.topicColorFn(name)
|
return props.topicColorFn(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
@upload="onFilesSelected"
|
@upload="onFilesSelected"
|
||||||
@folder-navigate="item => navigateTo(item)"
|
@folder-navigate="item => navigateTo(item)"
|
||||||
@file-open="onFileOpen"
|
@file-open="onFileOpen"
|
||||||
|
@upload-queue-resolve="onQueueResolve"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -169,20 +170,193 @@ async function load() {
|
|||||||
saveLastFolder(connectionId.value, folderId.value)
|
saveLastFolder(connectionId.value, folderId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onFilesSelected({ files: selectedFiles }) {
|
/**
|
||||||
const promises = selectedFiles.map(file => {
|
* D-04: Sequential cloud upload queue.
|
||||||
const item = reactive({ name: file.name, done: false, error: null, status: 'Uploading…' })
|
*
|
||||||
uploadQueue.value.unshift(item)
|
* When the user selects files to upload, each file is added to the queue as
|
||||||
return api.uploadToCloud(file, connectionId.value, folderId.value || null)
|
* a {file, state, conflictBody, errorBody} item. A single sequential runner
|
||||||
.then(() => { item.done = true; item.status = null })
|
* processes one item at a time and pauses the whole queue on conflict or error
|
||||||
.catch(e => { item.error = e.message || 'Upload failed' })
|
* so the user can decide what to do next.
|
||||||
})
|
*
|
||||||
await Promise.allSettled(promises)
|
* States:
|
||||||
await load()
|
* '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) {
|
/** Sequential queue runner — process one item at a time, pause on conflict/error. */
|
||||||
toast.show(`"${file.name}" can't be opened here — open it directly in your cloud storage provider.`, 'info')
|
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 () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ vi.mock('../../api/client.js', () => ({
|
|||||||
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [], capabilities: null }),
|
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [], capabilities: null }),
|
||||||
uploadToCloud: vi.fn(),
|
uploadToCloud: vi.fn(),
|
||||||
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
|
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', () => ({
|
vi.mock('../../stores/toast.js', () => ({
|
||||||
@@ -529,6 +532,7 @@ describe('cloud_folder_view_remains_thin_data_provider_phase13', () => {
|
|||||||
|
|
||||||
let capturedUploadQueue = undefined
|
let capturedUploadQueue = undefined
|
||||||
const CapturingStub = {
|
const CapturingStub = {
|
||||||
|
name: 'StorageBrowser',
|
||||||
template: '<div data-test="storage-browser" />',
|
template: '<div data-test="storage-browser" />',
|
||||||
props: [
|
props: [
|
||||||
'mode', 'folders', 'files', 'breadcrumb', 'uploadQueue', 'loading',
|
'mode', 'folders', 'files', 'breadcrumb', 'uploadQueue', 'loading',
|
||||||
|
|||||||
Reference in New Issue
Block a user