feat(12.1-03): align cloud browser contract — kind, provider_item_id, server freshness

- CloudFolderView: folders/files classified by kind=folder/file (remove is_dir)
- CloudFolderView: navigateTo uses item.provider_item_id for route param (not item.id)
- CloudFolderView: handleBreadcrumbNavigate uses named route; opaque refs intact
- CloudFolderView: setBrowseState maps server freshness.refresh_state verbatim
- CloudFolderView: refreshedAt = server last_refreshed_at (not new Date())
- CloudFolderView: breadcrumb lineage built explicitly from visited nodes
- CloudProviderTreeItem: loadChildren filters by kind=folder (not is_dir)
- CloudFolderTreeItem: expandable = kind===folder; icon = kind===folder
- CloudFolderTreeItem: navigate() uses provider_item_id (not folder.id)
- CloudFolderTreeItem: loadChildren uses provider_item_id as parent_ref
- CloudFolderTreeItem: isActive checks provider_item_id first
All 82 focused tests pass; full suite: 369/369
This commit is contained in:
curo1305
2026-06-22 08:49:19 +02:00
parent 9974fca2cb
commit dc3e1725da
4 changed files with 168 additions and 58 deletions
+74 -23
View File
@@ -43,8 +43,13 @@ const uploadQueue = ref([])
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))
/**
* Classify items by kind (canonical API field).
* The API returns CloudItemOut with kind="folder"|"file".
* Never use is_dir — that field is absent from the normalized schema.
*/
const folders = computed(() => items.value.filter(i => i.kind === 'folder'))
const files = computed(() => items.value.filter(i => i.kind === 'file'))
const connectionRoot = computed(() => {
const conn = cloudStore.connections.find(c => c.id === connectionId.value)
@@ -56,19 +61,67 @@ const connectionRoot = computed(() => {
}
})
const breadcrumb = computed(() => {
if (!folderId.value || folderId.value === 'root') return []
const parts = folderId.value.replace(/\/$/, '').split('/')
return parts.map((seg, idx) => ({
id: parts.slice(0, idx + 1).join('/'),
name: seg,
}))
})
/**
* Session-only breadcrumb lineage.
* Maintained as an explicit list of { name, providerItemId } visited nodes.
* Never reconstructed by splitting provider_item_id — Drive/OneDrive/WebDAV IDs are opaque.
*/
const breadcrumbLineage = ref([])
const breadcrumb = computed(() => breadcrumbLineage.value)
const mappedBreadcrumb = computed(() =>
breadcrumb.value.map(f => ({ id: f.id, label: f.name }))
breadcrumb.value.map(f => ({ id: f.providerItemId, label: f.name }))
)
/**
* Navigate to a cloud folder using provider_item_id as the opaque provider reference.
* The DocuVault stable id (item.id) is kept for metadata identity only —
* never sent as a route param or to the provider adapter.
*
* Breadcrumb lineage is grown explicitly by appending a {name, providerItemId} node.
* Reserved characters (/, ?, #, %, spaces, Unicode) are not decoded or split.
*/
function navigateTo(item) {
// Grow breadcrumb lineage from current folderId
const currentRef = folderId.value && folderId.value !== 'root' ? folderId.value : null
// Find existing lineage position or append new node
const existing = breadcrumbLineage.value.findIndex(n => n.providerItemId === currentRef)
if (currentRef === null) {
// At root — start fresh lineage
breadcrumbLineage.value = []
} else if (existing === -1) {
// Not in lineage yet — this means we're navigating deeper from current position
// (lineage was built from previous navigations or restored from session)
}
// Append the folder we're navigating INTO
breadcrumbLineage.value = [
...breadcrumbLineage.value,
{ name: item.name, providerItemId: item.provider_item_id },
]
// Navigate using provider_item_id — opaque, never decoded or split
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: item.provider_item_id } })
}
/**
* Handle breadcrumb navigation.
* id is a providerItemId from the lineage — null means root.
* Opaque references are passed intact; never split or decoded.
*/
function handleBreadcrumbNavigate(id) {
if (id == null) {
breadcrumbLineage.value = []
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: 'root' } })
} else {
// Trim breadcrumb lineage to the clicked node
const idx = breadcrumbLineage.value.findIndex(n => n.providerItemId === id)
if (idx !== -1) {
breadcrumbLineage.value = breadcrumbLineage.value.slice(0, idx + 1)
}
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: id } })
}
}
async function load() {
loading.value = true
error.value = ''
@@ -79,11 +132,17 @@ async function load() {
folderId.value ?? 'root'
)
items.value = data.items ?? []
/**
* Map server freshness state verbatim — never infer fresh from HTTP 200.
* A successful cached browse response is not proof of provider synchronization.
* last_refreshed_at comes from the server; never use new Date() as evidence.
*/
const freshness = data.freshness ?? {}
cloudStore.setBrowseState({
items: items.value,
caps: data.capabilities ?? null,
freshness: 'fresh',
refreshedAt: new Date().toISOString(),
freshness: freshness.refresh_state ?? 'warning',
refreshedAt: freshness.last_refreshed_at ?? null,
byteAvail: data.byte_availability ?? null,
err: null,
})
@@ -98,6 +157,7 @@ async function load() {
} else {
error.value = msg || 'Failed to load folder contents.'
}
// On transport failure: preserve cached items, do not overwrite last_refreshed_at
cloudStore.setBrowseState({ freshness: 'stale', err: error.value })
} finally {
loading.value = false
@@ -106,15 +166,6 @@ async function load() {
saveLastFolder(connectionId.value, folderId.value)
}
function navigateTo(item) {
router.push(`/cloud/${connectionId.value}/${item.id}`)
}
function handleBreadcrumbNavigate(id) {
if (id == null) router.push(`/cloud/${connectionId.value}/root`)
else router.push(`/cloud/${connectionId.value}/${id}`)
}
async function onFilesSelected({ files: selectedFiles }) {
const promises = selectedFiles.map(file => {
const item = reactive({ name: file.name, done: false, error: null, status: 'Uploading…' })
@@ -143,7 +194,7 @@ onMounted(async () => {
if (isRoot) {
const last = loadLastFolder(connectionId.value)
if (last) {
router.replace(`/cloud/${connectionId.value}/${last}`)
router.replace({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: last } })
return
}
}