Files
kite/frontend/src/views/CloudFolderView.vue
T
curo1305 60afb028bf feat(13-10): store-backed health mapping, reconnect UX, no-probe-on-navigation, and Google Drive consent copy
- 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
2026-06-22 23:55:58 +02:00

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>