@@ -126,9 +146,9 @@
'bg-gray-50': renamingId === folder.id,
}"
@click="renamingId === folder.id ? null : $emit('folder-navigate', folder)"
- @dragover.prevent="mode === 'local' ? onFolderDragOver(folder.id, $event) : null"
+ @dragover.prevent="effectiveCaps.drag_move ? onFolderDragOver(folder.id, $event) : null"
@dragleave="dragOverFolderId = null"
- @drop.prevent="mode === 'local' ? onDropDocOnFolder(folder.id) : null"
+ @drop.prevent="effectiveCaps.drag_move ? onDropDocOnFolder(folder.id) : null"
>
@@ -150,16 +170,27 @@
{{ formatDate(folder.created_at) }}
-
-
-
-
+
+
+
+
+
+
+
+
@@ -167,11 +198,11 @@
-
{{ formatSize(file.size_bytes ?? file.size) }}
+
+ {{ formatSize(file.size_bytes ?? file.size) }}
+
+
+
+
+
{{ formatDate(file.created_at) }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -301,8 +357,72 @@ import UploadProgress from '../upload/UploadProgress.vue'
import TopicBadge from '../topics/TopicBadge.vue'
import { formatDate, formatSize } from '../../utils/formatters.js'
+// ── CapabilityButton inline component ──────────────────────────────────────────
+/**
+ * Renders an action button that is aware of its capability state.
+ * cap: 'supported' | 'unsupported' | 'temporarily_unavailable'
+ * variant: 'default' | 'danger'
+ *
+ * Accessibility: aria-disabled is used (not native disabled) so the element
+ * remains focusable. Enter, Space, pointer click, and touch tap on a
+ * non-supported button suppress the action and surface the explanation.
+ */
+const CapabilityButton = {
+ name: 'CapabilityButton',
+ props: {
+ cap: { type: String, default: 'supported' },
+ title: { type: String, default: '' },
+ message: { type: String, default: '' },
+ variant: { type: String, default: 'default' },
+ },
+ emits: ['action', 'explain'],
+ setup(props, { emit, slots }) {
+ function handleActivate(e) {
+ if (props.cap !== 'supported') {
+ e.stopPropagation()
+ emit('explain', { message: props.message, type: props.cap === 'temporarily_unavailable' ? 'temporary' : 'unsupported' })
+ return
+ }
+ emit('action', e)
+ }
+ function handleKeydown(e) {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ handleActivate(e)
+ }
+ }
+ return () => {
+ const isDisabled = props.cap !== 'supported'
+ const colorClass = isDisabled
+ ? (props.cap === 'temporarily_unavailable'
+ ? 'text-amber-400 hover:text-amber-500'
+ : 'text-gray-300 hover:text-gray-400')
+ : (props.variant === 'danger'
+ ? 'text-gray-400 hover:text-red-500 hover:bg-red-50 active:bg-red-100 focus-visible:ring-red-500'
+ : 'text-gray-400 hover:text-gray-700 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring-indigo-500')
+
+ const h = Vue.h
+ return h('button', {
+ type: 'button',
+ title: isDisabled ? (props.message || props.title) : props.title,
+ 'aria-label': props.title,
+ 'aria-disabled': isDisabled ? 'true' : undefined,
+ 'data-cap': props.cap,
+ class: `p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 ${colorClass}`,
+ onClick: handleActivate,
+ onKeydown: handleKeydown,
+ onTouchend: handleActivate,
+ }, slots.default?.())
+ }
+ },
+}
+
+// Vue's h function reference for CapabilityButton
+import { h as vueH } from 'vue'
+const Vue = { h: vueH }
+
const props = defineProps({
- /** "local" or "cloud" — controls which actions are visible */
+ /** "local" or "cloud" — used for search/sort visibility fallback only */
mode: { type: String, default: 'local' },
folders: { type: Array, default: () => [] },
files: { type: Array, default: () => [] },
@@ -318,6 +438,21 @@ const props = defineProps({
topicColorFn: { type: Function, default: () => '#6366f1' },
emptyMessage: { type: String, default: 'This folder is empty' },
emptyHint: { type: String, default: 'Upload files above or create a sub-folder' },
+ /**
+ * Capability map for cloud connections.
+ * Keys: share, move, delete, rename_folder, delete_folder, create_folder, drag_move
+ * Values: true (supported) | false (unsupported) | 'temporarily_unavailable'
+ * Defaults to full local capability set when null (local mode).
+ */
+ capabilities: { type: Object, default: null },
+ /** Connection root metadata { id, name, provider } for breadcrumb */
+ connectionRoot: { type: Object, default: null },
+ /** 'refreshing' | 'fresh' | 'stale' | null */
+ folderFreshness: { type: String, default: null },
+ /** ISO timestamp of last successful refresh */
+ lastRefreshedAt: { type: String, default: null },
+ /** 'cached' | 'cloud_only' | null — affects size column markers */
+ byteAvailability: { type: String, default: null },
})
const emit = defineEmits([
@@ -334,8 +469,66 @@ const emit = defineEmits([
'file-share',
'file-move',
'file-delete',
+ 'capability-explain',
])
+/**
+ * Default capability set for local mode — full access to all actions.
+ * When capabilities prop is provided (cloud mode), uses that instead.
+ */
+const LOCAL_CAPS = {
+ share: true,
+ move: true,
+ delete: true,
+ rename_folder: true,
+ delete_folder: true,
+ create_folder: true,
+ drag_move: true,
+}
+
+/** Effective capabilities: local defaults or cloud-provided */
+const effectiveCaps = computed(() => {
+ if (props.capabilities) return { ...LOCAL_CAPS, ...props.capabilities }
+ return LOCAL_CAPS
+})
+
+/**
+ * Determine capability state for a file action.
+ * Returns 'supported' | 'unsupported' | 'temporarily_unavailable'
+ */
+function fileCapState(action) {
+ const cap = effectiveCaps.value[action]
+ if (cap === true) return 'supported'
+ if (cap === 'temporarily_unavailable') return 'temporarily_unavailable'
+ return 'unsupported'
+}
+
+/** Human-readable explanation for a capability message */
+function capMessage(capKey) {
+ const messages = {
+ share: 'Sharing is not available for this cloud connection.',
+ move: 'Moving files is not available for this cloud connection.',
+ delete: 'Deleting files is not available for this cloud connection.',
+ rename_folder: 'Renaming folders is not available for this cloud connection.',
+ delete_folder: 'Deleting folders is not available for this cloud connection.',
+ create_folder: 'Creating folders is not available for this cloud connection.',
+ drag_move: 'Drag-to-move is not available for this cloud connection.',
+ }
+ const cap = effectiveCaps.value[capKey]
+ if (cap === 'temporarily_unavailable') {
+ return `This action is temporarily unavailable. Please try again in a moment.`
+ }
+ return messages[capKey] ?? 'This action is not available.'
+}
+
+/** Inline notice displayed on touch/keyboard activation of a disabled button */
+const capabilityNotice = ref(null)
+
+function showCapabilityNotice({ message, type }) {
+ capabilityNotice.value = { message, type }
+ emit('capability-explain', { message, type })
+}
+
const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
function topicColor(name) {
@@ -487,3 +680,14 @@ onUnmounted(() => {
window.removeEventListener('resize', onWindowScroll)
})
+
+
diff --git a/frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js b/frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js
new file mode 100644
index 0000000..3091f02
--- /dev/null
+++ b/frontend/src/components/storage/__tests__/StorageBrowser.capabilities.test.js
@@ -0,0 +1,257 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import { nextTick } from 'vue'
+import StorageBrowser from '../StorageBrowser.vue'
+
+const globalStubs = {
+ BreadcrumbBar: true,
+ SearchBar: true,
+ SortControls: true,
+ DropZone: true,
+ UploadProgress: true,
+ TopicBadge: true,
+ AppIcon: { template: '
' },
+ EmptyState: true,
+}
+
+const FOLDERS = [{ id: 'f1', name: 'Archive', created_at: '2025-01-01T00:00:00Z' }]
+const FILES = [{ id: 'd1', original_name: 'report.pdf', size_bytes: 1024, created_at: '2025-01-01T00:00:00Z', topics: [] }]
+
+// Full cloud-unsupported capabilities
+const CAPS_NONE = {
+ share: false,
+ move: false,
+ delete: false,
+ rename_folder: false,
+ delete_folder: false,
+ create_folder: false,
+ drag_move: false,
+}
+
+// Temporarily unavailable
+const CAPS_TEMP = {
+ share: 'temporarily_unavailable',
+ move: 'temporarily_unavailable',
+ delete: 'temporarily_unavailable',
+ rename_folder: false,
+ delete_folder: false,
+ create_folder: false,
+ drag_move: false,
+}
+
+beforeEach(() => {
+ setActivePinia(createPinia())
+})
+
+describe('StorageBrowser capability rendering', () => {
+
+ // ── Local defaults — all pre-Phase-12 local actions preserved ─────────────
+ it('local mode: folders have rename and delete buttons in supported state', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'local', folders: FOLDERS, files: [], rootFolders: [] },
+ global: { stubs: globalStubs },
+ })
+ const folderActions = w.find('[data-test="folder-row-actions"]')
+ const buttons = folderActions.findAll('button')
+ expect(buttons.length).toBeGreaterThanOrEqual(2)
+ // No aria-disabled on local
+ buttons.forEach(btn => {
+ expect(btn.attributes('aria-disabled')).toBeUndefined()
+ })
+ })
+
+ it('local mode: file rows have share, move, delete buttons in supported state', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'local', folders: [], files: FILES, rootFolders: [] },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const buttons = fileActions.findAll('button')
+ expect(buttons.length).toBeGreaterThanOrEqual(3)
+ buttons.forEach(btn => {
+ expect(btn.attributes('aria-disabled')).toBeUndefined()
+ })
+ })
+
+ it('local mode: supported file-delete button emits file-delete on click', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'local', folders: [], files: FILES, rootFolders: [] },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const deleteBtn = fileActions.findAll('button').find(b => b.attributes('title') === 'Delete')
+ await deleteBtn.trigger('click')
+ expect(w.emitted('file-delete')).toBeTruthy()
+ expect(w.emitted('file-delete')[0][0]).toBe('d1')
+ })
+
+ // ── Unsupported actions — aria-disabled, gray, action suppressed ─────────
+ it('cloud unsupported: file actions have aria-disabled="true"', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const buttons = fileActions.findAll('button')
+ buttons.forEach(btn => {
+ expect(btn.attributes('aria-disabled')).toBe('true')
+ })
+ })
+
+ it('cloud unsupported: clicking aria-disabled action does NOT emit the action', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete')
+ await deleteBtn.trigger('click')
+ expect(w.emitted('file-delete')).toBeFalsy()
+ })
+
+ it('cloud unsupported: keyboard Enter on disabled action does NOT emit action', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share')
+ await shareBtn.trigger('keydown', { key: 'Enter' })
+ expect(w.emitted('file-share')).toBeFalsy()
+ })
+
+ it('cloud unsupported: keyboard Space on disabled action does NOT emit action', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share')
+ await shareBtn.trigger('keydown', { key: ' ' })
+ expect(w.emitted('file-share')).toBeFalsy()
+ })
+
+ it('cloud unsupported: clicking disabled action shows capability-notice', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete')
+ await deleteBtn.trigger('click')
+ await nextTick()
+ expect(w.find('[data-test="capability-notice"]').exists()).toBe(true)
+ })
+
+ it('cloud unsupported: also emits capability-explain event', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const deleteBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Delete')
+ await deleteBtn.trigger('click')
+ expect(w.emitted('capability-explain')).toBeTruthy()
+ })
+
+ // ── Temporarily unavailable — amber state ─────────────────────────────────
+ it('temporarily_unavailable: action buttons have data-cap="temporarily_unavailable"', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_TEMP },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const tempBtns = fileActions.findAll('button').filter(b => b.attributes('data-cap') === 'temporarily_unavailable')
+ expect(tempBtns.length).toBeGreaterThan(0)
+ })
+
+ it('temporarily_unavailable: clicking does NOT emit action', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_TEMP },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const shareBtn = fileActions.findAll('button').find(b => b.attributes('aria-label') === 'Share')
+ await shareBtn.trigger('click')
+ expect(w.emitted('file-share')).toBeFalsy()
+ })
+
+ // ── No native disabled attr ────────────────────────────────────────────────
+ it('no button uses native disabled attribute (all use aria-disabled)', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: FOLDERS, files: FILES, rootFolders: [], capabilities: CAPS_NONE },
+ global: { stubs: globalStubs },
+ })
+ const allButtons = w.findAll('button')
+ allButtons.forEach(btn => {
+ expect(btn.attributes('disabled')).toBeUndefined()
+ })
+ })
+
+ // ── Cached byte marker ────────────────────────────────────────────────────
+ it('shows cached-byte marker when byteAvailability is "cached"', async () => {
+ const w = mount(StorageBrowser, {
+ props: {
+ mode: 'cloud', folders: [], files: FILES, rootFolders: [],
+ capabilities: CAPS_NONE, byteAvailability: 'cached',
+ lastRefreshedAt: '2026-01-01T00:00:00Z',
+ },
+ global: { stubs: globalStubs },
+ })
+ expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(true)
+ })
+
+ it('does NOT show cached-byte marker when byteAvailability is "cloud_only"', async () => {
+ const w = mount(StorageBrowser, {
+ props: {
+ mode: 'cloud', folders: [], files: FILES, rootFolders: [],
+ capabilities: CAPS_NONE, byteAvailability: 'cloud_only',
+ },
+ global: { stubs: globalStubs },
+ })
+ expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(false)
+ })
+
+ it('does NOT show cached-byte marker in local mode', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'local', folders: [], files: FILES, rootFolders: [] },
+ global: { stubs: globalStubs },
+ })
+ expect(w.find('[data-test="cached-byte-marker"]').exists()).toBe(false)
+ })
+
+ // ── Touch target min size ─────────────────────────────────────────────────
+ it('action buttons have min-w-[36px] min-h-[36px] touch target classes', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: [], files: FILES, rootFolders: [], capabilities: CAPS_NONE },
+ global: { stubs: globalStubs },
+ })
+ const fileActions = w.find('[data-test="file-row-actions"]')
+ const buttons = fileActions.findAll('button')
+ buttons.forEach(btn => {
+ const cls = btn.attributes('class') || ''
+ expect(cls).toContain('min-w-[36px]')
+ expect(cls).toContain('min-h-[36px]')
+ })
+ })
+
+ // ── Drag/drop preserved for local ────────────────────────────────────────
+ it('local mode: file rows have draggable="true"', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'local', folders: FOLDERS, files: FILES, rootFolders: [] },
+ global: { stubs: globalStubs },
+ })
+ const fileRow = w.find('[draggable="true"]')
+ expect(fileRow.exists()).toBe(true)
+ })
+
+ it('cloud mode with drag_move=false: file rows are NOT draggable', async () => {
+ const w = mount(StorageBrowser, {
+ props: { mode: 'cloud', folders: FOLDERS, files: FILES, rootFolders: [], capabilities: CAPS_NONE },
+ global: { stubs: globalStubs },
+ })
+ const fileRows = w.findAll('[draggable="true"]')
+ expect(fileRows.length).toBe(0)
+ })
+})
diff --git a/frontend/src/components/ui/AppIcon.vue b/frontend/src/components/ui/AppIcon.vue
index d27db40..4fba158 100644
--- a/frontend/src/components/ui/AppIcon.vue
+++ b/frontend/src/components/ui/AppIcon.vue
@@ -60,6 +60,8 @@ const ICON_PATHS = {
pencilEdit: 'M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z',
lightBulb: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0',
+ info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
+ clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
dots: 'M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z',
cog: [
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',