Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/queue/QueueProgressOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
throw new Error('Asset not found in media assets panel')
}
assetSelectionStore.setSelection([assetId])
assetSelectionStore.setLastSelectedAssetId(assetId)
}
Comment on lines 266 to 268
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

focusAssetInSidebar sets setSelection([assetId]) and now sets lastSelectedAssetId, but it leaves lastSelectedIndex unchanged. Since shift-range selection is keyed off lastSelectedIndex, this can produce an incorrect range anchored to a stale index after selecting from the queue overlay. Consider also resetting lastSelectedIndex (or setting it to the focused asset’s index when available) to keep the anchor state consistent.

Copilot uses AI. Check for mistakes.

const inspectJobAsset = wrapWithErrorHandlingAsync(
Expand Down
89 changes: 89 additions & 0 deletions src/platform/assets/composables/useAssetSelection.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] }
]
Comment on lines +22 to +25
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test fixtures typed as AssetItem only provide { id, name, tags }, but AssetItem (from assetSchema) includes other required fields (e.g. asset_hash, mime_type, etc.). If the repo runs TypeScript typechecking in CI, this test file will fail to compile. Suggest using an existing AssetItem factory (or a local helper) that populates the required fields, or loosening the fixture typing (e.g. Partial<AssetItem> where appropriate).

Copilot uses AI. Check for mistakes.

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()
})
})
64 changes: 58 additions & 6 deletions src/platform/assets/composables/useAssetSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ export function useAssetSelection() {
const metaKey = computed(() => isActive.value && metaKeyRaw.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
* @param asset The clicked asset
Expand Down Expand Up @@ -60,14 +79,14 @@ export function useAssetSelection() {
// Ctrl/Cmd + Click: Toggle individual selection
if (cmdOrCtrlKey.value) {
selectionStore.toggleSelection(assetId)
selectionStore.setLastSelectedIndex(index)
setAnchor(index, assetId)
return
}

// Normal Click: Single selection
selectionStore.clearSelection()
selectionStore.addToSelection(assetId)
selectionStore.setLastSelectedIndex(index)
setAnchor(index, assetId)
}

/**
Expand All @@ -77,7 +96,8 @@ export function useAssetSelection() {
const allIds = allAssets.map((a) => a.id)
selectionStore.setSelection(allIds)
if (allAssets.length > 0) {
selectionStore.setLastSelectedIndex(allAssets.length - 1)
const lastIndex = allAssets.length - 1
setAnchor(lastIndex, allAssets[lastIndex].id)
}
}

Expand All @@ -88,6 +108,39 @@ export function useAssetSelection() {
return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
}

function reconcileSelection(assets: AssetItem[]) {
if (selectionStore.selectedAssetIds.size === 0) {
return
}
Comment on lines +111 to +114
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reconcileSelection is implemented and returned from the composable, but it isn’t called anywhere in the codebase (search shows only this file + its new test). Without wiring it to changes in the visible asset list (e.g., a watcher where displayAssets is derived), selection won’t actually be pruned/re-anchored at runtime as described in the PR summary.

Copilot uses AI. Check for mistakes.

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
* Same logic as in AssetsSidebarTab.vue
Expand Down Expand Up @@ -117,7 +170,7 @@ export function useAssetSelection() {
function deactivate() {
isActive.value = false
// Reset selection state to ensure clean state when deactivated
selectionStore.reset()
selectionStore.clearSelection()
}

return {
Expand All @@ -132,10 +185,9 @@ export function useAssetSelection() {
selectAll,
clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets,
reconcileSelection,
getOutputCount,
getTotalOutputCount,
reset: () => selectionStore.reset(),

// Lifecycle management
activate,
deactivate,
Expand Down
11 changes: 6 additions & 5 deletions src/platform/assets/composables/useAssetSelectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
// State
const selectedAssetIds = ref<Set<string>>(new Set())
const lastSelectedIndex = ref<number>(-1)
const lastSelectedAssetId = ref<string | null>(null)

// Getters
const selectedCount = computed(() => selectedAssetIds.value.size)
Expand Down Expand Up @@ -34,6 +35,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
function clearSelection() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
lastSelectedAssetId.value = null
}

function toggleSelection(assetId: string) {
Expand All @@ -52,16 +54,15 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
lastSelectedIndex.value = index
}

// Reset function for cleanup
function reset() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
function setLastSelectedAssetId(assetId: string | null) {
lastSelectedAssetId.value = assetId
}

return {
// State
selectedAssetIds: computed(() => selectedAssetIds.value),
lastSelectedIndex: computed(() => lastSelectedIndex.value),
lastSelectedAssetId: computed(() => lastSelectedAssetId.value),

// Getters
selectedCount,
Expand All @@ -76,6 +77,6 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
toggleSelection,
isSelected,
setLastSelectedIndex,
reset
setLastSelectedAssetId
}
})
Loading