feat: reconcile asset selection

This commit is contained in:
Benjamin Lu
2026-01-24 08:02:24 -08:00
parent b7a64b991e
commit 77ea40b43e
4 changed files with 154 additions and 11 deletions

View File

@@ -264,6 +264,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
throw new Error('Asset not found in media assets panel') throw new Error('Asset not found in media assets panel')
} }
assetSelectionStore.setSelection([assetId]) assetSelectionStore.setSelection([assetId])
assetSelectionStore.setLastSelectedAssetId(assetId)
} }
const inspectJobAsset = wrapWithErrorHandlingAsync( const inspectJobAsset = wrapWithErrorHandlingAsync(

View File

@@ -0,0 +1,89 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetSelection } from './useAssetSelection'
import { useAssetSelectionStore } from './useAssetSelectionStore'
vi.mock('@vueuse/core', () => ({
useKeyModifier: vi.fn(() => ref(false))
}))
describe('useAssetSelection', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('prunes selection to visible assets', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a', 'b'])
store.setLastSelectedIndex(1)
store.setLastSelectedAssetId('b')
selection.reconcileSelection([assets[1]])
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
expect(store.lastSelectedIndex).toBe(0)
expect(store.lastSelectedAssetId).toBe('b')
})
it('clears selection when no visible assets remain', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
store.setSelection(['a'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([])
expect(store.selectedAssetIds.size).toBe(0)
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
it('recomputes the anchor index when assets reorder', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([assets[1], assets[0]])
expect(store.lastSelectedIndex).toBe(1)
expect(store.lastSelectedAssetId).toBe('a')
})
it('clears anchor when the anchored asset is no longer visible', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a', 'b'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([assets[1]])
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
})

View File

@@ -21,6 +21,25 @@ export function useAssetSelection() {
const metaKey = computed(() => isActive.value && metaKeyRaw.value) const metaKey = computed(() => isActive.value && metaKeyRaw.value)
const cmdOrCtrlKey = computed(() => ctrlKey.value || metaKey.value) const cmdOrCtrlKey = computed(() => ctrlKey.value || metaKey.value)
function setAnchor(index: number, assetId: string | null) {
selectionStore.setLastSelectedIndex(index)
selectionStore.setLastSelectedAssetId(assetId)
}
function syncAnchorFromAssets(assets: AssetItem[]) {
const anchorId = selectionStore.lastSelectedAssetId
const anchorIndex = anchorId
? assets.findIndex((asset) => asset.id === anchorId)
: -1
if (anchorIndex !== -1) {
selectionStore.setLastSelectedIndex(anchorIndex)
return
}
setAnchor(-1, null)
}
/** /**
* Handle asset click with modifier keys for selection * Handle asset click with modifier keys for selection
* @param asset The clicked asset * @param asset The clicked asset
@@ -60,14 +79,14 @@ export function useAssetSelection() {
// Ctrl/Cmd + Click: Toggle individual selection // Ctrl/Cmd + Click: Toggle individual selection
if (cmdOrCtrlKey.value) { if (cmdOrCtrlKey.value) {
selectionStore.toggleSelection(assetId) selectionStore.toggleSelection(assetId)
selectionStore.setLastSelectedIndex(index) setAnchor(index, assetId)
return return
} }
// Normal Click: Single selection // Normal Click: Single selection
selectionStore.clearSelection() selectionStore.clearSelection()
selectionStore.addToSelection(assetId) selectionStore.addToSelection(assetId)
selectionStore.setLastSelectedIndex(index) setAnchor(index, assetId)
} }
/** /**
@@ -77,7 +96,8 @@ export function useAssetSelection() {
const allIds = allAssets.map((a) => a.id) const allIds = allAssets.map((a) => a.id)
selectionStore.setSelection(allIds) selectionStore.setSelection(allIds)
if (allAssets.length > 0) { if (allAssets.length > 0) {
selectionStore.setLastSelectedIndex(allAssets.length - 1) const lastIndex = allAssets.length - 1
setAnchor(lastIndex, allAssets[lastIndex].id)
} }
} }
@@ -88,6 +108,39 @@ export function useAssetSelection() {
return allAssets.filter((asset) => selectionStore.isSelected(asset.id)) return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
} }
function reconcileSelection(assets: AssetItem[]) {
if (selectionStore.selectedAssetIds.size === 0) {
return
}
if (assets.length === 0) {
selectionStore.clearSelection()
return
}
const visibleIds = new Set(assets.map((asset) => asset.id))
const nextSelectedIds: string[] = []
for (const id of selectionStore.selectedAssetIds) {
if (visibleIds.has(id)) {
nextSelectedIds.push(id)
}
}
if (nextSelectedIds.length === selectionStore.selectedAssetIds.size) {
syncAnchorFromAssets(assets)
return
}
if (nextSelectedIds.length === 0) {
selectionStore.clearSelection()
return
}
selectionStore.setSelection(nextSelectedIds)
syncAnchorFromAssets(assets)
}
/** /**
* Get the output count for a single asset * Get the output count for a single asset
* Same logic as in AssetsSidebarTab.vue * Same logic as in AssetsSidebarTab.vue
@@ -117,7 +170,7 @@ export function useAssetSelection() {
function deactivate() { function deactivate() {
isActive.value = false isActive.value = false
// Reset selection state to ensure clean state when deactivated // Reset selection state to ensure clean state when deactivated
selectionStore.reset() selectionStore.clearSelection()
} }
return { return {
@@ -132,10 +185,9 @@ export function useAssetSelection() {
selectAll, selectAll,
clearSelection: () => selectionStore.clearSelection(), clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets, getSelectedAssets,
reconcileSelection,
getOutputCount, getOutputCount,
getTotalOutputCount, getTotalOutputCount,
reset: () => selectionStore.reset(),
// Lifecycle management // Lifecycle management
activate, activate,
deactivate, deactivate,

View File

@@ -5,6 +5,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
// State // State
const selectedAssetIds = ref<Set<string>>(new Set()) const selectedAssetIds = ref<Set<string>>(new Set())
const lastSelectedIndex = ref<number>(-1) const lastSelectedIndex = ref<number>(-1)
const lastSelectedAssetId = ref<string | null>(null)
// Getters // Getters
const selectedCount = computed(() => selectedAssetIds.value.size) const selectedCount = computed(() => selectedAssetIds.value.size)
@@ -34,6 +35,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
function clearSelection() { function clearSelection() {
selectedAssetIds.value.clear() selectedAssetIds.value.clear()
lastSelectedIndex.value = -1 lastSelectedIndex.value = -1
lastSelectedAssetId.value = null
} }
function toggleSelection(assetId: string) { function toggleSelection(assetId: string) {
@@ -52,16 +54,15 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
lastSelectedIndex.value = index lastSelectedIndex.value = index
} }
// Reset function for cleanup function setLastSelectedAssetId(assetId: string | null) {
function reset() { lastSelectedAssetId.value = assetId
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
} }
return { return {
// State // State
selectedAssetIds: computed(() => selectedAssetIds.value), selectedAssetIds: computed(() => selectedAssetIds.value),
lastSelectedIndex: computed(() => lastSelectedIndex.value), lastSelectedIndex: computed(() => lastSelectedIndex.value),
lastSelectedAssetId: computed(() => lastSelectedAssetId.value),
// Getters // Getters
selectedCount, selectedCount,
@@ -76,6 +77,6 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
toggleSelection, toggleSelection,
isSelected, isSelected,
setLastSelectedIndex, setLastSelectedIndex,
reset setLastSelectedAssetId
} }
}) })