From 6521506ae608f7a84fff558cddc224702411fec9 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 5 Aug 2026 17:24:03 -0400 Subject: [PATCH 1/6] Library: multi-select datasets for bulk delete show-select checkboxes with header select-all; a Delete (N) button confirms once and removes every selected dataset. --- .../desktop/frontend/components/Recent.vue | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue index 011ec6f3d..46d9aa1c3 100644 --- a/client/platform/desktop/frontend/components/Recent.vue +++ b/client/platform/desktop/frontend/components/Recent.vue @@ -165,6 +165,37 @@ export default defineComponent({ removeRecents(datasetId); } + const selectedRecents = ref([] as JsonConfigCache[]); + + async function confirmDeleteSelected() { + const items = selectedRecents.value; + if (items.length === 0) { + return; + } + const result = await prompt({ + title: `Delete ${items.length} dataset${items.length > 1 ? 's' : ''}`, + text: ['Do you want to delete the selected datasets?', + '1. Deleting datasets will not remove source media, such as images or video.', + '2. It will not remove annotations files that were imported when the datasets were created.', + '3. This will remove any annotations that have been created in DIVE for these datasets', + '4. Use the Export button for a dataset to create a copy of the last set of annotations'], + positiveButton: 'Delete', + negativeButton: 'Cancel', + confirm: true, + }); + if (!result) { + return; + } + // Deletions run sequentially so a failure stops before touching the rest + // eslint-disable-next-line no-restricted-syntax + for (const item of items) { + // eslint-disable-next-line no-await-in-loop + await request(() => api.deleteDataset(item.id)); + removeRecents(item.id); + } + selectedRecents.value = []; + } + const filteredRecents = computed(() => recents.value .filter((v) => v.name.toLowerCase().indexOf((searchText.value || '').toLowerCase()) >= 0)); function getTypeIcon(recent: JsonConfigCache) { @@ -259,6 +290,7 @@ export default defineComponent({ getTypeIcon, importMedia: api.importMedia, confirmDeleteDataset, + confirmDeleteSelected, preloadCheck, toDisplayString, resetError, @@ -266,6 +298,7 @@ export default defineComponent({ multiCamOpenType, stereo, filteredRecents, + selectedRecents, pendingImportPayload, bulkImport, searchText, @@ -469,6 +502,30 @@ export default defineComponent({
Recent
+ + + Delete all selected datasets + Date: Wed, 5 Aug 2026 20:32:48 -0400 Subject: [PATCH 2/6] Library selection: right-side checkboxes, bulk pipeline/training/delete actions Selection column replaces the per-row trash; Run Pipeline and Run Training navigate to their pages with the checked datasets pre-staged via query. --- .../frontend/components/MultiPipeline.vue | 20 +- .../frontend/components/MultiTrainingMenu.vue | 17 +- .../desktop/frontend/components/Recent.vue | 174 +++++++++++++----- 3 files changed, 156 insertions(+), 55 deletions(-) diff --git a/client/platform/desktop/frontend/components/MultiPipeline.vue b/client/platform/desktop/frontend/components/MultiPipeline.vue index fd0b08f73..2c7852b07 100644 --- a/client/platform/desktop/frontend/components/MultiPipeline.vue +++ b/client/platform/desktop/frontend/components/MultiPipeline.vue @@ -7,7 +7,7 @@ import { watch, } from 'vue'; import { DataTableHeader } from 'vuetify'; -import { useRouter } from 'vue-router/composables'; +import { useRoute, useRouter } from 'vue-router/composables'; import { Pipe, Pipelines, useApi } from 'dive-common/apispec'; import { parentDatasetId } from 'dive-common/compositeDatasetId'; import { @@ -30,6 +30,16 @@ import { datasets, JsonConfigCache } from '../store/dataset'; const { getPipelineList, runPipeline, hasCalibrationFile } = useApi(); const { prompt } = usePrompt(); const router = useRouter(); +const route = useRoute(); + +/** Dataset ids handed off by another page (e.g. the Library selection). */ +function preselectedDatasetIds(): string[] { + const query = route.query.datasetIds; + const values = Array.isArray(query) ? query : [query]; + return values + .flatMap((value) => (value || '').split(',')) + .filter((id) => id in datasets.value); +} const unsortedPipelines = ref({} as Pipelines); const selectedPipelineType: Ref = ref(null); @@ -173,8 +183,11 @@ function toggleStaged(item: JsonConfigCache) { async function runPipelineForDatasets() { if (selectedPipeline.value !== null) { + // Only the staged datasets compatible with (and displayed for) the + // selected pipeline; staged ids can hold datasets other pipelines accept. + const runIds = stagedDatasets.value.map((item: JsonConfigCache) => item.id); const results = await Promise.allSettled( - stagedDatasetIds.value.map((datasetId: string) => { + runIds.map((datasetId: string) => { if (pipelineCreatesNewDataset(selectedPipeline.value)) { const datasetMeta = availableItems.value.find((item: JsonConfigCache) => item.id === datasetId); if (!datasetMeta) { @@ -188,7 +201,7 @@ async function runPipelineForDatasets() { }), ); const failed = results - .map((result, i) => ({ result, datasetId: stagedDatasetIds.value[i] })) + .map((result, i) => ({ result, datasetId: runIds[i] })) .filter(({ result }) => result.status === 'rejected'); if (failed.length > 0) { @@ -204,6 +217,7 @@ async function runPipelineForDatasets() { } onBeforeMount(async () => { + stagedDatasetIds.value = preselectedDatasetIds(); unsortedPipelines.value = await getPipelineList(); availableItems.value = getAvailableItems(); }); diff --git a/client/platform/desktop/frontend/components/MultiTrainingMenu.vue b/client/platform/desktop/frontend/components/MultiTrainingMenu.vue index 3e7b9853b..9e214ab44 100644 --- a/client/platform/desktop/frontend/components/MultiTrainingMenu.vue +++ b/client/platform/desktop/frontend/components/MultiTrainingMenu.vue @@ -18,7 +18,7 @@ import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { itemsPerPageOptions, simplifyTrainingName } from 'dive-common/constants'; import { clientSettings } from 'dive-common/store/settings'; -import { useRouter } from 'vue-router/composables'; +import { useRoute, useRouter } from 'vue-router/composables'; import { datasets } from '../store/dataset'; function joinPath(dir: string, filename: string) { @@ -33,6 +33,7 @@ export default defineComponent({ } = useApi(); const { prompt } = usePrompt(); const router = useRouter(); + const route = useRoute(); const unsortedPipelines = ref({} as Pipelines); const labelFile = ref(null as File | null); @@ -56,6 +57,20 @@ export default defineComponent({ unsortedPipelines.value = await getPipelineList(); }); + /* Stage dataset ids handed off by another page (e.g. the Library selection). */ + onBeforeMount(() => { + const query = route.query.datasetIds; + const values = Array.isArray(query) ? query : [query]; + values + .flatMap((value) => (value || '').split(',')) + .forEach((id) => { + const meta = datasets.value[id]; + if (meta && meta.subType === null) { + set(data.stagedItems, id, meta); + } + }); + }); + const trainedPipelines = computed(() => { if (unsortedPipelines.value.trained) { return unsortedPipelines.value.trained.pipes.map((item) => item.name); diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue index 46d9aa1c3..8e1d6d269 100644 --- a/client/platform/desktop/frontend/components/Recent.vue +++ b/client/platform/desktop/frontend/components/Recent.vue @@ -147,25 +147,20 @@ export default defineComponent({ pendingImportPayload.value = [await request(() => api.importMultiCam(args))]; } - async function confirmDeleteDataset(datasetId: string, datasetName: string) { - const result = await prompt({ - title: 'Warning Deleting Dataset', - text: [`Do you want to delete dataset ${datasetName}?`, - '1. Deleting dataset will not remove source media, such as images or video.', - '2. It will not remove annotations files that were imported when the dataset was created.', - '3. This will remove any annotations that bave been created in DIVE for this dataset', - '4. Use the Export button for the dataset to create a copy of the last set of annotations'], - confirm: true, - }); - if (!result) { - return; - } - await request(() => api.deleteDataset(datasetId)); - //Now we need to update recents by removing the dataset from localStorage - removeRecents(datasetId); + const selectedRecents = ref([] as JsonConfigCache[]); + const selectedIds = computed(() => new Set(selectedRecents.value.map((item) => item.id))); + + function isSelected(item: JsonConfigCache) { + return selectedIds.value.has(item.id); } - const selectedRecents = ref([] as JsonConfigCache[]); + function toggleSelected(item: JsonConfigCache) { + if (isSelected(item)) { + selectedRecents.value = selectedRecents.value.filter((v) => v.id !== item.id); + } else { + selectedRecents.value = selectedRecents.value.concat([item]); + } + } async function confirmDeleteSelected() { const items = selectedRecents.value; @@ -198,6 +193,31 @@ export default defineComponent({ const filteredRecents = computed(() => recents.value .filter((v) => v.name.toLowerCase().indexOf((searchText.value || '').toLowerCase()) >= 0)); + const allSelected = computed(() => filteredRecents.value.length > 0 + && filteredRecents.value.every((item) => selectedIds.value.has(item.id))); + const someSelected = computed(() => filteredRecents.value.some( + (item) => selectedIds.value.has(item.id), + )); + + function toggleSelectAll() { + if (allSelected.value) { + selectedRecents.value = []; + } else { + selectedRecents.value = filteredRecents.value.slice(); + } + } + + function selectedIdsQuery() { + return { datasetIds: selectedRecents.value.map((item) => item.id).join(',') }; + } + + function runPipelineOnSelected() { + router.push({ name: 'pipeline', query: selectedIdsQuery() }); + } + + function runTrainingOnSelected() { + router.push({ name: 'training', query: selectedIdsQuery() }); + } function getTypeIcon(recent: JsonConfigCache) { if (recent.subType) { if (recent.subType === 'stereo') { @@ -268,7 +288,7 @@ export default defineComponent({ }, { text: '', - value: 'delete', + value: 'select', sortable: false, width: 40, }, @@ -289,8 +309,12 @@ export default defineComponent({ openMultiCamDialog, getTypeIcon, importMedia: api.importMedia, - confirmDeleteDataset, confirmDeleteSelected, + runPipelineOnSelected, + runTrainingOnSelected, + isSelected, + toggleSelected, + toggleSelectAll, preloadCheck, toDisplayString, resetError, @@ -299,6 +323,8 @@ export default defineComponent({ stereo, filteredRecents, selectedRecents, + allSelected, + someSelected, pendingImportPayload, bulkImport, searchText, @@ -502,30 +528,6 @@ export default defineComponent({
Recent
- - - Delete all selected datasets - +

+ - From 1173c21fa937923da2cb642d32ce8534e566bb16 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 5 Aug 2026 20:38:53 -0400 Subject: [PATCH 3/6] Library: selection actions sit left of the search box, which stays fixed --- .../desktop/frontend/components/Recent.vue | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue index 8e1d6d269..722ef4119 100644 --- a/client/platform/desktop/frontend/components/Recent.vue +++ b/client/platform/desktop/frontend/components/Recent.vue @@ -529,29 +529,11 @@ export default defineComponent({ Recent - - - - - - Run a pipeline on the selected datasets @@ -612,6 +594,24 @@ export default defineComponent({ Delete all selected datasets + + +

Date: Mon, 10 Aug 2026 12:11:25 -0400 Subject: [PATCH 5/6] moving checkboxes to the left side, like girder and other tools --- .../platform/desktop/frontend/components/Recent.vue | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue index 2c8fda625..f3135fa3f 100644 --- a/client/platform/desktop/frontend/components/Recent.vue +++ b/client/platform/desktop/frontend/components/Recent.vue @@ -268,6 +268,12 @@ export default defineComponent({ } const headers: DataTableHeader[] = [ + { + text: '', + value: 'select', + sortable: false, + width: 40, + }, { text: 'Type', value: 'type', @@ -286,12 +292,6 @@ export default defineComponent({ sort: (a: string, b: string) => parseRecentDate(b).valueOf() - parseRecentDate(a).valueOf(), width: 140, }, - { - text: '', - value: 'select', - sortable: false, - width: 40, - }, ]; const toDisplayString = (dateString: string) => { const parsed = parseRecentDate(dateString); From eefd263be3cc930c3087174e0406e2675a1971f7 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Mon, 10 Aug 2026 12:22:16 -0400 Subject: [PATCH 6/6] handle invalid deletes --- .../desktop/frontend/components/Recent.vue | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue index f3135fa3f..63639f167 100644 --- a/client/platform/desktop/frontend/components/Recent.vue +++ b/client/platform/desktop/frontend/components/Recent.vue @@ -15,6 +15,7 @@ import ImportButton from 'dive-common/components/ImportButton.vue'; import ImportMultiCamDialog from 'dive-common/components/ImportMultiCamDialog.vue'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { useRequest } from 'dive-common/use'; +import { getResponseError } from 'vue-media-annotator/utils'; import { DataTableHeader } from 'vuetify'; import { useRouter } from 'vue-router/composables'; @@ -181,14 +182,35 @@ export default defineComponent({ if (!result) { return; } - // Deletions run sequentially so a failure stops before touching the rest + const failures: { id: string; name: string; reason: string }[] = []; + // Continue through the full selection so one failure does not skip the rest // eslint-disable-next-line no-restricted-syntax for (const item of items) { - // eslint-disable-next-line no-await-in-loop - await request(() => api.deleteDataset(item.id)); - removeRecents(item.id); + try { + // eslint-disable-next-line no-await-in-loop + await api.deleteDataset(item.id); + removeRecents(item.id); + } catch (err) { + failures.push({ id: item.id, name: item.name, reason: getResponseError(err) }); + } + } + const failedIds = new Set(failures.map((failure) => failure.id)); + selectedRecents.value = items.filter((item) => failedIds.has(item.id)); + if (failures.length > 0) { + const deletedCount = items.length - failures.length; + await prompt({ + title: deletedCount > 0 + ? 'Some datasets could not be deleted' + : 'Failed to delete datasets', + text: [ + deletedCount > 0 + ? `Deleted ${deletedCount} of ${items.length} datasets. The following could not be deleted:` + : 'None of the selected datasets could be deleted:', + ...failures.map((failure) => `${failure.name}: ${failure.reason}`), + ], + positiveButton: 'Okay', + }); } - selectedRecents.value = []; } const filteredRecents = computed(() => recents.value