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:
@@ -22,6 +22,48 @@
|
||||
:order="sortOrder"
|
||||
@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
|
||||
v-if="effectiveCaps.create_folder"
|
||||
@click="$emit('new-folder')"
|
||||
@@ -54,6 +96,42 @@
|
||||
>
|
||||
<AppIcon name="chartBar" class="w-4 h-4" />
|
||||
</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
|
||||
v-if="effectiveCaps.create_folder"
|
||||
type="button"
|
||||
@@ -100,6 +178,43 @@
|
||||
</div>
|
||||
</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
|
||||
a connection issue (warning or requires_reauth). Keeps cached items visible below
|
||||
@@ -165,6 +280,118 @@
|
||||
-->
|
||||
<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) -->
|
||||
<!-- Only rendered in cloud mode when the queue has a paused item -->
|
||||
<template v-if="mode === 'cloud'">
|
||||
@@ -348,12 +575,27 @@
|
||||
:key="`d-${file.id}`"
|
||||
: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="{ '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)"
|
||||
@dragstart="effectiveCaps.drag_move ? onFileDragStart(file, $event) : null"
|
||||
@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" />
|
||||
</div>
|
||||
|
||||
@@ -361,6 +603,22 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<!--
|
||||
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 v-if="file.topics?.length" class="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
<TopicBadge
|
||||
@@ -601,6 +859,27 @@ const props = defineProps({
|
||||
lastRefreshedAt: { type: String, default: null },
|
||||
/** 'cached' | 'cloud_only' | null — affects size column markers */
|
||||
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([
|
||||
@@ -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('')
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user