feat(14-07): extend StorageBrowser and CloudFolderView with analysis UX (Task 2)

StorageBrowser:
- Add analyze-file button per cloud file row (D-01); placed inside name cell
  to avoid interfering with capability-gated file-row-actions (Rule 1 compliance)
- Add file-select checkboxes for multi-select analysis (cloud mode only)
- Add analyze-selection toolbar button (D-01, ANALYZE-02)
- Add analyze-folder toolbar button when breadcrumb is non-empty (D-01)
- Add analyze-connection toolbar button when connectionRoot is set (D-01, ANALYZE-03)
- Add analysisEstimate prop + analysis-estimate-modal (D-03) with estimate-start button
- Add analysisQueue prop + analysis-aggregate-progress with simplified/detailed labels (D-05/D-06)
- Add expandable per-item list via analysis-queue-expand toggle (D-05)
- Add per-item cancel/retry/skip controls in expanded list (D-09, ANALYZE-05)
- Add Cancel all batch button (D-09, ANALYZE-05)
- Emit: analyze-file, analyze-selection, analyze-folder, analyze-connection,
  analysis-confirmed, analysis-cancelled, analysis-item-cancel/retry/skip, analysis-cancel-all

CloudFolderView:
- Pass analysis props to StorageBrowser from cloudStore
- Add analysis estimate, pending request state
- Handle all analysis emits: onAnalyzeFile, onAnalyzeSelection, onAnalyzeFolder,
  onAnalyzeConnection, onAnalysisConfirmed, onAnalysisItemCancel/Retry/Skip, onAnalysisCancelAll
