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:
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user