feat: allow manual saved view ordering

This commit is contained in:
lucaronin
2026-04-28 20:41:07 +02:00
parent a39b346a75
commit fd92ff5e9b
20 changed files with 964 additions and 161 deletions

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import type { ViewFile } from '../types'
import {
buildViewOrderUpdates,
canMoveView,
moveView,
nextViewOrder,
orderViewsByFilename,
} from './viewOrdering'
function makeView(filename: string, name: string, order: number | null = null): ViewFile {
return {
filename,
definition: {
name,
icon: null,
color: null,
order,
sort: null,
filters: { all: [] },
},
}
}
describe('viewOrdering', () => {
const views = [
makeView('alpha.yml', 'Alpha', 0),
makeView('beta.yml', 'Beta', 1),
makeView('gamma.yml', 'Gamma', 2),
]
it('moves a view up or down without mutating the input order', () => {
expect(moveView(views, 'beta.yml', 'up')?.map((view) => view.filename)).toEqual([
'beta.yml',
'alpha.yml',
'gamma.yml',
])
expect(moveView(views, 'beta.yml', 'down')?.map((view) => view.filename)).toEqual([
'alpha.yml',
'gamma.yml',
'beta.yml',
])
expect(views.map((view) => view.filename)).toEqual(['alpha.yml', 'beta.yml', 'gamma.yml'])
})
it('blocks moves outside the list bounds', () => {
expect(canMoveView(views, 'alpha.yml', 'up')).toBe(false)
expect(canMoveView(views, 'gamma.yml', 'down')).toBe(false)
expect(moveView(views, 'missing.yml', 'up')).toBeNull()
})
it('orders views by a complete filename list', () => {
expect(orderViewsByFilename(views, ['gamma.yml', 'alpha.yml', 'beta.yml'])?.map((view) => view.filename)).toEqual([
'gamma.yml',
'alpha.yml',
'beta.yml',
])
})
it('rejects incomplete, duplicate, or unknown filename orders', () => {
expect(orderViewsByFilename(views, ['alpha.yml', 'beta.yml'])).toBeNull()
expect(orderViewsByFilename(views, ['alpha.yml', 'alpha.yml', 'gamma.yml'])).toBeNull()
expect(orderViewsByFilename(views, ['alpha.yml', 'beta.yml', 'missing.yml'])).toBeNull()
})
it('builds dense order updates while preserving view definitions', () => {
expect(buildViewOrderUpdates([views[2], views[0]]).map(({ filename, definition }) => ({
filename,
name: definition.name,
order: definition.order,
}))).toEqual([
{ filename: 'gamma.yml', name: 'Gamma', order: 0 },
{ filename: 'alpha.yml', name: 'Alpha', order: 1 },
])
})
it('places new views after the highest explicit order', () => {
expect(nextViewOrder(views)).toBe(3)
expect(nextViewOrder([makeView('untitled.yml', 'Untitled')])).toBe(1)
})
})

62
src/utils/viewOrdering.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { ViewDefinition, ViewFile } from '../types'
export type ViewMoveDirection = 'up' | 'down'
export type ViewMoveHandler = (filename: string, direction: ViewMoveDirection) => Promise<void> | void
export type ViewReorderHandler = (orderedFilenames: string[]) => Promise<void> | void
export interface ViewOrderUpdate {
filename: string
definition: ViewDefinition
}
function orderedViewIndex(views: ViewFile[], filename: string): number {
return views.findIndex((view) => view.filename === filename)
}
function destinationIndex(index: number, direction: ViewMoveDirection): number {
return direction === 'up' ? index - 1 : index + 1
}
export function canMoveView(views: ViewFile[], filename: string, direction: ViewMoveDirection): boolean {
const index = orderedViewIndex(views, filename)
if (index === -1) return false
const nextIndex = destinationIndex(index, direction)
return nextIndex >= 0 && nextIndex < views.length
}
export function moveView(views: ViewFile[], filename: string, direction: ViewMoveDirection): ViewFile[] | null {
if (!canMoveView(views, filename, direction)) return null
const index = orderedViewIndex(views, filename)
const nextIndex = destinationIndex(index, direction)
const reordered = [...views]
const [view] = reordered.splice(index, 1)
reordered.splice(nextIndex, 0, view)
return reordered
}
export function orderViewsByFilename(views: ViewFile[], orderedFilenames: string[]): ViewFile[] | null {
if (views.length !== orderedFilenames.length) return null
if (new Set(orderedFilenames).size !== orderedFilenames.length) return null
const viewByFilename = new Map(views.map((view) => [view.filename, view]))
const ordered = orderedFilenames.map((filename) => viewByFilename.get(filename))
if (ordered.some((view) => view === undefined)) return null
return ordered as ViewFile[]
}
export function buildViewOrderUpdates(views: ViewFile[]): ViewOrderUpdate[] {
return views.map((view, order) => ({
filename: view.filename,
definition: { ...view.definition, order },
}))
}
export function nextViewOrder(views: ViewFile[]): number {
const explicitOrders = views
.map((view) => view.definition.order)
.filter((order): order is number => typeof order === 'number' && Number.isFinite(order))
return explicitOrders.length > 0 ? Math.max(...explicitOrders) + 1 : views.length
}