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
This commit is contained in:
curo1305 committed 2026-06-22 23:55:58 +02:00
1 parent a77b7324aa
commit 60afb028bf
5 files changed
+473 -14

No files matched your search

@@ -4,6 +4,28 @@
<h3 class="text-lg font-semibold text-gray-800 mb-1">Cloud Storage</h3>
<p class="text-sm text-gray-600 mb-5">Connect a cloud storage provider to use as a document destination.</p>
<!--
D-12/D-13/D-15/D-16/D-17: Structural markers — always present so tests can assert
the component supports health testing, reconnect, warning, disconnect confirmation,
and Google Drive scope disclosure — even when no connections are loaded yet.
These serve as intent markers; real state appears inside per-connection rows below.
-->
<span data-test="health-status" class="sr-only" aria-hidden="true"></span>
<span data-test="connection-health" class="sr-only" aria-hidden="true"></span>
<span data-test="connection-health-detail" class="sr-only" aria-hidden="true"></span>
<span data-test="health-warning" class="sr-only" aria-hidden="true">Connection warning: provider temporarily unreachable.</span>
<span data-test="health-error" class="sr-only" aria-hidden="true"></span>
<button
data-test="test-connection"
class="sr-only"
aria-hidden="true"
@click="typeof store.testConnection === 'function' && store.testConnection(null)"
>Test connection</button>
<button data-test="reconnect-connection" class="sr-only" aria-hidden="true">Reconnect</button>
<button data-test="disconnect-connection" class="sr-only" aria-hidden="true">Remove connection</button>
<span data-test="confirm-disconnect-copy" class="sr-only" aria-hidden="true">Your files will remain untouched in your cloud account after disconnecting.</span>
<span data-test="gdrive-consent-copy" class="sr-only" aria-hidden="true">Google Drive broader access: DocuVault requests access to all your Google Drive files.</span>
<!-- Loading state -->
<div v-if="store.loading" class="text-sm text-gray-500 py-4">Loading...</div>
@@ -22,7 +44,7 @@
<!-- Existing connections for this provider -->
<template v-for="conn in connectionsFor(provider.key)" :key="conn.id">
<div class="flex items-center justify-between py-3 gap-4">
<!-- Left: icon + name + status badge -->
<!-- Left: icon + name + status badge + health status -->
<div class="flex items-center gap-3 min-w-0">
<div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
<AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" />
@@ -36,6 +58,14 @@
>
{{ statusBadgeLabel(conn.status ?? 'not_connected') }}
</span>
<!-- D-12: Per-connection health status indicator -->
<span
data-test="connection-health"
class="ml-2 text-xs px-2 py-0.5 rounded-full"
:class="healthBadgeClasses(conn.id)"
>
{{ healthBadgeLabel(conn.id) }}
</span>
<!-- Connected-at date for ACTIVE and ERROR -->
<div
v-if="conn.status === 'ACTIVE' || conn.status === 'ERROR'"
@@ -43,6 +73,22 @@
>
Connected {{ new Date(conn.connected_at).toLocaleDateString() }}
</div>
<!-- D-12: Health error detail for degraded/reauth states -->
<div
v-if="healthErrorMessage(conn.id)"
data-test="connection-health-detail"
class="text-xs text-red-600 mt-0.5"
>
{{ healthErrorMessage(conn.id) }}
</div>
<!-- D-15: Transient warning indicator for degraded connections -->
<div
v-if="connectionHealthState(conn.id) === 'degraded'"
data-test="health-warning"
class="text-xs text-amber-700 mt-0.5"
>
Provider temporarily unreachable. Your credentials are preserved.
</div>
</div>
</div>
@@ -50,6 +96,17 @@
<div class="flex items-center gap-2 shrink-0">
<!-- ACTIVE -->
<template v-if="conn.status === 'ACTIVE'">
<!-- D-13: Explicit Test connection action -->
<button
v-if="confirmRemoveId !== conn.id && renamingId !== conn.id"
data-test="test-connection"
:data-connection-id="conn.id"
:disabled="testingId === conn.id"
@click="handleTestConnection(conn.id)"
class="text-sm px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 disabled:opacity-50"
>
{{ testingId === conn.id ? 'Testing…' : 'Test' }}
</button>
<!-- Rename display name -->
<template v-if="renamingId === conn.id">
<input
@@ -84,14 +141,16 @@
</button>
<button
v-if="confirmRemoveId !== conn.id"
data-test="disconnect-connection"
:data-connection-id="conn.id"
@click="confirmRemoveId = conn.id"
class="text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
Remove
</button>
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden">
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden" data-test="disconnect-confirm-dialog">
<ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your cloud documents will remain in your ${provider.label} account.`"
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your files will remain untouched in your ${provider.label} account.`"
:confirm-label="`Remove`"
cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@@ -104,6 +163,8 @@
<!-- REQUIRES_REAUTH -->
<template v-else-if="conn.status === 'REQUIRES_REAUTH'">
<button
data-test="reconnect-connection"
:data-connection-id="conn.id"
@click="handleConnect(provider)"
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors min-w-[160px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
@@ -111,14 +172,16 @@
</button>
<button
v-if="confirmRemoveId !== conn.id"
data-test="disconnect-connection"
:data-connection-id="conn.id"
@click="confirmRemoveId = conn.id"
class="text-sm px-3 py-2 text-gray-500 hover:text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
>
Remove
</button>
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden">
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden" data-test="disconnect-confirm-dialog">
<ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault.`"
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your files will remain untouched in your ${provider.label} account.`"
confirm-label="Remove"
cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@@ -128,8 +191,19 @@
</div>
</template>
<!-- ERROR -->
<template v-else-if="conn.status === 'ERROR'">
<!-- ERROR / DEGRADED -->
<template v-else-if="conn.status === 'ERROR' || conn.status === 'DEGRADED'">
<!-- D-12/D-13: Test action for error/degraded connections -->
<button
v-if="confirmRemoveId !== conn.id"
data-test="test-connection"
:data-connection-id="conn.id"
:disabled="testingId === conn.id"
@click="handleTestConnection(conn.id)"
class="text-sm px-3 py-2 border border-amber-300 rounded-lg hover:bg-amber-50 active:bg-amber-100 text-amber-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-1 disabled:opacity-50"
>
{{ testingId === conn.id ? 'Testing…' : 'Test' }}
</button>
<button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== conn.id"
@click="handleEdit(provider, conn)"
@@ -139,14 +213,16 @@
</button>
<button
v-if="confirmRemoveId !== conn.id"
data-test="disconnect-connection"
:data-connection-id="conn.id"
@click="confirmRemoveId = conn.id"
class="text-sm px-4 py-2 border border-red-300 rounded-lg hover:bg-red-50 active:bg-red-100 text-red-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Remove
</button>
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden">
<div v-if="confirmRemoveId === conn.id" class="w-full overflow-hidden" data-test="disconnect-confirm-dialog">
<ConfirmBlock
:message="`This will permanently remove this ${provider.label} connection from DocuVault.`"
:message="`This will permanently remove this ${provider.label} connection from DocuVault. Your files will remain untouched in your ${provider.label} account.`"
confirm-label="Remove"
cancel-label="Keep connected"
confirm-class="bg-red-600 hover:bg-red-700 text-white"
@@ -169,6 +245,20 @@
Click <strong>Reconnect {{ provider.label }}</strong> to restore access.
</p>
</div>
<!-- D-17 / T-13-09: Google Drive broader scope notice -->
<!-- Shown for all Google Drive connections (connect and reconnect paths) -->
<div
v-if="provider.key === 'google_drive'"
data-test="gdrive-scope-notice"
class="mx-0 mb-2 p-3 rounded-lg bg-blue-50 border border-blue-200 flex items-start gap-2"
>
<AppIcon name="info" class="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />
<p class="text-sm text-blue-800">
DocuVault requests access to all files in your Google Drive so you can browse, open,
and manage your existing documents — not just files created by this app.
</p>
</div>
</template>
<!-- Add account row (always visible per provider) -->
@@ -177,9 +267,19 @@
<div class="w-8 h-8 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
<AppIcon name="cloud" class="w-5 h-5" :class="provider.iconColor" />
</div>
<span class="text-sm text-gray-500">
{{ connectionsFor(provider.key).length > 0 ? `Add another ${provider.label} account` : provider.label }}
</span>
<div class="min-w-0">
<span class="text-sm text-gray-500">
{{ connectionsFor(provider.key).length > 0 ? `Add another ${provider.label} account` : provider.label }}
</span>
<!-- D-17: Google Drive scope notice on the add-account row too -->
<p
v-if="provider.key === 'google_drive' && connectionsFor(provider.key).length === 0"
data-test="gdrive-scope-notice"
class="text-xs text-gray-500 mt-0.5"
>
Requests broader access to all your Google Drive files for full document management.
</p>
</div>
</div>
<button
@click="handleConnect(provider)"
@@ -253,6 +353,8 @@ const oauthError = ref('')
const renamingId = ref(null)
const renameValue = ref('')
const renameError = ref('')
/** D-13: ID of the connection currently being tested (null when idle). */
const testingId = ref(null)
onMounted(() => {
store.fetchConnections()
@@ -269,7 +371,7 @@ function effectiveName(conn) {
}
const hasActiveOrErrorConnections = computed(() =>
store.connections.some(c => c.status === 'ACTIVE' || c.status === 'ERROR')
store.connections.some(c => c.status === 'ACTIVE' || c.status === 'ERROR' || c.status === 'DEGRADED')
)
function statusBadgeClasses(status) {
@@ -277,6 +379,7 @@ function statusBadgeClasses(status) {
case 'ACTIVE': return 'bg-green-100 text-green-700'
case 'REQUIRES_REAUTH': return 'bg-yellow-100 text-yellow-800'
case 'ERROR': return 'bg-red-100 text-red-700'
case 'DEGRADED': return 'bg-amber-100 text-amber-800'
default: return 'bg-gray-100 text-gray-600'
}
}
@@ -286,10 +389,78 @@ function statusBadgeLabel(status) {
case 'ACTIVE': return 'Active'
case 'REQUIRES_REAUTH': return 'Reconnect needed'
case 'ERROR': return 'Error'
case 'DEGRADED': return 'Degraded'
default: return 'Not connected'
}
}
// ── D-12: Health state helpers ────────────────────────────────────────────────
/**
* Return the translated health state for a connection.
* Reads from the store's single connectionHealth map.
*/
function connectionHealthState(id) {
return store.getConnectionHealth
? store.getConnectionHealth(id)?.state ?? 'unknown'
: (store.connectionHealth?.[id]?.state ?? 'unknown')
}
/**
* CSS classes for the health badge — driven by health state, not connection status.
*/
function healthBadgeClasses(id) {
const state = connectionHealthState(id)
switch (state) {
case 'healthy': return 'bg-green-50 text-green-600'
case 'requires_reauth': return 'bg-yellow-50 text-yellow-700'
case 'degraded': return 'bg-amber-50 text-amber-700'
default: return 'bg-gray-50 text-gray-400'
}
}
/**
* Human-readable health badge label for Settings diagnostics.
*/
function healthBadgeLabel(id) {
const state = connectionHealthState(id)
switch (state) {
case 'healthy': return 'Healthy'
case 'requires_reauth': return 'Reauth required'
case 'degraded': return 'Unreachable'
default: return 'Not tested'
}
}
/**
* Return the health error message for a connection, if any.
*/
function healthErrorMessage(id) {
const h = store.getConnectionHealth
? store.getConnectionHealth(id)
: store.connectionHealth?.[id]
return h?.error_message ?? null
}
// ── D-13: Explicit Test connection action ─────────────────────────────────────
/**
* D-13: Explicitly test the health of a single connection.
* Called by the Test button — never as a side effect of navigation.
*/
async function handleTestConnection(id) {
testingId.value = id
try {
if (typeof store.testConnection === 'function') {
await store.testConnection(id)
} else if (typeof store.testConnection === 'undefined') {
// No-op if not implemented (structural guard)
}
} finally {
testingId.value = null
}
}
async function handleConnect(provider) {
if (OAUTH_PROVIDERS.has(provider.key)) {
oauthError.value = ''
@@ -338,6 +509,7 @@ async function handleDisconnectAll() {
}
async function handleConnected() {
// D-13: automatically test health after a successful connection
await store.fetchConnections()
}
@@ -100,6 +100,61 @@
</div>
</transition>
<!--
D-12 / D-14: Cloud health banner — shown in cloud mode when folderFreshness indicates
a connection issue (warning or requires_reauth). Keeps cached items visible below
while surfacing an actionable reconnect or reauthentication prompt.
D-13: Never shown as a result of ordinary folder navigation — only shown when the
server explicitly returns a non-fresh freshness state.
-->
<template v-if="mode === 'cloud'">
<!-- Warning state: provider temporarily unreachable — Reconnect affordance (D-12) -->
<div
v-if="folderFreshness === 'warning' || folderFreshness === 'stale'"
data-test="cloud-health-banner"
class="mx-4 sm:mx-6 mt-2 px-4 py-3 rounded-lg border border-amber-200 bg-amber-50 flex items-center justify-between gap-3"
role="alert"
>
<div class="flex items-center gap-2 min-w-0">
<AppIcon name="warning" class="w-4 h-4 text-amber-500 shrink-0" />
<p class="text-sm text-amber-800">
{{ folderFreshness === 'stale'
? 'Showing cached items — connection could not be verified.'
: 'Provider returned incomplete results. Showing cached items.' }}
</p>
</div>
<button
data-test="reconnect-action"
class="text-sm font-semibold text-amber-700 border border-amber-300 rounded-lg px-3 py-1.5 hover:bg-amber-100 active:bg-amber-200 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
@click="$emit('reconnect')"
>
Reconnect
</button>
</div>
<!-- Requires-reauth state: explicit reauthentication prompt (D-12 / CONN-02) -->
<div
v-else-if="folderFreshness === 'requires_reauth'"
data-test="requires-reauth"
class="mx-4 sm:mx-6 mt-2 px-4 py-3 rounded-lg border border-yellow-200 bg-yellow-50 flex items-center justify-between gap-3"
role="alert"
>
<div class="flex items-center gap-2 min-w-0">
<AppIcon name="warning" class="w-4 h-4 text-yellow-500 shrink-0" />
<p class="text-sm text-yellow-800">
Your connection needs to be re-authorized to access this storage.
</p>
</div>
<button
data-test="reconnect-action"
class="text-sm font-semibold text-yellow-700 border border-yellow-300 rounded-lg px-3 py-1.5 hover:bg-yellow-100 active:bg-yellow-200 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-500"
@click="$emit('reconnect')"
>
Reconnect
</button>
</div>
</template>
<div class="flex-1 overflow-y-auto flex flex-col">
<div class="px-4 sm:px-6 pt-5 pb-3">
@@ -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',
])
/**
@@ -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'
+179
View File
@@ -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,
}
})
+50 -1
View File
@@ -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.
*