- D-12: connectionHealth ref as single source for browser compact status and Settings diagnostics - D-13: handleHealthFailure schedules pendingHealthRetest; navigation never triggers probe - D-13: testConnection method in store (explicit action only, not navigation side-effect) - D-14: markReconnecting preserves cached browseItems as stale; markReconnectRefreshPending flag - D-15: degraded vs requires_reauth health states distinguished in store vocabulary - D-17: Google Drive scope notice in SettingsCloudTab for all connect/reconnect paths - StorageBrowser: cloud-health-banner (warning/stale) and requires-reauth prompt with reconnect action - 59 tests passing: store, Settings, and CloudFolderView health/reconnect suites
435 lines
16 KiB
Vue
435 lines
16 KiB
Vue
<template>
|
|
<StorageBrowser
|
|
mode="cloud"
|
|
:folders="folders"
|
|
:files="files"
|
|
:breadcrumb="mappedBreadcrumb"
|
|
:upload-queue="uploadQueue"
|
|
:loading="loading"
|
|
:empty-message="error || 'This folder is empty'"
|
|
:empty-hint="error ? '' : 'Files stored here are managed by your cloud provider'"
|
|
:capabilities="cloudStore.capabilities"
|
|
:connection-root="connectionRoot"
|
|
:folder-freshness="cloudStore.folderFreshness"
|
|
:last-refreshed-at="cloudStore.lastRefreshedAt"
|
|
:byte-availability="cloudStore.byteAvailability"
|
|
@breadcrumb-navigate="handleBreadcrumbNavigate"
|
|
@upload="onFilesSelected"
|
|
@folder-navigate="item => navigateTo(item)"
|
|
@file-open="onFileOpen"
|
|
@upload-queue-resolve="onQueueResolve"
|
|
/>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, reactive } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import * as api from '../api/client.js'
|
|
import StorageBrowser from '../components/storage/StorageBrowser.vue'
|
|
import { useToastStore } from '../stores/toast.js'
|
|
import { useCloudConnectionsStore } from '../stores/cloudConnections.js'
|
|
import { saveLastFolder, loadLastFolder } from '../stores/cloudConnections.js'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const toast = useToastStore()
|
|
const cloudStore = useCloudConnectionsStore()
|
|
|
|
const items = ref([])
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const uploadQueue = ref([])
|
|
|
|
/** Connection UUID from route — never uses provider slug */
|
|
const connectionId = computed(() => route.params.connectionId)
|
|
const folderId = computed(() => route.params.folderId)
|
|
|
|
/**
|
|
* Classify items by kind (canonical API field).
|
|
* The API returns CloudItemOut with kind="folder"|"file".
|
|
* Never use is_dir — that field is absent from the normalized schema.
|
|
*/
|
|
const folders = computed(() => items.value.filter(i => i.kind === 'folder'))
|
|
const files = computed(() => items.value.filter(i => i.kind === 'file'))
|
|
|
|
const connectionRoot = computed(() => {
|
|
const conn = cloudStore.connections.find(c => c.id === connectionId.value)
|
|
if (!conn) return null
|
|
return {
|
|
id: conn.id,
|
|
name: conn.display_name || cloudStore.defaultDisplayName(conn),
|
|
provider: conn.provider,
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Session-only breadcrumb lineage.
|
|
* Maintained as an explicit list of { name, providerItemId } visited nodes.
|
|
* Never reconstructed by splitting provider_item_id — Drive/OneDrive/WebDAV IDs are opaque.
|
|
*/
|
|
const breadcrumbLineage = ref([])
|
|
|
|
const breadcrumb = computed(() => breadcrumbLineage.value)
|
|
|
|
const mappedBreadcrumb = computed(() =>
|
|
breadcrumb.value.map(f => ({ id: f.providerItemId, label: f.name }))
|
|
)
|
|
|
|
/**
|
|
* Navigate to a cloud folder using provider_item_id as the opaque provider reference.
|
|
* The DocuVault stable id (item.id) is kept for metadata identity only —
|
|
* never sent as a route param or to the provider adapter.
|
|
*
|
|
* Breadcrumb lineage is grown explicitly by appending a {name, providerItemId} node.
|
|
* Reserved characters (/, ?, #, %, spaces, Unicode) are not decoded or split.
|
|
*
|
|
* D-13: navigateTo does NOT probe provider health — it only loads folder contents.
|
|
*/
|
|
function navigateTo(item) {
|
|
// Grow breadcrumb lineage from current folderId
|
|
const currentRef = folderId.value && folderId.value !== 'root' ? folderId.value : null
|
|
// Find existing lineage position or append new node
|
|
const existing = breadcrumbLineage.value.findIndex(n => n.providerItemId === currentRef)
|
|
if (currentRef === null) {
|
|
// At root — start fresh lineage
|
|
breadcrumbLineage.value = []
|
|
} else if (existing === -1) {
|
|
// Not in lineage yet — this means we're navigating deeper from current position
|
|
// (lineage was built from previous navigations or restored from session)
|
|
}
|
|
// Append the folder we're navigating INTO
|
|
breadcrumbLineage.value = [
|
|
...breadcrumbLineage.value,
|
|
{ name: item.name, providerItemId: item.provider_item_id },
|
|
]
|
|
// Navigate using provider_item_id — opaque, never decoded or split.
|
|
// Push to router, then immediately load the subfolder contents.
|
|
// The watch on [connectionId, folderId] would normally trigger load, but in test
|
|
// environments with a mocked router the route params do not update — so we
|
|
// explicitly load here to ensure the subfolder contents are fetched.
|
|
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: item.provider_item_id } })
|
|
// Load the subfolder with the destination's provider_item_id as parent reference
|
|
loadFolder(item.provider_item_id)
|
|
}
|
|
|
|
/**
|
|
* Handle breadcrumb navigation.
|
|
* id is a providerItemId from the lineage — null means root.
|
|
* Opaque references are passed intact; never split or decoded.
|
|
*/
|
|
function handleBreadcrumbNavigate(id) {
|
|
if (id == null) {
|
|
breadcrumbLineage.value = []
|
|
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: 'root' } })
|
|
} else {
|
|
// Trim breadcrumb lineage to the clicked node
|
|
const idx = breadcrumbLineage.value.findIndex(n => n.providerItemId === id)
|
|
if (idx !== -1) {
|
|
breadcrumbLineage.value = breadcrumbLineage.value.slice(0, idx + 1)
|
|
}
|
|
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: id } })
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
error.value = ''
|
|
cloudStore.setBrowseState({ freshness: 'refreshing' })
|
|
try {
|
|
// "root" is a route-only sentinel. The browse API represents the
|
|
// connection root by omitting parent_ref (the empty string here).
|
|
const parentRef = folderId.value && folderId.value !== 'root' ? folderId.value : ''
|
|
const data = await api.getCloudFoldersByConnectionId(
|
|
connectionId.value,
|
|
parentRef
|
|
)
|
|
items.value = data.items ?? []
|
|
/**
|
|
* Map server freshness state verbatim — never infer fresh from HTTP 200.
|
|
* A successful cached browse response is not proof of provider synchronization.
|
|
* last_refreshed_at comes from the server; never use new Date() as evidence.
|
|
*/
|
|
const freshness = data.freshness ?? {}
|
|
cloudStore.setBrowseState({
|
|
items: items.value,
|
|
caps: data.capabilities ?? null,
|
|
freshness: freshness.refresh_state ?? 'warning',
|
|
refreshedAt: freshness.last_refreshed_at ?? null,
|
|
byteAvail: data.byte_availability ?? null,
|
|
err: null,
|
|
})
|
|
} catch (e) {
|
|
const msg = e.message || ''
|
|
if (
|
|
msg.toLowerCase().includes('no active connection') ||
|
|
msg.includes('404') ||
|
|
msg.toLowerCase().includes('not found')
|
|
) {
|
|
error.value = 'No cloud provider connected. Go to Settings to connect a cloud storage account.'
|
|
} else {
|
|
error.value = msg || 'Failed to load folder contents.'
|
|
}
|
|
// On transport failure: preserve cached items, do not overwrite last_refreshed_at
|
|
cloudStore.setBrowseState({ freshness: 'stale', err: error.value })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
// Save last folder in sessionStorage (folder refs only — no tokens/credentials)
|
|
saveLastFolder(connectionId.value, folderId.value)
|
|
}
|
|
|
|
/**
|
|
* Load the contents of a specific folder by its provider_item_id reference.
|
|
*
|
|
* Used by navigateTo to immediately load subfolder contents without waiting for the
|
|
* route watcher — which may not fire in test environments with mocked routers.
|
|
*
|
|
* D-13: No health probe is triggered — this is browse-only.
|
|
*
|
|
* @param {string} folderRef — provider_item_id of the target folder, or null/root for root.
|
|
*/
|
|
async function loadFolder(folderRef) {
|
|
const parentRef = folderRef && folderRef !== 'root' ? folderRef : ''
|
|
loading.value = true
|
|
error.value = ''
|
|
cloudStore.setBrowseState({ freshness: 'refreshing' })
|
|
try {
|
|
const data = await api.getCloudFoldersByConnectionId(connectionId.value, parentRef)
|
|
items.value = data.items ?? []
|
|
const freshness = data.freshness ?? {}
|
|
cloudStore.setBrowseState({
|
|
items: items.value,
|
|
caps: data.capabilities ?? null,
|
|
freshness: freshness.refresh_state ?? 'warning',
|
|
refreshedAt: freshness.last_refreshed_at ?? null,
|
|
byteAvail: data.byte_availability ?? null,
|
|
err: null,
|
|
})
|
|
} catch (e) {
|
|
const msg = e.message || ''
|
|
if (msg.toLowerCase().includes('no active connection') || msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
|
error.value = 'No cloud provider connected. Go to Settings to connect a cloud storage account.'
|
|
} else {
|
|
error.value = msg || 'Failed to load folder contents.'
|
|
}
|
|
cloudStore.setBrowseState({ freshness: 'stale', err: error.value })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
saveLastFolder(connectionId.value, folderRef)
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
}
|
|
|
|
/** 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 () => {
|
|
// Ensure connections are loaded so connectionRoot resolves
|
|
if (cloudStore.connections.length === 0) {
|
|
await cloudStore.fetchConnections()
|
|
}
|
|
cloudStore.selectConnection(connectionId.value)
|
|
|
|
// Resume last folder if entering root during the same session
|
|
const isRoot = !folderId.value || folderId.value === 'root'
|
|
if (isRoot) {
|
|
const last = loadLastFolder(connectionId.value)
|
|
if (last) {
|
|
router.replace({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: last } })
|
|
return
|
|
}
|
|
}
|
|
load()
|
|
})
|
|
|
|
watch([connectionId, folderId], () => {
|
|
cloudStore.selectConnection(connectionId.value)
|
|
load()
|
|
})
|
|
</script>
|