+
+
+
+
+
@@ -361,6 +603,22 @@
{{ file.original_name ?? file.name }}
Shared
+
+
[] },
+ /**
+ * 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([
@@ -621,6 +900,19 @@ const emit = defineEmits([
'capability-explain',
// D-12: health banner reconnect action — emitted when user clicks Reconnect in cloud health banner
'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 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 newFolderName = ref('')
const newFolderError = ref('')
diff --git a/frontend/src/views/CloudFolderView.vue b/frontend/src/views/CloudFolderView.vue
index c20f12e..c4abed8 100644
--- a/frontend/src/views/CloudFolderView.vue
+++ b/frontend/src/views/CloudFolderView.vue
@@ -13,11 +13,24 @@
:folder-freshness="cloudStore.folderFreshness"
:last-refreshed-at="cloudStore.lastRefreshedAt"
:byte-availability="cloudStore.byteAvailability"
+ :analysis-estimate="analysisEstimate"
+ :analysis-queue="cloudStore.analysisQueue"
+ :detailed-analysis-progress="cloudStore.detailedAnalysisProgress"
@breadcrumb-navigate="handleBreadcrumbNavigate"
@upload="onFilesSelected"
@folder-navigate="item => navigateTo(item)"
@file-open="onFileOpen"
@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"
/>
@@ -40,6 +53,23 @@ const loading = ref(true)
const error = 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 */
const connectionId = computed(() => route.params.connectionId)
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 () => {
// Ensure connections are loaded so connectionRoot resolves
if (cloudStore.connections.length === 0) {