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

@@ -28,6 +28,11 @@ interface ViewCommandsConfig {
onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean
noteListColumnsLabel: string
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
}
function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: () => void): CommandAction {
@@ -41,12 +46,32 @@ function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: ()
}
}
function buildMoveSavedViewCommand(
direction: 'Up' | 'Down',
selectedViewName: string | undefined,
onMoveSelectedView: (() => void) | undefined,
canMoveSelectedView: boolean | undefined,
): CommandAction {
const directionKeyword = direction.toLowerCase()
return {
id: `move-view-${directionKeyword}`,
label: selectedViewName ? `Move ${selectedViewName} ${direction}` : `Move View ${direction}`,
group: 'View',
keywords: ['saved view', 'view', 'views', 'order', 'sidebar', 'move', directionKeyword],
enabled: Boolean(onMoveSelectedView && canMoveSelectedView),
execute: onMoveSelectedView ?? noop,
}
}
export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeNoteModified,
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout = 'centered', onToggleNoteLayout, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown,
canMoveSelectedViewUp, canMoveSelectedViewDown,
} = config
return [
@@ -60,6 +85,8 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
buildMoveSavedViewCommand('Up', selectedViewName, onMoveSelectedViewUp, canMoveSelectedViewUp),
buildMoveSavedViewCommand('Down', selectedViewName, onMoveSelectedViewDown, canMoveSelectedViewDown),
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomIn), keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomOut), keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },

View File

