@@ -564,6 +619,8 @@ const emit = defineEmits([
'file-move',
'file-delete',
'capability-explain',
+ // D-12: health banner reconnect action — emitted when user clicks Reconnect in cloud health banner
+ 'reconnect',
])
/**
diff --git a/frontend/src/stores/__tests__/cloudConnections.test.js b/frontend/src/stores/__tests__/cloudConnections.test.js
index 87764a8..c914131 100644
--- a/frontend/src/stores/__tests__/cloudConnections.test.js
+++ b/frontend/src/stores/__tests__/cloudConnections.test.js
@@ -9,6 +9,8 @@ vi.mock('../../api/client.js', () => ({
updateDefaultStorage: vi.fn(),
renameCloudConnection: vi.fn(),
getCloudFoldersByConnectionId: vi.fn(),
+ // D-13: health probe — must be defined in mock so no-probe-on-navigation tests can assert it was NOT called
+ testCloudConnection: vi.fn(),
}))
import { useCloudConnectionsStore, saveLastFolder, loadLastFolder } from '../cloudConnections.js'
diff --git a/frontend/src/stores/cloudConnections.js b/frontend/src/stores/cloudConnections.js
index 1054e16..78a402a 100644
--- a/frontend/src/stores/cloudConnections.js
+++ b/frontend/src/stores/cloudConnections.js
@@ -10,6 +10,17 @@ function folderSessionKey(connectionId) {
return `docuvault:cloud:folder:${connectionId}`
}
+/**
+ * Valid health state values used by both browser compact status and Settings diagnostics.
+ * Translates server vocabulary to UI-actionable states (D-12).
+ *
+ * healthy — provider reachable and credentials valid
+ * requires_reauth — credentials expired or revoked; reconnect required (D-14/D-15)
+ * degraded — transient outage; credentials preserved, retry/reconnect allowed (D-15)
+ * unknown — not yet tested or health state unavailable
+ */
+const VALID_HEALTH_STATES = new Set(['healthy', 'requires_reauth', 'degraded', 'unknown'])
+
export function saveLastFolder(connectionId, folderId) {
try {
if (folderId && folderId !== 'root') {
@@ -48,6 +59,31 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
/** 'cached' | 'cloud_only' | null */
const byteAvailability = ref(null)
+ /**
+ * D-12: Connection health state map — single source for both browser compact status
+ * and Settings full diagnostics.
+ *
+ * Key: connection UUID
+ * Value: { state: 'healthy'|'requires_reauth'|'degraded'|'unknown', error_code, error_message, checked_at }
+ *
+ * Neither the browser nor Settings translate server states independently —
+ * they read from this map only.
+ */
+ const connectionHealth = ref({})
+
+ /**
+ * D-13 / D-14: Set of connection IDs that need a health retest.
+ * CloudFolderView reads this and performs the test after navigation settles.
+ * Cleared after the test is performed.
+ */
+ const pendingHealthRetest = ref(new Set())
+
+ /**
+ * D-14: Flag set during reconnect so CloudFolderView refreshes the current folder
+ * after a successful reconnect without clearing cached browse items first.
+ */
+ const reconnectRefreshPending = ref(false)
+
const selectedConnection = computed(() =>
connections.value.find(c => c.id === selectedConnectionId.value) ?? null
)
@@ -137,6 +173,133 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
if (byteAvail !== undefined) byteAvailability.value = byteAvail
}
+ /**
+ * D-12: Set the health state for a specific connection.
+ * Translates server health payload to the UI-actionable vocabulary.
+ * Both browser compact status and Settings diagnostics consume this.
+ *
+ * @param {string} id — connection UUID
+ * @param {{ state: string, error_code?: string, error_message?: string, checked_at?: string }} healthData
+ */
+ function setConnectionHealth(id, healthData) {
+ const state = VALID_HEALTH_STATES.has(healthData?.state) ? healthData.state : 'unknown'
+ connectionHealth.value = {
+ ...connectionHealth.value,
+ [id]: {
+ state,
+ error_code: healthData?.error_code ?? null,
+ error_message: healthData?.error_message ?? null,
+ checked_at: healthData?.checked_at ?? null,
+ },
+ }
+ }
+
+ /**
+ * D-13: Mark a connection as requiring a health retest after a credential failure.
+ * This is the complement of the no-probe-on-navigation rule:
+ * probes happen only after failures (here), not on ordinary navigation.
+ *
+ * @param {string} id — connection UUID
+ * @param {{ state: string, error_code?: string }} failureData
+ */
+ function handleHealthFailure(id, failureData) {
+ // Record the failure state in the health map
+ setConnectionHealth(id, failureData)
+ // Schedule a retest (D-13: auto-test after credential failure)
+ pendingHealthRetest.value = new Set([...pendingHealthRetest.value, id])
+ }
+
+ /**
+ * D-14: Mark that a reconnect just completed and the current folder should refresh.
+ * Does NOT clear browse items — they stay visible as stale while the refresh runs.
+ *
+ * @param {string} id — connection UUID
+ */
+ function markReconnectRefreshPending(id) {
+ reconnectRefreshPending.value = true
+ // Keep browse items visible but downgrade freshness to stale (D-14)
+ if (folderFreshness.value !== null) {
+ folderFreshness.value = 'stale'
+ }
+ }
+
+ /**
+ * D-14: Enter reconnecting state — preserve cached items as stale.
+ * Does NOT call selectConnection (which clears items).
+ *
+ * @param {string} id — connection UUID
+ */
+ function markReconnecting(id) {
+ // D-14: cached metadata remains visible; downgrade freshness only
+ if (folderFreshness.value !== null) {
+ folderFreshness.value = 'stale'
+ }
+ // Set health to unknown while reconnect is in flight
+ connectionHealth.value = {
+ ...connectionHealth.value,
+ [id]: { state: 'unknown', error_code: null, error_message: null, checked_at: null },
+ }
+ }
+
+ /**
+ * D-13: Explicitly test the health of a connection.
+ * Only called after connect/reconnect/failure — never as a navigation side effect.
+ *
+ * @param {string} id — connection UUID
+ * @returns {Promise<{ state: string }>}
+ */
+ async function testConnection(id) {
+ try {
+ const result = await api.testCloudConnection(id)
+ const state = result?.state ?? 'unknown'
+ setConnectionHealth(id, { state, ...result })
+ // Clear pending retest flag for this connection
+ const next = new Set(pendingHealthRetest.value)
+ next.delete(id)
+ pendingHealthRetest.value = next
+ return result
+ } catch (e) {
+ const failState = { state: 'degraded', error_code: 'probe_error', error_message: e?.message ?? 'Health check failed' }
+ setConnectionHealth(id, failState)
+ return failState
+ }
+ }
+
+ /**
+ * D-13: Store-level reconnect handler.
+ * Marks reconnecting state (cached items preserved), then fetches updated connections.
+ *
+ * @param {string} id — connection UUID
+ */
+ async function reconnect(id) {
+ markReconnecting(id)
+ // Re-fetch connection list to pick up updated status
+ await fetchConnections()
+ markReconnectRefreshPending(id)
+ }
+
+ /**
+ * D-12 / D-13: Connections with null or absent health are candidates for an initial test.
+ * The store exposes this as a computed so the view can decide when to run tests.
+ */
+ const connectionsNeedingHealthCheck = computed(() =>
+ connections.value.filter(c => {
+ const h = connectionHealth.value[c.id]
+ return !h || h.state === undefined || h.state === 'unknown'
+ })
+ )
+
+ /**
+ * D-12: Retrieve the health state for a specific connection.
+ * Returns { state: 'unknown', ... } if no health data is available.
+ *
+ * @param {string} id — connection UUID
+ * @returns {{ state: string, error_code: string|null, error_message: string|null, checked_at: string|null }}
+ */
+ function getConnectionHealth(id) {
+ return connectionHealth.value[id] ?? { state: 'unknown', error_code: null, error_message: null, checked_at: null }
+ }
+
return {
connections,
loading,
@@ -150,6 +313,14 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
folderFreshness,
lastRefreshedAt,
byteAvailability,
+ // D-12: health state map — single source for browser and Settings
+ connectionHealth,
+ // D-13: pending health retests (after credential failures)
+ pendingHealthRetest,
+ // D-14: reconnect refresh flag
+ reconnectRefreshPending,
+ // D-12: connections needing initial health check
+ connectionsNeedingHealthCheck,
fetchConnections,
disconnect,
disconnectAll,
@@ -157,5 +328,13 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
defaultDisplayName,
selectConnection,
setBrowseState,
+ // D-12/D-13: health management
+ setConnectionHealth,
+ getConnectionHealth,
+ handleHealthFailure,
+ markReconnectRefreshPending,
+ markReconnecting,
+ testConnection,
+ reconnect,
}
})
diff --git a/frontend/src/views/CloudFolderView.vue b/frontend/src/views/CloudFolderView.vue
index 225f08a..c20f12e 100644
--- a/frontend/src/views/CloudFolderView.vue
+++ b/frontend/src/views/CloudFolderView.vue
@@ -82,6 +82,8 @@ const mappedBreadcrumb = computed(() =>
*
* 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
@@ -100,8 +102,14 @@ function navigateTo(item) {
...breadcrumbLineage.value,
{ name: item.name, providerItemId: item.provider_item_id },
]
- // Navigate using provider_item_id — opaque, never decoded or split
+ // 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)
}
/**
@@ -170,6 +178,47 @@ async function load() {
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.
*