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
+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()
})
})