@@ -41,6 +41,11 @@ interface AppCommandsConfig {
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
activeNoteModified: boolean
@@ -155,6 +160,11 @@ type CommandRegistryCoreActions = Pick<
| 'onToggleInspector'
| 'onToggleDiff'
| 'onToggleRawEditor'
| 'selectedViewName'
| 'onMoveSelectedViewUp'
| 'onMoveSelectedViewDown'
| 'canMoveSelectedViewUp'
| 'canMoveSelectedViewDown'
| 'noteLayout'
| 'onToggleNoteLayout'
| 'onToggleAIChat'
@@ -417,6 +427,11 @@ function createCommandRegistryCoreConfig(
onToggleInspector: config.onToggleInspector,
onToggleDiff: config.onToggleDiff,
onToggleRawEditor: config.onToggleRawEditor,
selectedViewName: config.selectedViewName,
onMoveSelectedViewUp: config.onMoveSelectedViewUp,
onMoveSelectedViewDown: config.onMoveSelectedViewDown,
canMoveSelectedViewUp: config.canMoveSelectedViewUp,
canMoveSelectedViewDown: config.canMoveSelectedViewDown,
onFindInNote: config.onFindInNote,
onReplaceInNote: config.onReplaceInNote,
noteLayout: config.noteLayout,

View File

@@ -359,6 +359,53 @@ describe('useCommandRegistry', () => {
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
})
it('exposes command palette actions for moving the selected saved view', () => {
const onMoveSelectedViewUp = vi.fn()
const onMoveSelectedViewDown = vi.fn()
const config = makeConfig({
selectedViewName: 'Active Projects',
onMoveSelectedViewUp,
onMoveSelectedViewDown,
canMoveSelectedViewUp: true,
canMoveSelectedViewDown: true,
})
const { result } = renderHook(() => useCommandRegistry(config))
const moveUp = findCommand(result.current, 'move-view-up')
const moveDown = findCommand(result.current, 'move-view-down')
expect(moveUp).toMatchObject({
label: 'Move Active Projects Up',
group: 'View',
enabled: true,
})
expect(moveDown).toMatchObject({
label: 'Move Active Projects Down',
group: 'View',
enabled: true,
})
moveUp!.execute()
moveDown!.execute()
expect(onMoveSelectedViewUp).toHaveBeenCalledOnce()
expect(onMoveSelectedViewDown).toHaveBeenCalledOnce()
})
it('disables saved view move commands at list boundaries', () => {
const config = makeConfig({
selectedViewName: 'Top View',
onMoveSelectedViewUp: vi.fn(),
onMoveSelectedViewDown: vi.fn(),
canMoveSelectedViewUp: false,
canMoveSelectedViewDown: true,
})
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'move-view-up')?.enabled).toBe(false)
expect(findCommand(result.current, 'move-view-down')?.enabled).toBe(true)
})
it('updates note layout command copy when left alignment is active', () => {
const config = makeConfig({ noteLayout: 'left' })
const { result } = renderHook(() => useCommandRegistry(config))

View File

@@ -83,6 +83,11 @@ interface CommandRegistryConfig {
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
onFindInNote?: () => void
onReplaceInNote?: () => void
noteLayout?: NoteLayout
@@ -119,6 +124,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onRenameFolder, onDeleteFolder, onRevealSelectedFolder, onCopySelectedFolderPath,
@@ -214,11 +220,13 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
}), [
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
])
const settingsCommands = useMemo(() => buildSettingsCommands({

View File

@@ -0,0 +1,99 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidebarSelection, ViewFile } from '../types'
import { useSavedViewOrdering } from './useSavedViewOrdering'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
function makeView(filename: string, name: string, order: number): ViewFile {
return {
filename,
definition: {
name,
icon: null,
color: null,
order,
sort: null,
filters: { all: [] },
},
}
}
function renderOrdering(selection: SidebarSelection = { kind: 'view', filename: 'beta.yml' }) {
const reloadViews = vi.fn().mockResolvedValue([])
const loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
const onToast = vi.fn()
const views = [
makeView('alpha.yml', 'Alpha', 0),
makeView('beta.yml', 'Beta', 1),
makeView('gamma.yml', 'Gamma', 2),
]
const hook = renderHook(() => useSavedViewOrdering({
views,
selection,
vaultPath: '/vault',
reloadViews,
loadModifiedFiles,
onToast,
}))
return { ...hook, reloadViews, loadModifiedFiles, onToast }
}
describe('useSavedViewOrdering', () => {
beforeEach(() => {
mockInvokeFn.mockReset()
mockInvokeFn.mockResolvedValue(null)
})
it('persists a dense order when moving a saved view', async () => {
const { result, reloadViews, loadModifiedFiles, onToast } = renderOrdering()
await act(async () => {
await result.current.onMoveView('beta.yml', 'up')
})
expect(mockInvokeFn).toHaveBeenCalledWith('save_view_cmd', {
vaultPath: '/vault',
filename: 'beta.yml',
definition: expect.objectContaining({ name: 'Beta', order: 0 }),
})
expect(mockInvokeFn).toHaveBeenCalledWith('save_view_cmd', {
vaultPath: '/vault',
filename: 'alpha.yml',
definition: expect.objectContaining({ name: 'Alpha', order: 1 }),
})
expect(reloadViews).toHaveBeenCalledOnce()
expect(loadModifiedFiles).toHaveBeenCalledOnce()
expect(onToast).toHaveBeenCalledWith('Views reordered')
})
it('exposes command state for the selected saved view', () => {
const { result } = renderOrdering()
expect(result.current.selectedViewName).toBe('Beta')
expect(result.current.selectedViewFilename).toBe('beta.yml')
expect(result.current.canMoveSelectedViewUp).toBe(true)
expect(result.current.canMoveSelectedViewDown).toBe(true)
})
it('ignores invalid drag orders without writing view files', async () => {
const { result, reloadViews } = renderOrdering()
await act(async () => {
await result.current.onReorderViews(['alpha.yml', 'alpha.yml', 'gamma.yml'])
})
expect(mockInvokeFn).not.toHaveBeenCalled()
expect(reloadViews).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,96 @@
import { useCallback, useMemo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { SidebarSelection, ViewFile } from '../types'
import {
buildViewOrderUpdates,
canMoveView,
moveView,
orderViewsByFilename,
type ViewMoveDirection,
type ViewMoveHandler,
type ViewReorderHandler,
} from '../utils/viewOrdering'
interface SavedViewOrderingConfig {
views: ViewFile[]
selection: SidebarSelection
vaultPath: string
reloadViews: () => Promise<unknown>
loadModifiedFiles: () => Promise<void>
onToast: (message: string) => void
}
interface SavedViewOrdering {
onReorderViews: ViewReorderHandler
onMoveView: ViewMoveHandler
selectedViewName?: string
selectedViewFilename: string | null
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp: boolean
canMoveSelectedViewDown: boolean
}
function selectedSavedView(views: ViewFile[], selection: SidebarSelection): ViewFile | null {
if (selection.kind !== 'view') return null
return views.find((view) => view.filename === selection.filename) ?? null
}
function viewMoveCommand(
view: ViewFile | null,
direction: ViewMoveDirection,
onMoveView: ViewMoveHandler,
): (() => void) | undefined {
if (!view) return undefined
const { filename } = view
return () => { void onMoveView(filename, direction) }
}
export function useSavedViewOrdering({
views,
selection,
vaultPath,
reloadViews,
loadModifiedFiles,
onToast,
}: SavedViewOrderingConfig): SavedViewOrdering {
const persistViewOrder = useCallback(async (orderedViews: ViewFile[]) => {
const target = isTauri() ? invoke : mockInvoke
await Promise.all(buildViewOrderUpdates(orderedViews).map(({ filename, definition }) => (
target('save_view_cmd', { vaultPath, filename, definition })
)))
await reloadViews()
await loadModifiedFiles()
onToast('Views reordered')
}, [loadModifiedFiles, onToast, reloadViews, vaultPath])
const onReorderViews = useCallback<ViewReorderHandler>(async (orderedFilenames) => {
const orderedViews = orderViewsByFilename(views, orderedFilenames)
if (!orderedViews) return
await persistViewOrder(orderedViews)
}, [persistViewOrder, views])
const onMoveView = useCallback<ViewMoveHandler>(async (filename, direction) => {
const orderedViews = moveView(views, filename, direction)
if (!orderedViews) return
await persistViewOrder(orderedViews)
}, [persistViewOrder, views])
const selectedView = useMemo(
() => selectedSavedView(views, selection),
[selection, views],
)
const selectedViewFilename = selectedView?.filename ?? null
return {
onReorderViews,
onMoveView,
selectedViewName: selectedView?.definition.name,
selectedViewFilename,
onMoveSelectedViewUp: viewMoveCommand(selectedView, 'up', onMoveView),
onMoveSelectedViewDown: viewMoveCommand(selectedView, 'down', onMoveView),
canMoveSelectedViewUp: selectedViewFilename ? canMoveView(views, selectedViewFilename, 'up') : false,
canMoveSelectedViewDown: selectedViewFilename ? canMoveView(views, selectedViewFilename, 'down') : false,
}
}