feat: allow manual saved view ordering
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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({
|
||||
|
||||
99
src/hooks/useSavedViewOrdering.test.ts
Normal file
99
src/hooks/useSavedViewOrdering.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
96
src/hooks/useSavedViewOrdering.ts
Normal file
96
src/hooks/useSavedViewOrdering.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user