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"
|
: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('')
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user