feat(12-03): connection-ID routing, rename, session folder memory, thin views

- Route /cloud/:connectionId/:folderId replaces /cloud/:provider/:folderId
- api/cloud.js: getCloudFoldersByConnectionId + renameCloudConnection
- cloudConnections store: rename, selectConnection, setBrowseState, defaultDisplayName, session storage helpers
- CloudStorageView: thin — passes connectionRoots to StorageBrowser
- CloudFolderView: thin — uses connectionId param, never provider slug
- SettingsCloudTab: inline rename input for each active connection
- 27 tests pass: store, view, settings
This commit is contained in:
curo1305
2026-06-18 23:25:29 +02:00
parent 6f0ecfa39b
commit bf9af11274
10 changed files with 594 additions and 91 deletions
+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')
})
})
+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,
}
})
+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()
})
})