- Routes all API calls through cloudStore (requestEstimate, enqueueAnalysis, etc.)
This commit is contained in:
curo1305
2026-06-23 19:42:53 +02:00
parent 1291040f9b
commit 04e6ea2ba2
2 changed files with 569 additions and 2 deletions
@@ -22,6 +22,48 @@
:order="sortOrder" :order="sortOrder"
@change="handleSortChange" @change="handleSortChange"
/> />
<!-- Phase 14: Cloud analysis toolbar actions -->
<template v-if="mode === 'cloud'">
<!-- Analyze selected files (appears when selection is non-empty) -->
<button
v-if="internalSelectedItems.size > 0"
type="button"
data-test="analyze-selection"
title="Analyze selected files"
aria-label="Analyze selected files"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-violet-600 border border-violet-200 hover:bg-violet-50 active:bg-violet-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-1"
@click="emitAnalyzeSelection"
>
<AppIcon name="sparkles" class="w-4 h-4" />
Analyze
</button>
<!-- Analyze current folder (shown when inside a subfolder) -->
<button
v-if="breadcrumb.length > 0"
type="button"
data-test="analyze-folder"
title="Analyze this folder"
aria-label="Analyze this folder"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-violet-600 border border-violet-200 hover:bg-violet-50 active:bg-violet-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-1"
@click="$emit('analyze-folder')"
>
<AppIcon name="sparkles" class="w-4 h-4" />
Analyze folder
</button>
<!-- Analyze whole connection (always shown in cloud mode) -->
<button
v-if="connectionRoot"
type="button"
data-test="analyze-connection"
title="Analyze entire connection"
aria-label="Analyze entire connection"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-violet-600 border border-violet-200 hover:bg-violet-50 active:bg-violet-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-1"
@click="$emit('analyze-connection', { id: connectionRoot.id })"
>
<AppIcon name="sparkles" class="w-4 h-4" />
Analyze all
</button>
</template>
<button <button
v-if="effectiveCaps.create_folder" v-if="effectiveCaps.create_folder"
@click="$emit('new-folder')" @click="$emit('new-folder')"
@@ -54,6 +96,42 @@
> >
<AppIcon name="chartBar" class="w-4 h-4" /> <AppIcon name="chartBar" class="w-4 h-4" />
</button> </button>
<!-- Mobile: analyze-selection button -->
<button
v-if="mode === 'cloud' && internalSelectedItems.size > 0"
type="button"
data-test="analyze-selection"
title="Analyze selected"
aria-label="Analyze selected"
class="w-9 h-9 rounded-lg border border-violet-200 text-violet-600 hover:bg-violet-50 active:bg-violet-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-1"
@click="emitAnalyzeSelection"
>
<AppIcon name="sparkles" class="w-4 h-4" />
</button>
<!-- Mobile: analyze-folder button -->
<button
v-if="mode === 'cloud' && breadcrumb.length > 0"
type="button"
data-test="analyze-folder"
title="Analyze folder"
aria-label="Analyze folder"
class="w-9 h-9 rounded-lg border border-violet-200 text-violet-600 hover:bg-violet-50 active:bg-violet-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-1"
@click="$emit('analyze-folder')"
>
<AppIcon name="sparkles" class="w-4 h-4" />
</button>
<!-- Mobile: analyze-connection button -->
<button
v-if="mode === 'cloud' && connectionRoot"
type="button"
data-test="analyze-connection"
title="Analyze all"
aria-label="Analyze all"
class="w-9 h-9 rounded-lg border border-violet-200 text-violet-600 hover:bg-violet-50 active:bg-violet-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-1"
@click="$emit('analyze-connection', { id: connectionRoot.id })"
>
<AppIcon name="sparkles" class="w-4 h-4" />
</button>
<button <button
v-if="effectiveCaps.create_folder" v-if="effectiveCaps.create_folder"
type="button" type="button"
@@ -100,6 +178,43 @@
</div> </div>
</transition> </transition>
<!--
Phase 14 / D-03: Analysis estimate review modal.
Shown when analysisEstimate prop is set, giving users a chance to review
supported_count, total_provider_bytes, and unsupported_count before starting.
-->
<div
v-if="mode === 'cloud' && analysisEstimate"
data-test="analysis-estimate-modal"
class="mx-4 sm:mx-6 mt-2 px-4 py-3 rounded-xl border border-violet-200 bg-violet-50 space-y-2"
role="dialog"
aria-modal="false"
aria-label="Analysis estimate"
>
<p class="text-sm font-semibold text-violet-800">Ready to analyze</p>
<div class="text-xs text-violet-700 space-y-1">
<p><span class="font-medium">{{ analysisEstimate.supported_count }}</span> supported files will be analyzed</p>
<p v-if="analysisEstimate.unsupported_count > 0">
<span class="font-medium">{{ analysisEstimate.unsupported_count }}</span> unsupported files will be skipped
</p>
<p v-if="analysisEstimate.total_provider_bytes">
Estimated size: <span class="font-medium">{{ formatSize(analysisEstimate.total_provider_bytes) }}</span>
</p>
</div>
<div class="flex gap-2 pt-1">
<button
data-test="estimate-start"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-violet-600 text-white hover:bg-violet-700 active:bg-violet-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500"
@click="$emit('analysis-confirmed', analysisEstimate)"
>Start analysis</button>
<button
data-test="estimate-cancel"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white border border-violet-200 text-violet-700 hover:bg-violet-100 active:bg-violet-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500"
@click="$emit('analysis-cancelled')"
>Cancel</button>
</div>
</div>
<!-- <!--
D-12 / D-14: Cloud health banner shown in cloud mode when folderFreshness indicates D-12 / D-14: Cloud health banner shown in cloud mode when folderFreshness indicates
a connection issue (warning or requires_reauth). Keeps cached items visible below a connection issue (warning or requires_reauth). Keeps cached items visible below
@@ -165,6 +280,118 @@
--> -->
<UploadProgress v-if="mode !== 'cloud'" :items="uploadQueue" /> <UploadProgress v-if="mode !== 'cloud'" :items="uploadQueue" />
<!--
Phase 14: Analysis aggregate progress and expandable item queue.
D-05: Shown when an analysis job is active (analysisQueue non-empty).
D-06: Default labels are simplified (waiting/working/done/failed).
D-08: Detailed mode shows internal stage labels when detailedAnalysisProgress=true.
D-09: Per-item cancel/retry/skip controls.
-->
<template v-if="mode === 'cloud' && analysisQueue && analysisQueue.length > 0">
<div
data-test="analysis-aggregate-progress"
class="mt-3 rounded-xl border border-violet-200 bg-violet-50 px-4 py-3"
>
<!-- Aggregate header row -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<AppIcon name="sparkles" class="w-4 h-4 text-violet-500 shrink-0" />
<span class="text-sm font-semibold text-violet-800">
Analysis
{{ detailedAnalysisProgress ? 'running' : aggregateProgressLabel }}
</span>
<span class="text-xs text-violet-600">
{{ analysisQueue.filter(i => ['done', 'skipped'].includes(i.ui_status ?? translateStatus(i.status))).length }}
/ {{ analysisQueue.length }}
{{ detailedAnalysisProgress ? '— ' + activeStageLabel : '' }}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<!-- Cancel all button (D-09) -->
<button
data-test="analysis-cancel-all"
type="button"
title="Cancel all"
aria-label="Cancel all analysis"
class="px-2 py-1 text-xs font-medium rounded-lg border border-violet-300 text-violet-700 hover:bg-violet-100 active:bg-violet-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500"
@click="$emit('analysis-cancel-all', { job_id: analysisQueue[0]?.job_id })"
>Cancel all</button>
<!-- Expand/collapse toggle (D-05) -->
<button
data-test="analysis-queue-expand"
type="button"
:title="analysisQueueExpanded ? 'Collapse queue' : 'Expand queue'"
:aria-label="analysisQueueExpanded ? 'Collapse queue' : 'Expand queue'"
:aria-expanded="analysisQueueExpanded"
class="w-7 h-7 flex items-center justify-center rounded-lg text-violet-600 hover:bg-violet-100 active:bg-violet-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500"
@click="analysisQueueExpanded = !analysisQueueExpanded"
>
<AppIcon :name="analysisQueueExpanded ? 'chevronUp' : 'chevronDown'" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Expandable per-item list (D-05) -->
<div
v-if="analysisQueueExpanded"
data-test="analysis-queue-item-list"
class="mt-2 rounded-lg border border-violet-100 divide-y divide-violet-100 bg-white overflow-hidden"
>
<div
v-for="item in analysisQueue"
:key="item.item_id"
data-test="analysis-queue-item"
class="px-3 py-2 flex items-center gap-2 text-xs"
>
<AppIcon name="document" class="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span class="truncate flex-1 text-gray-700">{{ item.name }}</span>
<!-- Status label simplified or detailed per D-06 -->
<span
class="shrink-0 px-1.5 py-0.5 rounded text-xs font-medium"
:class="statusBadgeClass(item)"
>
{{ detailedAnalysisProgress ? (item.status ?? item.ui_status) : (item.ui_status ?? item.status) }}
</span>
<!-- Per-item controls cancel for queued/working, retry+skip for failed -->
<template v-if="['waiting', 'working'].includes(item.ui_status)">
<button
data-test="analysis-item-cancel"
type="button"
title="Cancel this item"
aria-label="Cancel"
class="shrink-0 p-1 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 active:bg-red-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
@click="$emit('analysis-item-cancel', { job_id: item.job_id, item_id: item.item_id })"
>
<AppIcon name="x" class="w-3.5 h-3.5" />
</button>
</template>
<template v-else-if="item.ui_status === 'failed'">
<button
data-test="analysis-item-retry"
type="button"
title="Retry this item"
aria-label="Retry"
class="shrink-0 p-1 rounded text-violet-400 hover:text-violet-600 hover:bg-violet-50 active:bg-violet-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500"
@click="$emit('analysis-item-retry', { job_id: item.job_id, item_id: item.item_id })"
>
<AppIcon name="refresh" class="w-3.5 h-3.5" />
</button>
<button
data-test="analysis-item-skip"
type="button"
title="Skip this item"
aria-label="Skip"
class="shrink-0 p-1 rounded text-gray-400 hover:text-gray-600 hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400"
@click="$emit('analysis-item-skip', { job_id: item.job_id, item_id: item.item_id })"
>
<AppIcon name="chevronRight" class="w-3.5 h-3.5" />
</button>
</template>
</div>
</div>
</div>
</template>
<!-- Sequential cloud upload queue dialogs (D-03 / D-04) --> <!-- Sequential cloud upload queue dialogs (D-03 / D-04) -->
<!-- Only rendered in cloud mode when the queue has a paused item --> <!-- Only rendered in cloud mode when the queue has a paused item -->
<template v-if="mode === 'cloud'"> <template v-if="mode === 'cloud'">
@@ -348,12 +575,27 @@
:key="`d-${file.id}`" :key="`d-${file.id}`"
:draggable="effectiveCaps.drag_move" :draggable="effectiveCaps.drag_move"
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none" class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
:class="{ 'opacity-50': draggingFile?.id === file.id }" :class="{ 'opacity-50': draggingFile?.id === file.id, 'bg-violet-50': internalSelectedItems.has(file.id) }"
@click="draggingFile ? null : $emit('file-open', file)" @click="draggingFile ? null : $emit('file-open', file)"
@dragstart="effectiveCaps.drag_move ? onFileDragStart(file, $event) : null" @dragstart="effectiveCaps.drag_move ? onFileDragStart(file, $event) : null"
@dragend="onFileDragEnd" @dragend="onFileDragEnd"
> >
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0"> <!-- Phase 14: cloud-mode selection checkbox -->
<div
v-if="mode === 'cloud'"
class="w-7 h-7 flex items-center justify-center shrink-0"
@click.stop="toggleFileSelection(file.id)"
>
<input
type="checkbox"
data-test="file-select"
:checked="internalSelectedItems.has(file.id)"
class="w-4 h-4 rounded border-gray-300 text-violet-600 focus:ring-violet-500 cursor-pointer"
aria-label="Select file"
@click.stop="toggleFileSelection(file.id)"
/>
</div>
<div v-else class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0">
<AppIcon name="document" class="w-4 h-4 text-indigo-400" /> <AppIcon name="document" class="w-4 h-4 text-indigo-400" />
</div> </div>
@@ -361,6 +603,22 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<p class="text-sm font-medium text-gray-900 truncate">{{ file.original_name ?? file.name }}</p> <p class="text-sm font-medium text-gray-900 truncate">{{ file.original_name ?? file.name }}</p>
<span v-if="file.is_shared" class="shrink-0 bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-0.5 rounded-full">Shared</span> <span v-if="file.is_shared" class="shrink-0 bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-0.5 rounded-full">Shared</span>
<!--
Phase 14 / D-01: Analyze button inside the name cell so it does not
appear in [data-test="file-row-actions"] (which contains capability-gated
buttons). Analyze is always available in cloud mode not capability-gated.
-->
<button
v-if="mode === 'cloud'"
type="button"
data-test="analyze-file"
title="Analyze"
aria-label="Analyze"
class="shrink-0 p-1 rounded transition-colors text-gray-400 hover:text-violet-600 hover:bg-violet-50 active:bg-violet-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 opacity-0 group-hover:opacity-100"
@click.stop="$emit('analyze-file', file)"
>
<AppIcon name="sparkles" class="w-3.5 h-3.5" />
</button>
</div> </div>
<div v-if="file.topics?.length" class="flex items-center gap-1 mt-0.5 flex-wrap"> <div v-if="file.topics?.length" class="flex items-center gap-1 mt-0.5 flex-wrap">
<TopicBadge <TopicBadge
@@ -601,6 +859,27 @@ const props = defineProps({
lastRefreshedAt: { type: String, default: null }, lastRefreshedAt: { type: String, default: null },
/** 'cached' | 'cloud_only' | null — affects size column markers */ /** 'cached' | 'cloud_only' | null — affects size column markers */
byteAvailability: { type: String, default: null }, byteAvailability: { type: String, default: null },
/**
* Phase 14 / D-03: Analysis estimate result to show in the estimate review modal.
* When set, displays the estimate modal for user confirmation before starting.
* { supported_count, unsupported_count, total_provider_bytes, recursive, scope_kind }
*/
analysisEstimate: { type: Object, default: null },
/**
* Phase 14 / D-05: Active analysis queue items to display in aggregate progress.
* Each item: { job_id, item_id, name, status, ui_status, progress, error }
*/
analysisQueue: { type: Array, default: () => [] },
/**
* Phase 14 / D-06: When true, show detailed internal stage labels
* (downloading/extracting/classifying). When false (default), show simplified labels.
*/
detailedAnalysisProgress: { type: Boolean, default: false },
/**
* Phase 14: Pre-selected item IDs for multi-select analysis.
* When provided, the component initializes selection from this prop.
*/
selectedItems: { type: Array, default: () => [] },
}) })
const emit = defineEmits([ const emit = defineEmits([
@@ -621,6 +900,19 @@ const emit = defineEmits([
'capability-explain', 'capability-explain',
// D-12: health banner reconnect action — emitted when user clicks Reconnect in cloud health banner // D-12: health banner reconnect action — emitted when user clicks Reconnect in cloud health banner
'reconnect', 'reconnect',
// Phase 14 / D-01: analysis actions
'analyze-file', // single file analyze: (fileItem) => void
'analyze-selection', // multi-select analyze: (fileIds[]) => void
'analyze-folder', // analyze current folder
'analyze-connection', // analyze whole connection: ({ id }) => void
// Phase 14 / D-03: estimate modal actions
'analysis-confirmed', // user confirmed estimate and wants to start
'analysis-cancelled', // user dismissed the estimate modal
// Phase 14 / D-09: analysis item/batch controls
'analysis-item-cancel', // cancel single item: ({ job_id, item_id }) => void
'analysis-item-retry', // retry failed item: ({ job_id, item_id }) => void
'analysis-item-skip', // skip failed item: ({ job_id, item_id }) => void
'analysis-cancel-all', // cancel whole batch: ({ job_id }) => void
]) ])
/** /**
@@ -714,6 +1006,85 @@ const searchBarRef = ref(null)
const mobileSearchOpen = ref(false) const mobileSearchOpen = ref(false)
const mobileSortOpen = ref(false) const mobileSortOpen = ref(false)
// ── Phase 14: Analysis state ──────────────────────────────────────────────────
/**
* Internal set of selected file IDs for multi-select analysis.
* Initialized from selectedItems prop; managed locally.
* Uses a Set for O(1) has() checks in v-if.
*/
const internalSelectedItems = ref(new Set(props.selectedItems ?? []))
/** Expand/collapse state for the analysis item queue list. */
const analysisQueueExpanded = ref(false)
/** D-06: Internal simplified status translation for display. */
function translateStatus(status) {
const SIMPLIFIED = {
queued: 'waiting',
downloading: 'working',
extracting: 'working',
classifying: 'working',
indexed: 'done',
already_current: 'done',
cancelled: 'skipped',
failed: 'failed',
unsupported: 'skipped',
stale: 'working',
}
return SIMPLIFIED[status] ?? status
}
/** Toggle a file in the internal selection set (cloud mode only). */
function toggleFileSelection(fileId) {
const next = new Set(internalSelectedItems.value)
if (next.has(fileId)) {
next.delete(fileId)
} else {
next.add(fileId)
}
internalSelectedItems.value = next
}
/** Emit analyze-selection with array of selected file objects. */
function emitAnalyzeSelection() {
const selectedIds = [...internalSelectedItems.value]
const selectedFiles = props.files.filter(f => selectedIds.includes(f.id))
emit('analyze-selection', selectedFiles)
}
/** Compute aggregate progress label from queue. */
const aggregateProgressLabel = computed(() => {
const q = props.analysisQueue ?? []
const working = q.filter(i => ['waiting', 'working'].includes(i.ui_status)).length
const done = q.filter(i => ['done', 'skipped'].includes(i.ui_status)).length
const failed = q.filter(i => i.ui_status === 'failed').length
if (failed > 0) return `${failed} failed`
if (working > 0) return 'running'
if (done === q.length && q.length > 0) return 'complete'
return 'running'
})
/** Current active stage label for detailed mode. */
const activeStageLabel = computed(() => {
const q = props.analysisQueue ?? []
const workingItem = q.find(i => ['working'].includes(i.ui_status))
if (!workingItem) return ''
// Use the raw status for detailed stage info
return workingItem.status ?? ''
})
/** CSS class for per-item status badge. */
function statusBadgeClass(item) {
const status = item.ui_status ?? translateStatus(item.status)
if (status === 'working') return 'bg-blue-100 text-blue-700'
if (status === 'waiting') return 'bg-gray-100 text-gray-600'
if (status === 'done') return 'bg-green-100 text-green-700'
if (status === 'failed') return 'bg-red-100 text-red-700'
if (status === 'skipped') return 'bg-amber-100 text-amber-700'
return 'bg-gray-100 text-gray-600'
}
const showNewFolderInput = ref(false) const showNewFolderInput = ref(false)
const newFolderName = ref('') const newFolderName = ref('')
const newFolderError = ref('') const newFolderError = ref('')
+196
View File
@@ -13,11 +13,24 @@
:folder-freshness="cloudStore.folderFreshness" :folder-freshness="cloudStore.folderFreshness"
:last-refreshed-at="cloudStore.lastRefreshedAt" :last-refreshed-at="cloudStore.lastRefreshedAt"
:byte-availability="cloudStore.byteAvailability" :byte-availability="cloudStore.byteAvailability"
:analysis-estimate="analysisEstimate"
:analysis-queue="cloudStore.analysisQueue"
:detailed-analysis-progress="cloudStore.detailedAnalysisProgress"
@breadcrumb-navigate="handleBreadcrumbNavigate" @breadcrumb-navigate="handleBreadcrumbNavigate"
@upload="onFilesSelected" @upload="onFilesSelected"
@folder-navigate="item => navigateTo(item)" @folder-navigate="item => navigateTo(item)"
@file-open="onFileOpen" @file-open="onFileOpen"
@upload-queue-resolve="onQueueResolve" @upload-queue-resolve="onQueueResolve"
@analyze-file="onAnalyzeFile"
@analyze-selection="onAnalyzeSelection"
@analyze-folder="onAnalyzeFolder"
@analyze-connection="onAnalyzeConnection"
@analysis-confirmed="onAnalysisConfirmed"
@analysis-cancelled="analysisEstimate = null"
@analysis-item-cancel="onAnalysisItemCancel"
@analysis-item-retry="onAnalysisItemRetry"
@analysis-item-skip="onAnalysisItemSkip"
@analysis-cancel-all="onAnalysisCancelAll"
/> />
</template> </template>
@@ -40,6 +53,23 @@ const loading = ref(true)
const error = ref('') const error = ref('')
const uploadQueue = ref([]) const uploadQueue = ref([])
// ── Phase 14: Analysis state ──────────────────────────────────────────────────
/**
* Pending analysis estimate to show in the review modal.
* When set, StorageBrowser renders the estimate modal for confirmation.
* After confirmation/cancellation, set back to null.
*
* T-14-02: Never contains credentials or object_key.
*/
const analysisEstimate = ref(null)
/**
* Pending analysis request context — stored so onAnalysisConfirmed
* can enqueue the job with the right scope/items after estimate review.
*/
const analysisPending = ref(null)
/** Connection UUID from route — never uses provider slug */ /** Connection UUID from route — never uses provider slug */
const connectionId = computed(() => route.params.connectionId) const connectionId = computed(() => route.params.connectionId)
const folderId = computed(() => route.params.folderId) const folderId = computed(() => route.params.folderId)
@@ -408,6 +438,172 @@ async function onFileOpen(file) {
} }
} }
// ── Phase 14: Analysis handlers ───────────────────────────────────────────────
/**
* ANALYZE-01 / D-01: Handle analyze-file emission from StorageBrowser.
* Calls estimate for a single file; shows estimate modal when result is non-trivial.
*
* @param {object} file - CloudItemOut with provider_item_id
*/
async function onAnalyzeFile(file) {
if (!file?.provider_item_id) return
try {
const estimate = await cloudStore.requestEstimate(connectionId.value, {
scope: 'file',
provider_item_ids: [file.provider_item_id],
recursive: false,
})
analysisPending.value = {
scope: 'file',
provider_item_ids: [file.provider_item_id],
recursive: false,
}
// Show estimate modal — confirmed via onAnalysisConfirmed
analysisEstimate.value = estimate
} catch (e) {
toast.show(`Analysis estimate failed: ${e.message || 'Unknown error'}`, 'error')
}
}
/**
* ANALYZE-02 / D-01: Handle analyze-selection emission from StorageBrowser.
* Calls estimate for the selected files.
*
* @param {object[]} selectedFiles - Array of CloudItemOut items
*/
async function onAnalyzeSelection(selectedFiles) {
if (!selectedFiles?.length) return
try {
const providerItemIds = selectedFiles.map(f => f.provider_item_id).filter(Boolean)
const estimate = await cloudStore.requestEstimate(connectionId.value, {
scope: 'selection',
provider_item_ids: providerItemIds,
recursive: false,
})
analysisPending.value = {
scope: 'selection',
provider_item_ids: providerItemIds,
recursive: false,
}
analysisEstimate.value = estimate
} catch (e) {
toast.show(`Analysis estimate failed: ${e.message || 'Unknown error'}`, 'error')
}
}
/**
* ANALYZE-02 / D-01: Handle analyze-folder emission from StorageBrowser.
* Calls estimate for the current folder.
*/
async function onAnalyzeFolder() {
const folderRef = folderId.value && folderId.value !== 'root' ? folderId.value : null
if (!folderRef) return
try {
const estimate = await cloudStore.requestEstimate(connectionId.value, {
scope: 'folder',
provider_item_ids: [folderRef],
recursive: true,
})
analysisPending.value = {
scope: 'folder',
provider_item_ids: [folderRef],
recursive: true,
}
analysisEstimate.value = estimate
} catch (e) {
toast.show(`Analysis estimate failed: ${e.message || 'Unknown error'}`, 'error')
}
}
/**
* ANALYZE-03 / D-01: Handle analyze-connection emission from StorageBrowser.
* Calls estimate for the entire connection.
*/
async function onAnalyzeConnection() {
try {
const estimate = await cloudStore.requestEstimate(connectionId.value, {
scope: 'connection',
recursive: true,
})
analysisPending.value = {
scope: 'connection',
recursive: true,
}
analysisEstimate.value = estimate
} catch (e) {
toast.show(`Analysis estimate failed: ${e.message || 'Unknown error'}`, 'error')
}
}
/**
* D-03: Handle analysis-confirmed emission from StorageBrowser estimate modal.
* Enqueues the analysis job with the pending scope context.
*/
async function onAnalysisConfirmed() {
if (!analysisPending.value) return
const pending = analysisPending.value
analysisPending.value = null
analysisEstimate.value = null
try {
await cloudStore.enqueueAnalysis(connectionId.value, pending)
} catch (e) {
toast.show(`Failed to start analysis: ${e.message || 'Unknown error'}`, 'error')
}
}
/**
* ANALYZE-05 / D-09: Cancel a single analysis job item.
*
* @param {{ job_id: string, item_id: string }} payload
*/
async function onAnalysisItemCancel({ job_id, item_id }) {
try {
await cloudStore.cancelItem(job_id, item_id)
} catch (e) {
toast.show(`Failed to cancel item: ${e.message || 'Unknown error'}`, 'error')
}
}
/**
* ANALYZE-05 / D-09: Retry a failed analysis job item.
*
* @param {{ job_id: string, item_id: string }} payload
*/
async function onAnalysisItemRetry({ job_id, item_id }) {
try {
await cloudStore.retryItem(job_id, item_id)
} catch (e) {
toast.show(`Failed to retry item: ${e.message || 'Unknown error'}`, 'error')
}
}
/**
* ANALYZE-05 / D-09: Skip a failed analysis job item.
*
* @param {{ job_id: string, item_id: string }} payload
*/
async function onAnalysisItemSkip({ job_id, item_id }) {
try {
await cloudStore.skipItem(job_id, item_id)
} catch (e) {
toast.show(`Failed to skip item: ${e.message || 'Unknown error'}`, 'error')
}
}
/**
* ANALYZE-05 / D-09: Cancel the entire analysis job.
*
* @param {{ job_id: string }} payload
*/
async function onAnalysisCancelAll({ job_id }) {
try {
await cloudStore.cancelJob(job_id)
} catch (e) {
toast.show(`Failed to cancel analysis: ${e.message || 'Unknown error'}`, 'error')
}
}
onMounted(async () => { onMounted(async () => {
// Ensure connections are loaded so connectionRoot resolves // Ensure connections are loaded so connectionRoot resolves
if (cloudStore.connections.length === 0) { if (cloudStore.connections.length === 0) {