From bf9af11274f3599a36e83d413ca1c2a37f17e3e5 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Thu, 18 Jun 2026 23:25:29 +0200 Subject: [PATCH 1/4] feat(12-03): connection-ID routing, rename, session folder memory, thin views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/api/cloud.js | 16 +++ .../components/settings/SettingsCloudTab.vue | 48 ++++++- .../__tests__/SettingsCloudTab.test.js | 45 +++++++ frontend/src/router/index.js | 2 +- .../stores/__tests__/cloudConnections.test.js | 103 +++++++++++++- frontend/src/stores/cloudConnections.js | 126 +++++++++++++++++- frontend/src/views/CloudFolderView.vue | 87 +++++++++--- frontend/src/views/CloudStorageView.vue | 103 +++++--------- .../views/__tests__/CloudFolderView.test.js | 100 ++++++++++++++ .../views/__tests__/CloudStorageView.test.js | 55 ++++++++ 10 files changed, 594 insertions(+), 91 deletions(-) create mode 100644 frontend/src/views/__tests__/CloudFolderView.test.js create mode 100644 frontend/src/views/__tests__/CloudStorageView.test.js diff --git a/frontend/src/api/cloud.js b/frontend/src/api/cloud.js index 7cf83d2..8582b31 100644 --- a/frontend/src/api/cloud.js +++ b/frontend/src/api/cloud.js @@ -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. * diff --git a/frontend/src/components/settings/SettingsCloudTab.vue b/frontend/src/components/settings/SettingsCloudTab.vue index 5fe3b31..46cb644 100644 --- a/frontend/src/components/settings/SettingsCloudTab.vue +++ b/frontend/src/components/settings/SettingsCloudTab.vue @@ -60,8 +60,33 @@ + + +
  • + + + + Refreshing + + + + + + + Up to date + + + + + +
  • + + + + + diff --git a/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js b/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js index 9d9b6be..deaf9f0 100644 --- a/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js +++ b/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js @@ -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') + }) +}) diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js index cc0135c..f8cd6a6 100644 --- a/frontend/src/utils/formatters.js +++ b/frontend/src/utils/formatters.js @@ -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 = { From 5287efd34c4e046acf18bb45a066930efbe9e805 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Thu, 18 Jun 2026 23:32:41 +0200 Subject: [PATCH 4/4] docs(12-03): add plan 03 execution summary --- .../12-03-SUMMARY.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .planning/phases/12-cloud-resource-foundation/12-03-SUMMARY.md diff --git a/.planning/phases/12-cloud-resource-foundation/12-03-SUMMARY.md b/.planning/phases/12-cloud-resource-foundation/12-03-SUMMARY.md new file mode 100644 index 0000000..25c12ba --- /dev/null +++ b/.planning/phases/12-cloud-resource-foundation/12-03-SUMMARY.md @@ -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.