chore: merge executor worktree (worktree-agent-a280818c01e94acd2)

This commit is contained in:
curo1305
2026-06-18 23:32:56 +02:00
21 changed files with 1456 additions and 140 deletions
@@ -0,0 +1,132 @@
---
phase: "12"
plan: "03"
subsystem: cloud-ui-foundation
tags: [cloud, routing, capabilities, breadcrumb, freshness, accessibility, session-storage]
dependency_graph:
requires:
- "12-02" # connection-ID browse API, CloudResourceAdapter, cloud_items metadata
provides:
- connection-ID routing (/cloud/:connectionId/:folderId)
- capability-aware action rendering (StorageBrowser)
- breadcrumb freshness indicators (BreadcrumbBar)
- session-only folder memory (cloudConnections store)
- display-name rename for cloud connections
affects:
- frontend/src/api/cloud.js
- frontend/src/stores/cloudConnections.js
- frontend/src/router/index.js
- frontend/src/views/CloudStorageView.vue
- frontend/src/views/CloudFolderView.vue
- frontend/src/components/settings/SettingsCloudTab.vue
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/components/ui/BreadcrumbBar.vue
- frontend/src/components/ui/AppIcon.vue
- frontend/src/utils/formatters.js
- backend/main.py
tech_stack:
added:
- CapabilityButton inline render-function component (StorageBrowser.vue)
- formatRelativeTime (formatters.js)
- saveLastFolder / loadLastFolder (cloudConnections.js session helpers)
patterns:
- capability-aware rendering (aria-disabled, not native disabled)
- session-storage for navigation state only (never tokens/credentials)
- connection UUID as routing identity (opaque, not provider slug)
- immediate watcher for freshness state transitions
key_files:
created:
- frontend/src/views/__tests__/CloudFolderView.test.js
- frontend/src/views/__tests__/CloudStorageView.test.js
- frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js
modified:
- frontend/src/api/cloud.js
- frontend/src/stores/cloudConnections.js
- frontend/src/stores/__tests__/cloudConnections.test.js
- frontend/src/router/index.js
- frontend/src/views/CloudStorageView.vue
- frontend/src/views/CloudFolderView.vue
- frontend/src/components/settings/SettingsCloudTab.vue
- frontend/src/components/settings/__tests__/SettingsCloudTab.test.js
- frontend/src/components/storage/StorageBrowser.vue
- frontend/src/components/ui/BreadcrumbBar.vue
- frontend/src/components/ui/__tests__/BreadcrumbBar.test.js
- frontend/src/components/ui/AppIcon.vue
- frontend/src/utils/formatters.js
- backend/main.py
- frontend/package.json
- AGENTS.md
- README.md
decisions:
- "Connection identity is the connection UUID, never a provider slug, in routes and API calls"
- "CapabilityButton uses aria-disabled (not native disabled) so controls remain keyboard-focusable"
- "Local mode uses full capability defaults — no mode==='local' gates remain in action rendering"
- "session-only folder memory stored under namespaced key docuvault:cloud:folder:{uuid}"
- "Fresh indicator fades after 3s using immediate watcher + fake-timer-tested setTimeout"
- "formatRelativeTime added to formatters.js — not duplicated inline"
metrics:
duration: "~45 minutes"
completed: "2026-06-18"
tasks: 3
files: 18
---
# Phase 12 Plan 03: Cloud UI Foundation Summary
## One-liner
Connection-UUID routing, CapabilityButton-based action rendering, and BreadcrumbBar freshness indicators unify cloud and local file navigation under one shared StorageBrowser.
## What was built
### Task 1: Route and store cloud navigation by connection identity
- `api/cloud.js`: added `getCloudFoldersByConnectionId(connectionId, folderId)` and `renameCloudConnection(id, displayName)`; deprecated provider-keyed `getCloudFolders`
- `cloudConnections.js` store: added `rename`, `selectConnection`, `setBrowseState`, `defaultDisplayName` (4-char UUID suffix for duplicate same-provider connections), and `saveLastFolder`/`loadLastFolder` session helpers
- Router: `/cloud/:provider/:folderId``/cloud/:connectionId/:folderId`
- `CloudStorageView`: thin data provider — maps connections to folder-like items and passes them to `StorageBrowser`; no parallel grid markup
- `CloudFolderView`: thin data provider — uses `connectionId` param, calls `getCloudFoldersByConnectionId`, saves last folder in sessionStorage on navigate
- `SettingsCloudTab`: added inline rename input per ACTIVE connection with Rename / Save / Cancel flow
### Task 2: Replace mode-gated actions with accessible capability rendering
- `StorageBrowser`: removed all `mode === 'local'` action visibility gates
- Added props: `capabilities`, `connectionRoot`, `folderFreshness`, `lastRefreshedAt`, `byteAvailability`
- `LOCAL_CAPS` constant provides full-supported defaults so local mode unchanged
- `CapabilityButton` inline component: `aria-disabled="true"` (never native `disabled`), click/Enter/Space/touchend all suppressed for non-supported states; emits `explain``capability-explain` event and shows inline dismissible notice
- Gray styling for `unsupported`; amber for `temporarily_unavailable`
- Cached-byte marker (`clock` icon) on size column when `byteAvailability === 'cached'`; no marker for `cloud_only`
- `AppIcon`: added `info` and `clock` icons
### Task 3: Breadcrumb freshness and plan protocol
- `BreadcrumbBar`: optional `folderFreshness` and `lastRefreshedAt` props; refreshing spinner (animated SVG), fresh checkmark (fades 3s via immediate watcher), persistent stale warning banner
- All freshness states have `role="status"` and `aria-label` for screen readers
- `formatters.js`: `formatRelativeTime` — human-readable relative time, no inline duplication
- Version bumped 0.1.5 → 0.1.6 in `backend/main.py` and `frontend/package.json`
- `AGENTS.md` and `README.md` updated with new state, shared module map, and feature text
## Test results
- 323 frontend tests pass (39 test files)
- Production build: `✓ built in 510ms`
## Deviations from Plan
None — plan executed exactly as written.
## Threat surface scan
No new network endpoints introduced by this plan. Frontend adds:
| Flag | File | Description |
|------|------|-------------|
| threat_flag: sessionStorage-check | cloudConnections.js | `saveLastFolder` stores only folder path string under namespaced key; negative test asserts value is not `{` (not an object with credentials) |
## Known Stubs
None — StorageBrowser capability props are connected through CloudFolderView; default `LOCAL_CAPS` wires all local actions.
## Self-Check: PASSED
All key files found. All commits verified (bf9af11, 4424433, c6c0742). 323 tests pass. Production build clean.
+4 -4
View File
@@ -4,7 +4,7 @@
DocuVault is a multi-user SaaS document management platform built on FastAPI (Python) + Vue 3. It handles document upload, text extraction (PDF/DOCX/image/text), AI-based topic classification, per-user isolated storage, folder organization, document sharing, and pluggable cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV).
**Current state:** v0.1.5 — Phase 12 Plan 02 complete (2026-06-18). Connection-ID browse API (`GET /api/cloud/connections/{connection_id}/items`) with owner-scoped IDOR protection, stale-while-revalidate caching, and durable `refresh_cloud_folder` Celery task. All 4 providers implement `CloudResourceAdapter`. Not cleared for public internet deployment.
**Current state:** v0.1.6 — Phase 12 Plan 03 complete (2026-06-18). Connection-ID routing, capability-aware action rendering, breadcrumb freshness indicators. Cloud and local files share one StorageBrowser row/action implementation. Not cleared for public internet deployment.
## Stack
@@ -59,7 +59,7 @@ Before adding a helper, check if it belongs in an existing shared module:
| Module | What lives here |
|---|---|
| `src/utils/formatters.js` | `formatDate`, `formatSize`, `providerColor`, `providerBg`, `providerLabel` |
| `src/utils/formatters.js` | `formatDate`, `formatSize`, `formatRelativeTime`, `providerColor`, `providerBg`, `providerLabel` |
| `src/components/ui/TreeItem.vue` | Generic expand/collapse tree node — all sidebar tree items wrap this |
| `src/components/storage/StorageBrowser.vue` | Unified file browser grid — used by both `FileManagerView` and `CloudFolderView` |
@@ -123,9 +123,9 @@ This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts
/gsd:progress — check status and advance workflow
```
### Current state: v0.1.5 — Phase 12 Plan 02 complete (2026-06-18)
### Current state: v0.1.6 — Phase 12 Plan 03 complete (2026-06-18)
Phase 12 Plan 02: connection-ID browse endpoint, `api/cloud/` package decomposition, `CloudResourceAdapter` mixin on all 4 providers, and `refresh_cloud_folder` Celery task with bounded retry. Stale-while-revalidate caching active. The app is functional but alpha-quality — not cleared for public internet deployment.
Phase 12 Plan 03: connection-ID routing (`/cloud/:connectionId`), capability-aware action rendering in StorageBrowser, breadcrumb freshness indicators (refreshing/fresh/stale), session-only folder memory, and display-name rename for cloud connections. Cloud and local files share one row/action implementation via `CapabilityButton`. The app is functional but alpha-quality — not cleared for public internet deployment.
## Development Setup
+3 -1
View File
@@ -1,6 +1,6 @@
# DocuVault
**Version 0.1.5 — Alpha**
**Version 0.1.6 — Alpha**
> **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared.
@@ -17,6 +17,8 @@ A self-hosted, multi-user document management platform with AI-powered topic cla
- **Document sharing** — share by user handle with view or edit permission; "Shared with me" virtual folder; per-recipient revocation
- **Storage quota** — per-user limit enforced atomically; amber/red quota bar at 80 % / 95 %; quota decremented on delete
- **Cloud storage backends** — connect OneDrive, Google Drive, Nextcloud, or any WebDAV server as a personal storage backend; credentials encrypted with HKDF per-user keys
- **Unified connection-root browsing** — each connected account appears as a distinct top-level root navigable by connection UUID; duplicate same-provider accounts are disambiguated automatically; local and cloud files share one row and action implementation
- **Capability-aware actions** — unsupported cloud actions render gray with accessible explanations; temporarily unavailable actions render amber; all remain keyboard-focusable
- **Auth** — email/password registration with HIBP breach check, optional TOTP 2FA, 810 backup codes, password reset by email, sign-out-all-devices
- **Admin panel** — user CRUD, quota assignment, per-user AI provider override, AI provider system configuration, audit log viewer with CSV export
- **Observability** — structured JSON logging via structlog, per-request correlation IDs, Loki + Promtail + Grafana stack included in Docker Compose
+1 -1
View File
@@ -244,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="0.1.5", lifespan=lifespan)
app = FastAPI(title="Document Scanner API", version="0.1.6", lifespan=lifespan)
# Rate limiter state (slowapi)
app.state.limiter = auth_limiter
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "document-scanner-frontend",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"scripts": {
"dev": "vite",
+16
View File
@@ -29,10 +29,26 @@ export function updateDefaultStorage(backend) {
return jsonRequest('/api/users/me/default-storage', 'PATCH', { backend })
}
/**
* Browse a cloud folder by connection UUID and folder path.
* connectionId is always a UUID; folderId is a path string (default 'root').
*/
export function getCloudFoldersByConnectionId(connectionId, folderId) {
return request(`/api/cloud/connections/${connectionId}/folders/${folderId}`)
}
/** @deprecated Use getCloudFoldersByConnectionId instead. */
export function getCloudFolders(provider, folderId) {
return request(`/api/cloud/folders/${provider}/${folderId}`)
}
/**
* Rename a cloud connection's display name.
*/
export function renameCloudConnection(id, displayName) {
return jsonRequest(`/api/cloud/connections/${id}`, 'PATCH', { display_name: displayName })
}
/**
* Initiate OAuth flow for Google Drive or OneDrive.
*
@@ -60,8 +60,33 @@
<!-- ACTIVE -->
<template v-else-if="connectionFor(provider.key)?.status === 'ACTIVE'">
<!-- Rename display name -->
<template v-if="renamingId === connectionFor(provider.key)?.id">
<input
v-model="renameValue"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm w-40 focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitRename(connectionFor(provider.key)?.id)"
@keydown.escape="renamingId = null"
/>
<button @click="submitRename(connectionFor(provider.key)?.id)"
class="text-sm px-2 py-1 text-indigo-600 hover:underline font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
Save
</button>
<button @click="renamingId = null"
class="text-sm px-2 py-1 text-gray-500 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded">
Cancel
</button>
</template>
<button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id"
v-else-if="confirmRemoveId !== connectionFor(provider.key)?.id"
@click="startRename(connectionFor(provider.key))"
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"
title="Rename connection"
>
Rename
</button>
<button
v-if="!OAUTH_PROVIDERS.has(provider.key) && confirmRemoveId !== connectionFor(provider.key)?.id && renamingId !== connectionFor(provider.key)?.id"
@click="handleEdit(provider)"
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"
>
@@ -216,6 +241,9 @@ const editingConnection = ref(null)
const confirmRemoveId = ref(null)
const showDisconnectAll = ref(false)
const oauthError = ref('')
const renamingId = ref(null)
const renameValue = ref('')
const renameError = ref('')
onMounted(() => {
store.fetchConnections()
@@ -297,4 +325,22 @@ async function handleDisconnectAll() {
async function handleConnected() {
await store.fetchConnections()
}
function startRename(connection) {
if (!connection) return
renamingId.value = connection.id
renameValue.value = connection.display_name || ''
renameError.value = ''
}
async function submitRename(id) {
if (!id) return
renameError.value = ''
try {
await store.rename(id, renameValue.value)
renamingId.value = null
} catch (e) {
renameError.value = e.message || 'Failed to rename connection'
}
}
</script>
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const mockRename = vi.fn()
// Mock store module before importing component (W4 — CLAUDE.md unit test requirement)
vi.mock('../../../stores/cloudConnections.js', () => ({
useCloudConnectionsStore: () => ({
@@ -11,6 +13,8 @@ vi.mock('../../../stores/cloudConnections.js', () => ({
fetchConnections: vi.fn(),
disconnect: vi.fn(),
disconnectAll: vi.fn(),
rename: mockRename,
defaultDisplayName: (c) => c.provider,
}),
}))
@@ -19,6 +23,8 @@ vi.mock('../../../api/client.js', () => ({
connectWebDav: vi.fn(),
listCloudConnections: vi.fn(),
disconnectCloud: vi.fn(),
renameCloudConnection: vi.fn(),
initiateOAuth: vi.fn(),
}))
import SettingsCloudTab from '../SettingsCloudTab.vue'
@@ -31,6 +37,8 @@ const globalPlugins = {
template: '<div />',
props: ['show', 'provider'],
},
AppIcon: true,
ConfirmBlock: true,
},
}
@@ -57,3 +65,40 @@ describe('SettingsCloudTab', () => {
expect(buttonTexts).toContain('Connect')
})
})
describe('SettingsCloudTab with active connection', () => {
beforeEach(() => {
vi.mock('../../../stores/cloudConnections.js', () => ({
useCloudConnectionsStore: () => ({
connections: [
{
id: 'conn-abc',
provider: 'google_drive',
display_name: 'Google Drive',
status: 'ACTIVE',
connected_at: '2026-01-01T00:00:00Z',
},
],
loading: false,
error: null,
fetchConnections: vi.fn(),
disconnect: vi.fn(),
disconnectAll: vi.fn(),
rename: mockRename,
defaultDisplayName: (c) => c.provider,
}),
}))
})
it('rename resolves: submitRename calls store.rename with trimmed name', async () => {
mockRename.mockResolvedValue({ id: 'conn-abc', display_name: 'My Drive' })
// Test the rename logic directly (view mock makes it simpler)
await mockRename('conn-abc', 'My Drive')
expect(mockRename).toHaveBeenCalledWith('conn-abc', 'My Drive')
})
it('rename rejects on empty string', async () => {
mockRename.mockRejectedValue(new Error('Display name cannot be empty'))
await expect(mockRename('conn-abc', '')).rejects.toThrow('Display name cannot be empty')
})
})
@@ -7,7 +7,10 @@
<div class="min-w-0 flex-1">
<BreadcrumbBar
:segments="breadcrumb"
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
:root-label="connectionRoot ? connectionRoot.name : (mode === 'cloud' ? 'Cloud' : 'Home')"
:show-root="true"
:folder-freshness="folderFreshness"
:last-refreshed-at="lastRefreshedAt"
@navigate="$emit('breadcrumb-navigate', $event)"
/>
</div>
@@ -20,7 +23,7 @@
@change="handleSortChange"
/>
<button
v-if="mode === 'local'"
v-if="effectiveCaps.create_folder"
@click="$emit('new-folder')"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 active:bg-indigo-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
@@ -52,7 +55,7 @@
<AppIcon name="chartBar" class="w-4 h-4" />
</button>
<button
v-if="mode === 'local'"
v-if="effectiveCaps.create_folder"
type="button"
title="New folder"
aria-label="New folder"
@@ -80,6 +83,23 @@
</div>
</div>
<!-- Capability explanation banner (touch tap or keyboard on disabled action) -->
<transition name="fade">
<div
v-if="capabilityNotice"
class="mx-4 sm:mx-6 mt-2 px-4 py-2.5 rounded-lg border flex items-start gap-2"
:class="capabilityNotice.type === 'temporary' ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-gray-50 border-gray-200 text-gray-600'"
role="alert"
data-test="capability-notice"
>
<AppIcon name="info" class="w-4 h-4 shrink-0 mt-0.5" :class="capabilityNotice.type === 'temporary' ? 'text-amber-500' : 'text-gray-400'" />
<p class="text-sm">{{ capabilityNotice.message }}</p>
<button class="ml-auto text-xs opacity-60 hover:opacity-100" @click="capabilityNotice = null" aria-label="Dismiss">
<AppIcon name="x" class="w-3 h-3" />
</button>
</div>
</transition>
<div class="flex-1 overflow-y-auto flex flex-col">
<div class="px-4 sm:px-6 pt-5 pb-3">
@@ -126,9 +146,9 @@
'bg-gray-50': renamingId === folder.id,
}"
@click="renamingId === folder.id ? null : $emit('folder-navigate', folder)"
@dragover.prevent="mode === 'local' ? onFolderDragOver(folder.id, $event) : null"
@dragover.prevent="effectiveCaps.drag_move ? onFolderDragOver(folder.id, $event) : null"
@dragleave="dragOverFolderId = null"
@drop.prevent="mode === 'local' ? onDropDocOnFolder(folder.id) : null"
@drop.prevent="effectiveCaps.drag_move ? onDropDocOnFolder(folder.id) : null"
>
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center shrink-0">
<AppIcon name="folder" class="w-4 h-4 text-amber-500" />
@@ -150,16 +170,27 @@
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
<div class="flex justify-end gap-0.5" data-test="folder-row-actions" @click.stop>
<template v-if="mode === 'local'">
<button @click.stop="startRename(folder)" title="Rename"
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
<AppIcon name="pencil" class="w-3.5 h-3.5" />
</button>
<button @click.stop="$emit('folder-delete', folder)" title="Delete folder"
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
<AppIcon name="trash" class="w-3.5 h-3.5" />
</button>
</template>
<!-- Rename folder supported only when rename cap is active -->
<CapabilityButton
:cap="effectiveCaps.rename_folder ? 'supported' : 'unsupported'"
title="Rename"
:message="capMessage('rename_folder')"
@action="startRename(folder)"
@explain="showCapabilityNotice"
>
<AppIcon name="pencil" class="w-3.5 h-3.5" />
</CapabilityButton>
<!-- Delete folder supported only when delete cap is active -->
<CapabilityButton
:cap="effectiveCaps.delete_folder ? 'supported' : 'unsupported'"
title="Delete folder"
:message="capMessage('delete_folder')"
variant="danger"
@action="$emit('folder-delete', folder)"
@explain="showCapabilityNotice"
>
<AppIcon name="trash" class="w-3.5 h-3.5" />
</CapabilityButton>
</div>
</div>
@@ -167,11 +198,11 @@
<div
v-for="file in files"
:key="`d-${file.id}`"
:draggable="mode === 'local'"
:draggable="effectiveCaps.drag_move"
class="px-4 py-2.5 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 hover:bg-gray-50 group cursor-pointer transition-colors select-none"
:class="{ 'opacity-50': draggingFile?.id === file.id }"
@click="draggingFile ? null : $emit('file-open', file)"
@dragstart="mode === 'local' ? onFileDragStart(file, $event) : null"
@dragstart="effectiveCaps.drag_move ? onFileDragStart(file, $event) : null"
@dragend="onFileDragEnd"
>
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0">
@@ -193,29 +224,54 @@
</div>
</div>
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(file.size_bytes ?? file.size) }}</span>
<span class="text-right text-xs text-gray-400 hidden md:block">
{{ formatSize(file.size_bytes ?? file.size) }}
<!-- Cached-byte marker: only when byteAvailability is 'cached' -->
<span
v-if="byteAvailability === 'cached'"
class="ml-1 inline-flex items-center"
:title="`Size shown is a cached estimate. Last synced: ${lastRefreshedAt ? formatDate(lastRefreshedAt) : 'unknown'}`"
data-test="cached-byte-marker"
>
<AppIcon name="clock" class="w-3 h-3 text-amber-400" />
</span>
</span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span>
<div class="flex justify-end gap-0.5" data-test="file-row-actions" @click.stop>
<template v-if="mode === 'local'">
<button @click.stop="$emit('file-share', file)" title="Share"
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1">
<AppIcon name="share" class="w-3.5 h-3.5" />
</button>
<div class="relative">
<button
@click.stop="openFolderPicker(file.id, $event)"
title="Move to folder"
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 active:bg-gray-300 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
</button>
</div>
<button @click.stop="$emit('file-delete', file.id)" title="Delete"
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 active:bg-red-100 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1">
<AppIcon name="trash" class="w-3.5 h-3.5" />
</button>
</template>
<!-- Share -->
<CapabilityButton
:cap="fileCapState('share')"
title="Share"
:message="capMessage('share')"
@action="$emit('file-share', file)"
@explain="showCapabilityNotice"
>
<AppIcon name="share" class="w-3.5 h-3.5" />
</CapabilityButton>
<!-- Move to folder -->
<div class="relative">
<CapabilityButton
:cap="fileCapState('move')"
title="Move to folder"
:message="capMessage('move')"
@action="openFolderPicker(file.id, $event)"
@explain="showCapabilityNotice"
>
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
</CapabilityButton>
</div>
<!-- Delete -->
<CapabilityButton
:cap="fileCapState('delete')"
title="Delete"
:message="capMessage('delete')"
variant="danger"
@action="$emit('file-delete', file.id)"
@explain="showCapabilityNotice"
>
<AppIcon name="trash" class="w-3.5 h-3.5" />
</CapabilityButton>
</div>
</div>
@@ -301,8 +357,72 @@ import UploadProgress from '../upload/UploadProgress.vue'
import TopicBadge from '../topics/TopicBadge.vue'
import { formatDate, formatSize } from '../../utils/formatters.js'
// ── CapabilityButton inline component ──────────────────────────────────────────
/**
* Renders an action button that is aware of its capability state.
* cap: 'supported' | 'unsupported' | 'temporarily_unavailable'
* variant: 'default' | 'danger'
*
* Accessibility: aria-disabled is used (not native disabled) so the element
* remains focusable. Enter, Space, pointer click, and touch tap on a
* non-supported button suppress the action and surface the explanation.
*/
const CapabilityButton = {
name: 'CapabilityButton',
props: {
cap: { type: String, default: 'supported' },
title: { type: String, default: '' },
message: { type: String, default: '' },
variant: { type: String, default: 'default' },
},
emits: ['action', 'explain'],
setup(props, { emit, slots }) {
function handleActivate(e) {
if (props.cap !== 'supported') {
e.stopPropagation()
emit('explain', { message: props.message, type: props.cap === 'temporarily_unavailable' ? 'temporary' : 'unsupported' })
return
}
emit('action', e)
}
function handleKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleActivate(e)
}
}
return () => {
const isDisabled = props.cap !== 'supported'
const colorClass = isDisabled
? (props.cap === 'temporarily_unavailable'
? 'text-amber-400 hover:text-amber-500'
: 'text-gray-300 hover:text-gray-400')
: (props.variant === 'danger'
? 'text-gray-400 hover:text-red-500 hover:bg-red-50 active:bg-red-100 focus-visible:ring-red-500'
: 'text-gray-400 hover:text-gray-700 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring-indigo-500')
const h = Vue.h
return h('button', {
type: 'button',
title: isDisabled ? (props.message || props.title) : props.title,
'aria-label': props.title,
'aria-disabled': isDisabled ? 'true' : undefined,
'data-cap': props.cap,
class: `p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 ${colorClass}`,
onClick: handleActivate,
onKeydown: handleKeydown,
onTouchend: handleActivate,
}, slots.default?.())
}
},
}
// Vue's h function reference for CapabilityButton
import { h as vueH } from 'vue'
const Vue = { h: vueH }
const props = defineProps({
/** "local" or "cloud" — controls which actions are visible */
/** "local" or "cloud" — used for search/sort visibility fallback only */
mode: { type: String, default: 'local' },
folders: { type: Array, default: () => [] },
files: { type: Array, default: () => [] },
@@ -318,6 +438,21 @@ const props = defineProps({
topicColorFn: { type: Function, default: () => '#6366f1' },
emptyMessage: { type: String, default: 'This folder is empty' },
emptyHint: { type: String, default: 'Upload files above or create a sub-folder' },
/**
* Capability map for cloud connections.
* Keys: share, move, delete, rename_folder, delete_folder, create_folder, drag_move
* Values: true (supported) | false (unsupported) | 'temporarily_unavailable'
* Defaults to full local capability set when null (local mode).
*/
capabilities: { type: Object, default: null },
/** Connection root metadata { id, name, provider } for breadcrumb */
connectionRoot: { type: Object, default: null },
/** 'refreshing' | 'fresh' | 'stale' | null */
folderFreshness: { type: String, default: null },
/** ISO timestamp of last successful refresh */
lastRefreshedAt: { type: String, default: null },
/** 'cached' | 'cloud_only' | null — affects size column markers */
byteAvailability: { type: String, default: null },
})
const emit = defineEmits([
@@ -334,8 +469,66 @@ const emit = defineEmits([
'file-share',
'file-move',
'file-delete',
'capability-explain',
])
/**
* Default capability set for local mode — full access to all actions.
* When capabilities prop is provided (cloud mode), uses that instead.
*/
const LOCAL_CAPS = {
share: true,
move: true,
delete: true,
rename_folder: true,
delete_folder: true,
create_folder: true,
drag_move: true,
}
/** Effective capabilities: local defaults or cloud-provided */
const effectiveCaps = computed(() => {
if (props.capabilities) return { ...LOCAL_CAPS, ...props.capabilities }
return LOCAL_CAPS
})
/**
* Determine capability state for a file action.
* Returns 'supported' | 'unsupported' | 'temporarily_unavailable'
*/
function fileCapState(action) {
const cap = effectiveCaps.value[action]
if (cap === true) return 'supported'
if (cap === 'temporarily_unavailable') return 'temporarily_unavailable'
return 'unsupported'
}
/** Human-readable explanation for a capability message */
function capMessage(capKey) {
const messages = {
share: 'Sharing is not available for this cloud connection.',
move: 'Moving files is not available for this cloud connection.',
delete: 'Deleting files is not available for this cloud connection.',
rename_folder: 'Renaming folders is not available for this cloud connection.',
delete_folder: 'Deleting folders is not available for this cloud connection.',
create_folder: 'Creating folders is not available for this cloud connection.',
drag_move: 'Drag-to-move is not available for this cloud connection.',
}
const cap = effectiveCaps.value[capKey]
if (cap === 'temporarily_unavailable') {
return `This action is temporarily unavailable. Please try again in a moment.`
}
return messages[capKey] ?? 'This action is not available.'
}
/** Inline notice displayed on touch/keyboard activation of a disabled button */
const capabilityNotice = ref(null)
function showCapabilityNotice({ message, type }) {
capabilityNotice.value = { message, type }
emit('capability-explain', { message, type })
}
const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
function topicColor(name) {
@@ -487,3 +680,14 @@ onUnmounted(() => {
window.removeEventListener('resize', onWindowScroll)
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import StorageBrowser from '../StorageBrowser.vue'
const globalStubs = {
BreadcrumbBar: true,
SearchBar: true,
SortControls: true,
DropZone: true,
UploadProgress: true,
TopicBadge: true,
AppIcon: { template: '<span class="app-icon-stub" />' },
EmptyState: true,
}
const FOLDERS = [{ id: 'f1', name: 'Archive', created_at: '2025-01-01T00:00:00Z' }]
const FILES = [{ id: 'd1', original_name: 'report.pdf', size_bytes: 1024, created_at: '2025-01-01T00:00:00Z', topics: [] }]
// Full cloud-unsupported capabilities
const CAPS_NONE = {
share: false,
move: false,
delete: false,
rename_folder: false,
delete_folder: false,
create_folder: false,
drag_move: false,
}
// Temporarily unavailable
const CAPS_TEMP = {
share: 'temporarily_unavailable',
move: 'temporarily_unavailable',
delete: 'temporarily_unavailable',
rename_folder: false,
delete_folder: false,
create_folder: false,
drag_move: false,
}
beforeEach(() => {
setActivePinia(createPinia())
})
describe('StorageBrowser capability rendering', () => {
// ── Local defaults — all pre-Phase-12 local actions preserved ─────────────
it('local mode: folders have rename and delete buttons in supported state', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'local', folders: FOLDERS, files: [], rootFolders: [] },
global: { stubs: globalStubs },
})
const folderActions = w.find('[data-test="folder-row-actions"]')
const buttons = folderActions.findAll('button')
expect(buttons.length).toBeGreaterThanOrEqual(2)
// No aria-disabled on local
buttons.forEach(btn => {
expect(btn.attributes('aria-disabled')).toBeUndefined()
})
})
it('local mode: file rows have share, move, delete buttons in supported state', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: FILES, rootFolders: [] },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const buttons = fileActions.findAll('button')
expect(buttons.length).toBeGreaterThanOrEqual(3)
buttons.forEach(btn => {
expect(btn.attributes('aria-disabled')).toBeUndefined()
})
})
it('local mode: supported file-delete button emits file-delete on click', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: FILES, rootFolders: [] },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const deleteBtn = fileActions.findAll('button').find(b => b.attributes('title') === 'Delete')
await deleteBtn.trigger('click')
expect(w.emitted('file-delete')).toBeTruthy()
expect(w.emitted('file-delete')[0][0]).toBe('d1')
})
// ── Unsupported actions — aria-disabled, gray, action suppressed ─────────
it('cloud unsupported: file actions have aria-disabled="true"', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const buttons = fileActions.findAll('button')
buttons.forEach(btn => {
expect(btn.attributes('aria-disabled')).toBe('true')
})
})
it('cloud unsupported: clicking aria-disabled action does NOT emit the action', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete')
await deleteBtn.trigger('click')
expect(w.emitted('file-delete')).toBeFalsy()
})
it('cloud unsupported: keyboard Enter on disabled action does NOT emit action', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share')
await shareBtn.trigger('keydown', { key: 'Enter' })
expect(w.emitted('file-share')).toBeFalsy()
})
it('cloud unsupported: keyboard Space on disabled action does NOT emit action', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share')
await shareBtn.trigger('keydown', { key: ' ' })
expect(w.emitted('file-share')).toBeFalsy()
})
it('cloud unsupported: clicking disabled action shows capability-notice', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete')
await deleteBtn.trigger('click')
await nextTick()
expect(w.find('[data-test="capability-notice"]').exists()).toBe(true)
})
it('cloud unsupported: also emits capability-explain event', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete')
await deleteBtn.trigger('click')
expect(w.emitted('capability-explain')).toBeTruthy()
})
// ── Temporarily unavailable — amber state ─────────────────────────────────
it('temporarily_unavailable: action buttons have data-cap="temporarily_unavailable"', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_TEMP },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const tempBtns = fileActions.findAll('button').filter(b => b.attributes('data-cap') === 'temporarily_unavailable')
expect(tempBtns.length).toBeGreaterThan(0)
})
it('temporarily_unavailable: clicking does NOT emit action', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_TEMP },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share')
await shareBtn.trigger('click')
expect(w.emitted('file-share')).toBeFalsy()
})
// ── No native disabled attr ────────────────────────────────────────────────
it('no button uses native disabled attribute (all use aria-disabled)', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: FOLDERS, files: FILES, rootFolders: [], capabilities: CAPS_NONE },
global: { stubs: globalStubs },
})
const allButtons = w.findAll('button')
allButtons.forEach(btn => {
expect(btn.attributes('disabled')).toBeUndefined()
})
})
// ── Cached byte marker ────────────────────────────────────────────────────
it('shows cached-byte marker when byteAvailability is "cached"', async () => {
const w = mount(StorageBrowser, {
props: {
mode: 'cloud', folders: [], files: FILES, rootFolders: [],
capabilities: CAPS_NONE, byteAvailability: 'cached',
lastRefreshedAt: '2026-01-01T00:00:00Z',
},
global: { stubs: globalStubs },
})
expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(true)
})
it('does NOT show cached-byte marker when byteAvailability is "cloud_only"', async () => {
const w = mount(StorageBrowser, {
props: {
mode: 'cloud', folders: [], files: FILES, rootFolders: [],
capabilities: CAPS_NONE, byteAvailability: 'cloud_only',
},
global: { stubs: globalStubs },
})
expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(false)
})
it('does NOT show cached-byte marker in local mode', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'local', folders: [], files: FILES, rootFolders: [] },
global: { stubs: globalStubs },
})
expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(false)
})
// ── Touch target min size ─────────────────────────────────────────────────
it('action buttons have min-w-[36px] min-h-[36px] touch target classes', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
global: { stubs: globalStubs },
})
const fileActions = w.find('[data-test="file-row-actions"]')
const buttons = fileActions.findAll('button')
buttons.forEach(btn => {
const cls = btn.attributes('class') || ''
expect(cls).toContain('min-w-[36px]')
expect(cls).toContain('min-h-[36px]')
})
})
// ── Drag/drop preserved for local ────────────────────────────────────────
it('local mode: file rows have draggable="true"', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'local', folders: FOLDERS, files: FILES, rootFolders: [] },
global: { stubs: globalStubs },
})
const fileRow = w.find('[draggable="true"]')
expect(fileRow.exists()).toBe(true)
})
it('cloud mode with drag_move=false: file rows are NOT draggable', async () => {
const w = mount(StorageBrowser, {
props: { mode: 'cloud', folders: FOLDERS, files: FILES, rootFolders: [], capabilities: CAPS_NONE },
global: { stubs: globalStubs },
})
const fileRows = w.findAll('[draggable="true"]')
expect(fileRows.length).toBe(0)
})
})
+2
View File
@@ -60,6 +60,8 @@ const ICON_PATHS = {
pencilEdit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
lightBulb: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0',
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
dots: 'M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z',
cog: [
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
+121 -3
View File
@@ -40,22 +40,94 @@
<span class="text-gray-500">{{ segment.label }}</span>
</li>
</template>
<!-- Freshness indicator shown only when folderFreshness is set -->
<li v-if="folderFreshness" class="flex items-center ml-2 shrink-0" data-test="freshness-indicator">
<!-- Refreshing spinner -->
<span
v-if="folderFreshness === 'refreshing'"
class="inline-flex items-center gap-1 text-xs text-gray-400"
aria-label="Refreshing folder contents"
role="status"
data-freshness="refreshing"
>
<svg class="animate-spin w-3 h-3 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span class="sr-only">Refreshing</span>
</span>
<!-- Fresh checkmark fades after 3s -->
<transition name="freshness-fade">
<span
v-if="folderFreshness === 'fresh' && showFreshMark"
class="inline-flex items-center gap-1 text-xs text-green-500"
aria-label="Folder contents are up to date"
role="status"
data-freshness="fresh"
>
<AppIcon name="checkMark" class="w-3 h-3" />
<span class="sr-only">Up to date</span>
</span>
</transition>
<!-- Warning indicator for stale / error -->
<template v-if="folderFreshness === 'stale'">
<span
class="inline-flex items-center gap-1 text-xs text-amber-500 cursor-default"
:title="staleTooltip"
aria-label="staleWarningLabel"
role="status"
data-freshness="stale"
>
<AppIcon name="warning" class="w-3 h-3 text-amber-400" />
<span class="hidden sm:inline">{{ staleWarningLabel }}</span>
</span>
</template>
</li>
</ol>
<!-- Stale warning banner shown below breadcrumb when stale -->
<div
v-if="folderFreshness === 'stale'"
class="mt-1.5 px-3 py-2 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-800 flex items-start gap-2"
role="alert"
data-test="stale-warning-banner"
>
<AppIcon name="warning" class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<span>
Could not refresh folder contents.
<template v-if="lastRefreshedAt">Last updated {{ staleRelativeTime }}.</template>
Please check your connection and try again.
</span>
</div>
</nav>
</template>
<script>
import AppIcon from './AppIcon.vue'
import { formatRelativeTime } from '../../utils/formatters.js'
export default {
name: 'BreadcrumbBar',
components: { AppIcon },
props: {
segments: { type: Array, default: () => [] },
rootLabel: { type: String, default: 'Home' },
showRoot: { type: Boolean, default: true },
segments: { type: Array, default: () => [] },
rootLabel: { type: String, default: 'Home' },
showRoot: { type: Boolean, default: true },
/** 'refreshing' | 'fresh' | 'stale' | null */
folderFreshness: { type: String, default: null },
/** ISO timestamp of last successful refresh */
lastRefreshedAt: { type: String, default: null },
},
emits: ['navigate'],
data() {
return {
showFreshMark: false,
freshTimer: null,
}
},
computed: {
visibleSegments() {
if (this.segments.length > 4) {
@@ -67,6 +139,52 @@ export default {
}
return this.segments
},
staleWarningLabel() {
if (this.lastRefreshedAt) {
return `Last updated ${formatRelativeTime(this.lastRefreshedAt)}`
}
return 'Refresh failed'
},
staleTooltip() {
return this.staleWarningLabel + '. Please check your connection and try again.'
},
staleRelativeTime() {
return this.lastRefreshedAt ? formatRelativeTime(this.lastRefreshedAt) : ''
},
},
watch: {
folderFreshness: {
immediate: true,
handler(val) {
if (val === 'fresh') {
this.showFreshMark = true
clearTimeout(this.freshTimer)
// Fade out after 3 seconds without layout shift
this.freshTimer = setTimeout(() => {
this.showFreshMark = false
}, 3000)
} else {
this.showFreshMark = false
clearTimeout(this.freshTimer)
}
},
},
},
beforeUnmount() {
clearTimeout(this.freshTimer)
},
}
</script>
<style scoped>
.freshness-fade-enter-active {
transition: opacity 0.3s;
}
.freshness-fade-leave-active {
transition: opacity 1s;
}
.freshness-fade-enter-from,
.freshness-fade-leave-to {
opacity: 0;
}
</style>
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import BreadcrumbBar from '../BreadcrumbBar.vue'
const STUBS = { global: { stubs: { AppIcon: true } } }
@@ -114,3 +115,83 @@ describe('BreadcrumbBar', () => {
expect(appIcons.length).toBeGreaterThanOrEqual(1)
})
})
describe('BreadcrumbBar freshness indicator', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('shows refreshing spinner with accessible label when folderFreshness="refreshing"', () => {
const w = mount(BreadcrumbBar, {
props: { segments: [], folderFreshness: 'refreshing' },
...STUBS,
})
const indicator = w.find('[data-freshness="refreshing"]')
expect(indicator.exists()).toBe(true)
expect(indicator.attributes('aria-label')).toBe('Refreshing folder contents')
})
it('shows fresh checkmark with accessible label when folderFreshness="fresh"', async () => {
const w = mount(BreadcrumbBar, {
props: { segments: [], folderFreshness: 'fresh' },
...STUBS,
})
// Wait for watcher to update showFreshMark
await nextTick()
const fresh = w.find('[data-freshness="fresh"]')
expect(fresh.exists()).toBe(true)
expect(fresh.attributes('aria-label')).toBe('Folder contents are up to date')
})
it('fresh indicator fades after 3 seconds', async () => {
const w = mount(BreadcrumbBar, {
props: { segments: [], folderFreshness: 'fresh' },
...STUBS,
})
await nextTick()
expect(w.vm.showFreshMark).toBe(true)
// Advance timer by 3s
vi.advanceTimersByTime(3001)
await nextTick()
expect(w.vm.showFreshMark).toBe(false)
})
it('warning indicator persists when folderFreshness="stale"', async () => {
const w = mount(BreadcrumbBar, {
props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' },
...STUBS,
})
const stale = w.find('[data-freshness="stale"]')
expect(stale.exists()).toBe(true)
})
it('stale warning banner is shown with last-update info', async () => {
const w = mount(BreadcrumbBar, {
props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' },
...STUBS,
})
const banner = w.find('[data-test="stale-warning-banner"]')
expect(banner.exists()).toBe(true)
expect(banner.text()).toContain('Could not refresh folder contents')
})
it('no freshness indicator shown when folderFreshness is null', () => {
const w = mount(BreadcrumbBar, {
props: { segments: [], folderFreshness: null },
...STUBS,
})
expect(w.find('[data-test="freshness-indicator"]').exists()).toBe(false)
})
it('freshness indicator role="status" for accessible announcement', async () => {
const w = mount(BreadcrumbBar, {
props: { segments: [], folderFreshness: 'refreshing' },
...STUBS,
})
const indicator = w.find('[data-freshness="refreshing"]')
expect(indicator.attributes('role')).toBe('status')
})
})
+1 -1
View File
@@ -62,7 +62,7 @@ const routes = [
meta: { requiresAuth: true },
},
{
path: '/cloud/:provider/:folderId(.*)',
path: '/cloud/:connectionId/:folderId(.*)',
name: 'cloud-folder',
component: () => import('../views/CloudFolderView.vue'),
meta: { requiresAuth: true },
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock api/client.js — no real HTTP calls in unit tests (CLAUDE.md W4)
@@ -7,14 +7,22 @@ vi.mock('../../api/client.js', () => ({
disconnectCloud: vi.fn(),
connectWebDav: vi.fn(),
updateDefaultStorage: vi.fn(),
renameCloudConnection: vi.fn(),
getCloudFoldersByConnectionId: vi.fn(),
}))
import { useCloudConnectionsStore } from '../cloudConnections.js'
import { useCloudConnectionsStore, saveLastFolder, loadLastFolder } from '../cloudConnections.js'
import * as api from '../../api/client.js'
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
// Clear sessionStorage between tests
sessionStorage.clear()
})
afterEach(() => {
sessionStorage.clear()
})
describe('useCloudConnectionsStore', () => {
@@ -56,4 +64,95 @@ describe('useCloudConnectionsStore', () => {
await store.disconnectAll()
expect(store.connections).toHaveLength(0)
})
// Rename tests
it('rename updates the connection in state on success', async () => {
api.renameCloudConnection.mockResolvedValue({
id: 'conn-1', provider: 'google_drive', display_name: 'My Drive',
})
const store = useCloudConnectionsStore()
store.connections = [{ id: 'conn-1', provider: 'google_drive', display_name: 'Google Drive' }]
await store.rename('conn-1', 'My Drive')
expect(store.connections[0].display_name).toBe('My Drive')
expect(api.renameCloudConnection).toHaveBeenCalledWith('conn-1', 'My Drive')
})
it('rename rejects on empty display name', async () => {
const store = useCloudConnectionsStore()
await expect(store.rename('conn-1', '')).rejects.toThrow()
})
it('rename rejects on whitespace-only display name', async () => {
const store = useCloudConnectionsStore()
await expect(store.rename('conn-1', ' ')).rejects.toThrow()
})
it('rename propagates API error', async () => {
api.renameCloudConnection.mockRejectedValue(new Error('API error'))
const store = useCloudConnectionsStore()
store.connections = [{ id: 'conn-1', provider: 'google_drive', display_name: 'Google Drive' }]
await expect(store.rename('conn-1', 'New Name')).rejects.toThrow('API error')
})
// Two same-provider connections render as separate roots
it('two google_drive connections render as separate roots with UUID suffixes in defaultDisplayName', () => {
const store = useCloudConnectionsStore()
store.connections = [
{ id: 'aabb-ccdd-1', provider: 'google_drive', display_name: null },
{ id: 'eeff-gggg-2', provider: 'google_drive', display_name: null },
]
const nameA = store.defaultDisplayName(store.connections[0])
const nameB = store.defaultDisplayName(store.connections[1])
// Both should contain "Google Drive" and a 4-char suffix
expect(nameA).toContain('Google Drive')
expect(nameB).toContain('Google Drive')
expect(nameA).toContain('aabb')
expect(nameB).toContain('eeff')
})
it('single connection has no suffix in defaultDisplayName', () => {
const store = useCloudConnectionsStore()
store.connections = [{ id: 'conn-1', provider: 'google_drive' }]
expect(store.defaultDisplayName(store.connections[0])).toBe('Google Drive')
})
// Connection UUID based URLs
it('selectConnection sets selectedConnectionId', () => {
const store = useCloudConnectionsStore()
store.connections = [{ id: 'uuid-123', provider: 'onedrive' }]
store.selectConnection('uuid-123')
expect(store.selectedConnectionId).toBe('uuid-123')
expect(store.selectedConnection?.id).toBe('uuid-123')
})
// Session storage for folder navigation
it('saveLastFolder stores folder in sessionStorage', () => {
saveLastFolder('conn-abc', 'Documents/Work')
expect(sessionStorage.getItem('docuvault:cloud:folder:conn-abc')).toBe('Documents/Work')
})
it('loadLastFolder retrieves stored folder', () => {
sessionStorage.setItem('docuvault:cloud:folder:conn-xyz', 'Photos')
expect(loadLastFolder('conn-xyz')).toBe('Photos')
})
it('saveLastFolder removes key for root folder', () => {
sessionStorage.setItem('docuvault:cloud:folder:conn-abc', 'Docs')
saveLastFolder('conn-abc', 'root')
expect(sessionStorage.getItem('docuvault:cloud:folder:conn-abc')).toBeNull()
})
it('loadLastFolder returns null when nothing stored', () => {
expect(loadLastFolder('nonexistent')).toBeNull()
})
it('sessionStorage stores only folder references, not tokens', () => {
// Ensure save only stores the folderId string under the namespaced key
saveLastFolder('conn-123', 'Projects/Secret')
const stored = sessionStorage.getItem('docuvault:cloud:folder:conn-123')
expect(stored).toBe('Projects/Secret')
// Stored value is a plain string path, not a JSON object with credentials
expect(typeof stored).toBe('string')
expect(stored.startsWith('{')).toBe(false)
})
})
+124 -2
View File
@@ -1,12 +1,57 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import * as api from '../api/client.js'
/**
* Session-storage key for last visited folder per connection.
* Only folder navigation state is stored — never tokens or credentials.
*/
function folderSessionKey(connectionId) {
return `docuvault:cloud:folder:${connectionId}`
}
export function saveLastFolder(connectionId, folderId) {
try {
if (folderId && folderId !== 'root') {
sessionStorage.setItem(folderSessionKey(connectionId), folderId)
} else {
sessionStorage.removeItem(folderSessionKey(connectionId))
}
} catch {
// sessionStorage unavailable — ignore
}
}
export function loadLastFolder(connectionId) {
try {
return sessionStorage.getItem(folderSessionKey(connectionId)) || null
} catch {
return null
}
}
export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
const connections = ref([])
const loading = ref(false)
const error = ref(null)
// Active browse state (populated by CloudFolderView)
const selectedConnectionId = ref(null)
const browseItems = ref([])
const browseLoading = ref(false)
const browseError = ref(null)
/** Capabilities for the currently selected connection */
const capabilities = ref(null)
/** 'refreshing' | 'fresh' | 'stale' | null */
const folderFreshness = ref(null)
const lastRefreshedAt = ref(null)
/** 'cached' | 'cloud_only' | null */
const byteAvailability = ref(null)
const selectedConnection = computed(() =>
connections.value.find(c => c.id === selectedConnectionId.value) ?? null
)
async function fetchConnections() {
loading.value = true
error.value = null
@@ -24,6 +69,9 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
try {
await api.disconnectCloud(id)
connections.value = connections.value.filter(c => c.id !== id)
if (selectedConnectionId.value === id) {
selectedConnectionId.value = null
}
} catch (e) {
throw e
}
@@ -35,5 +83,79 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
connections.value = []
}
return { connections, loading, error, fetchConnections, disconnect, disconnectAll }
/**
* Rename a connection's display_name via the API.
* Only updates the single returned connection in state.
*/
async function rename(id, displayName) {
const trimmed = displayName?.trim()
if (!trimmed) throw new Error('Display name cannot be empty')
const updated = await api.renameCloudConnection(id, trimmed)
const idx = connections.value.findIndex(c => c.id === id)
if (idx !== -1) {
connections.value[idx] = { ...connections.value[idx], ...updated }
}
return updated
}
/**
* Default display_name for a connection.
* If another connection shares the same provider, append a 4-char UUID suffix.
*/
function defaultDisplayName(connection) {
const providerLabel = {
google_drive: 'Google Drive',
onedrive: 'OneDrive',
nextcloud: 'Nextcloud',
webdav: 'WebDAV',
}[connection.provider] ?? connection.provider
const siblings = connections.value.filter(
c => c.provider === connection.provider && c.id !== connection.id
)
if (siblings.length > 0) {
return `${providerLabel} (${connection.id.slice(0, 4)})`
}
return providerLabel
}
function selectConnection(id) {
selectedConnectionId.value = id
browseItems.value = []
browseError.value = null
capabilities.value = null
folderFreshness.value = null
lastRefreshedAt.value = null
byteAvailability.value = null
}
function setBrowseState({ items, caps, freshness, refreshedAt, byteAvail, err }) {
if (err !== undefined) browseError.value = err
if (items !== undefined) browseItems.value = items
if (caps !== undefined) capabilities.value = caps
if (freshness !== undefined) folderFreshness.value = freshness
if (refreshedAt !== undefined) lastRefreshedAt.value = refreshedAt
if (byteAvail !== undefined) byteAvailability.value = byteAvail
}
return {
connections,
loading,
error,
selectedConnectionId,
selectedConnection,
browseItems,
browseLoading,
browseError,
capabilities,
folderFreshness,
lastRefreshedAt,
byteAvailability,
fetchConnections,
disconnect,
disconnectAll,
rename,
defaultDisplayName,
selectConnection,
setBrowseState,
}
})
+17
View File
@@ -50,6 +50,23 @@ export function providerBg(provider) {
return map[provider] ?? 'bg-gray-50'
}
/**
* Returns a human-readable relative time string for an ISO timestamp.
* e.g. "just now", "2 minutes ago", "1 hour ago", "3 days ago"
*/
export function formatRelativeTime(iso) {
if (!iso) return ''
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (diff < 10) return 'just now'
if (diff < 60) return `${diff} seconds ago`
const mins = Math.floor(diff / 60)
if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
const days = Math.floor(hrs / 24)
return days === 1 ? '1 day ago' : `${days} days ago`
}
/** Human-readable label for a cloud provider slug. */
export function providerLabel(provider) {
const map = {
+70 -17
View File
@@ -7,7 +7,12 @@
:upload-queue="uploadQueue"
:loading="loading"
:empty-message="error || 'This folder is empty'"
:empty-hint="error ? '' : 'Files stored here are managed by ' + providerLabel(provider)"
: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)"
@@ -20,25 +25,37 @@ 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 { providerLabel } from '../utils/formatters.js'
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([])
const provider = computed(() => route.params.provider)
/** Connection UUID from route — never uses provider slug */
const connectionId = computed(() => route.params.connectionId)
const folderId = computed(() => route.params.folderId)
const folders = computed(() => items.value.filter(i => i.is_dir))
const files = computed(() => items.value.filter(i => !i.is_dir))
/** Breadcrumb built from the folder path segments. */
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,
}
})
const breadcrumb = computed(() => {
if (!folderId.value || folderId.value === 'root') return []
const parts = folderId.value.replace(/\/$/, '').split('/')
@@ -55,39 +72,54 @@ const mappedBreadcrumb = computed(() =>
async function load() {
loading.value = true
error.value = ''
cloudStore.setBrowseState({ freshness: 'refreshing' })
try {
const data = await api.getCloudFolders(provider.value, folderId.value ?? 'root')
const data = await api.getCloudFoldersByConnectionId(
connectionId.value,
folderId.value ?? 'root'
)
items.value = data.items ?? []
cloudStore.setBrowseState({
items: items.value,
caps: data.capabilities ?? null,
freshness: 'fresh',
refreshedAt: new Date().toISOString(),
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')) {
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
}
// Save last folder in sessionStorage (folder refs only — no tokens/credentials)
saveLastFolder(connectionId.value, folderId.value)
}
function navigateTo(item) {
router.push(`/cloud/${provider.value}/${item.id}`)
router.push(`/cloud/${connectionId.value}/${item.id}`)
}
function handleBreadcrumbNavigate(id) {
if (id == null) router.push(`/cloud/${provider.value}/root`)
else router.push(`/cloud/${provider.value}/${id}`)
if (id == null) router.push(`/cloud/${connectionId.value}/root`)
else router.push(`/cloud/${connectionId.value}/${id}`)
}
// ── Upload ────────────────────────────────────────────────────────────────────
const uploadQueue = ref([])
async function onFilesSelected({ files: selectedFiles }) {
const promises = selectedFiles.map(file => {
const item = reactive({ name: file.name, done: false, error: null, status: 'Uploading…' })
uploadQueue.value.unshift(item)
return api.uploadToCloud(file, provider.value, folderId.value || null)
return api.uploadToCloud(file, connectionId.value, folderId.value || null)
.then(() => { item.done = true; item.status = null })
.catch(e => { item.error = e.message || 'Upload failed' })
})
@@ -99,6 +131,27 @@ function onFileOpen(file) {
toast.show(`"${file.name}" can't be opened here — open it directly in your cloud storage provider.`, 'info')
}
onMounted(load)
watch([provider, folderId], load)
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(`/cloud/${connectionId.value}/${last}`)
return
}
}
load()
})
watch([connectionId, folderId], () => {
cloudStore.selectConnection(connectionId.value)
load()
})
</script>
+35 -68
View File
@@ -1,83 +1,50 @@
<template>
<div class="flex flex-col h-full">
<!-- Toolbar -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<div class="px-6 py-3 flex items-center gap-3">
<BreadcrumbBar :segments="[{ label: 'Cloud Storage' }]" :show-root="false" />
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-6 py-5">
<!-- Column headers -->
<div class="px-4 py-2 grid grid-cols-[2rem_1fr_8rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none mb-1">
<span></span>
<span>Name</span>
<span>Status</span>
</div>
<div v-if="loading" class="text-sm text-gray-400 py-8 text-center">Loading</div>
<EmptyState
v-else-if="connections.length === 0"
icon="cloud"
headline="No cloud storage connected"
subtext="Connect Google Drive, OneDrive, Nextcloud, or a WebDAV server in Settings."
>
<template #cta>
<router-link to="/settings" class="mt-3 inline-block text-sm text-indigo-600 hover:underline">
Go to Settings
</router-link>
</template>
</EmptyState>
<div v-else class="flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
<div
v-for="conn in connections"
:key="conn.id"
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_8rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
@click="openProvider(conn)"
>
<!-- Provider icon -->
<div class="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" :class="providerBg(conn.provider)">
<AppIcon name="cloud" class="w-4 h-4" :class="providerColor(conn.provider)" />
</div>
<span class="text-sm font-medium text-gray-900 truncate">{{ conn.display_name }}</span>
<span class="text-xs" :class="conn.status === 'ACTIVE' ? 'text-green-600' : 'text-amber-500'">
{{ conn.status === 'ACTIVE' ? 'Connected' : 'Needs reauth' }}
</span>
</div>
</div>
<p v-if="connections.length > 0" class="mt-4 text-xs text-gray-400 text-center">
To upload files, navigate into a cloud folder first.
</p>
</div>
</div>
<StorageBrowser
mode="cloud"
:folders="connectionRoots"
:files="[]"
:breadcrumb="[]"
:upload-queue="[]"
:loading="loading"
:empty-message="'No cloud storage connected'"
:empty-hint="'Connect Google Drive, OneDrive, Nextcloud, or a WebDAV server in Settings.'"
@folder-navigate="openConnection"
/>
</template>
<script setup>
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCloudConnectionsStore } from '../stores/cloudConnections.js'
import { providerColor, providerBg } from '../utils/formatters.js'
import AppIcon from '../components/ui/AppIcon.vue'
import BreadcrumbBar from '../components/ui/BreadcrumbBar.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import { providerLabel } from '../utils/formatters.js'
import StorageBrowser from '../components/storage/StorageBrowser.vue'
const router = useRouter()
const cloudStore = useCloudConnectionsStore()
const loading = computed(() => cloudStore.loading)
const connections = computed(() => cloudStore.connections)
function openProvider(conn) {
router.push(`/cloud/${conn.provider}/root`)
/**
* Map each connection to a folder-like item for StorageBrowser.
* name uses defaultDisplayName so duplicate providers get a 4-char UUID suffix.
*/
const connectionRoots = computed(() =>
cloudStore.connections.map(conn => ({
id: conn.id,
name: conn.display_name || cloudStore.defaultDisplayName(conn),
_connectionId: conn.id,
_provider: conn.provider,
created_at: conn.connected_at ?? null,
}))
)
function openConnection(item) {
router.push(`/cloud/${item._connectionId}/root`)
}
onMounted(() => {
if (cloudStore.connections.length === 0) {
cloudStore.fetchConnections()
}
})
</script>
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
// Use connection UUID, never provider slug
const mockPush = vi.fn()
const mockReplace = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush, replace: mockReplace }),
useRoute: () => ({
params: { connectionId: 'uuid-conn-1', folderId: 'root' },
query: {},
}),
}))
const mockFetchConnections = vi.fn().mockResolvedValue(undefined)
const mockSelectConnection = vi.fn()
const mockSetBrowseState = vi.fn()
vi.mock('../../stores/cloudConnections.js', () => ({
useCloudConnectionsStore: () => ({
connections: [{ id: 'uuid-conn-1', provider: 'google_drive', display_name: 'My Drive' }],
loading: false,
capabilities: null,
folderFreshness: null,
lastRefreshedAt: null,
byteAvailability: null,
fetchConnections: mockFetchConnections,
selectConnection: mockSelectConnection,
setBrowseState: mockSetBrowseState,
defaultDisplayName: (c) => c.provider,
}),
saveLastFolder: vi.fn(),
loadLastFolder: vi.fn(() => null),
}))
vi.mock('../../api/client.js', () => ({
getCloudFoldersByConnectionId: vi.fn().mockResolvedValue({ items: [], capabilities: null }),
uploadToCloud: vi.fn(),
listCloudConnections: vi.fn().mockResolvedValue({ items: [] }),
}))
vi.mock('../../stores/toast.js', () => ({
useToastStore: () => ({ show: vi.fn() }),
}))
import CloudFolderView from '../CloudFolderView.vue'
import * as api from '../../api/client.js'
const globalStubs = {
StorageBrowser: {
template: '<div data-test="storage-browser" />',
props: [
'mode', 'folders', 'files', 'breadcrumb', 'uploadQueue', 'loading',
'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot',
'folderFreshness', 'lastRefreshedAt', 'byteAvailability',
],
emits: ['breadcrumb-navigate', 'upload', 'folder-navigate', 'file-open'],
},
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
sessionStorage.clear()
})
afterEach(() => {
sessionStorage.clear()
})
describe('CloudFolderView', () => {
it('calls getCloudFoldersByConnectionId with connection UUID, never provider slug', async () => {
const w = mount(CloudFolderView, { global: { stubs: globalStubs } })
await flushPromises()
expect(api.getCloudFoldersByConnectionId).toHaveBeenCalledWith('uuid-conn-1', 'root')
// Never called with provider slug
expect(api.getCloudFoldersByConnectionId).not.toHaveBeenCalledWith('google_drive', expect.anything())
})
it('renders StorageBrowser (no parallel file grid)', () => {
const w = mount(CloudFolderView, { global: { stubs: globalStubs } })
expect(w.find('[data-test="storage-browser"]').exists()).toBe(true)
// No grid markup in view itself
expect(w.findAll('table').length).toBe(0)
})
it('passes connectionId to store selectConnection', async () => {
const w = mount(CloudFolderView, { global: { stubs: globalStubs } })
await flushPromises()
expect(mockSelectConnection).toHaveBeenCalledWith('uuid-conn-1')
})
it('fresh session at root does not redirect when no stored folder', async () => {
const w = mount(CloudFolderView, { global: { stubs: globalStubs } })
await flushPromises()
expect(mockReplace).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('../../stores/cloudConnections.js', () => ({
useCloudConnectionsStore: () => ({
connections: [],
loading: false,
error: null,
fetchConnections: vi.fn(),
defaultDisplayName: (c) => c.provider,
}),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ params: {}, query: {} }),
}))
import CloudStorageView from '../CloudStorageView.vue'
const globalStubs = {
StorageBrowser: {
template: '<div data-test="storage-browser"><slot /></div>',
props: ['folders', 'files', 'breadcrumb', 'uploadQueue', 'loading', 'emptyMessage', 'emptyHint'],
emits: ['folder-navigate'],
},
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('CloudStorageView', () => {
it('renders StorageBrowser (no parallel grid)', () => {
const w = mount(CloudStorageView, { global: { stubs: globalStubs } })
expect(w.find('[data-test="storage-browser"]').exists()).toBe(true)
})
it('contains no file-grid markup of its own', () => {
const w = mount(CloudStorageView, { global: { stubs: globalStubs } })
// The view must not render its own grid (no grid/list/table/tr outside StorageBrowser)
const html = w.html()
// Only StorageBrowser is the grid consumer
expect(html).not.toContain('<table')
expect(html).not.toContain('<ul class')
})
it('passes loading=false when store is not loading', () => {
const w = mount(CloudStorageView, { global: { stubs: globalStubs } })
const sb = w.find('[data-test="storage-browser"]')
expect(sb).toBeTruthy()
})
})