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

@@ -72,7 +72,9 @@ import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge'
import { useSavedViewOrdering } from './hooks/useSavedViewOrdering'
import { createViewFilename } from './utils/viewFilename'
import { nextViewOrder } from './utils/viewOrdering'
import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
@@ -1076,7 +1078,9 @@ function App() {
const filename = editing
? editing.filename
: createViewFilename(definition.name, vault.views.map((view) => view.filename))
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
const nextDefinition = editing
? { ...editing.definition, ...definition }
: { ...definition, order: nextViewOrder(vault.views) }
const target = isTauri() ? invoke : mockInvoke
try {
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
@@ -1319,6 +1323,14 @@ function App() {
? 'Customize All Notes columns'
: 'Customize Inbox columns'
}, [effectiveSelection, vault.views])
const viewOrdering = useSavedViewOrdering({
views: vault.views,
selection: effectiveSelection,
vaultPath: resolvedPath,
reloadViews: vault.reloadViews,
loadModifiedFiles: vault.loadModifiedFiles,
onToast: setToastMessage,
})
const activeNoteModified = useMemo(
() => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath),
[notes.activeTabPath, vault.modifiedFiles],
@@ -1420,6 +1432,11 @@ function App() {
onToggleRawEditor: toggleRawEditorCommand,
noteLayout,
onToggleNoteLayout: toggleNoteLayout,
selectedViewName: viewOrdering.selectedViewName,
onMoveSelectedViewUp: viewOrdering.onMoveSelectedViewUp,
onMoveSelectedViewDown: viewOrdering.onMoveSelectedViewDown,
canMoveSelectedViewUp: viewOrdering.canMoveSelectedViewUp,
canMoveSelectedViewDown: viewOrdering.canMoveSelectedViewDown,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
@@ -1570,7 +1587,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onReorderViews={viewOrdering.onReorderViews} onMoveView={viewOrdering.onMoveView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>

View File

@@ -1384,6 +1384,33 @@ describe('Sidebar', () => {
},
]
it('renders keyboard-accessible move buttons for saved views', () => {
const onMoveView = vi.fn()
render(
<Sidebar
entries={mockEntries}
selection={defaultSelection}
onSelect={() => {}}
views={mockViews}
onMoveView={onMoveView}
/>
)
const moveUpButtons = screen.getAllByTitle('Move view up')
const moveDownButtons = screen.getAllByTitle('Move view down')
expect(moveUpButtons[0]).toBeDisabled()
expect(moveUpButtons[1]).not.toBeDisabled()
expect(moveDownButtons[0]).not.toBeDisabled()
expect(moveDownButtons[1]).toBeDisabled()
fireEvent.click(moveUpButtons[1])
expect(onMoveView).toHaveBeenCalledWith('all-topics.yml', 'up')
fireEvent.click(moveDownButtons[0])
expect(onMoveView).toHaveBeenCalledWith('active-projects.yml', 'down')
})
it('shows note count chip for each view matching the filter results', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} />

View File

@@ -24,6 +24,7 @@ import {
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
import type { AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
import type { ViewMoveDirection } from '../utils/viewOrdering'
interface SidebarProps {
entries: VaultEntry[]
@@ -43,6 +44,8 @@ interface SidebarProps {
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onReorderViews?: (orderedFilenames: string[]) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
@@ -68,6 +71,8 @@ interface SidebarNavigationProps extends Pick<
| 'onCreateView'
| 'onEditView'
| 'onDeleteView'
| 'onReorderViews'
| 'onMoveView'
| 'folders'
| 'onCreateFolder'
| 'onRenameFolder'
@@ -106,6 +111,8 @@ function SidebarNavigation({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
folders = [],
onCreateFolder,
onRenameFolder,
@@ -170,6 +177,9 @@ function SidebarNavigation({
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
onReorderViews={onReorderViews}
onMoveView={onMoveView}
sensors={sensors}
entries={entries}
locale={locale}
/>
@@ -232,6 +242,8 @@ export const Sidebar = memo(function Sidebar({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
folders = [],
onCreateFolder,
onRenameFolder,
@@ -268,6 +280,7 @@ export const Sidebar = memo(function Sidebar({
const reordered = computeReorder(sectionIds, active.id as string, over.id as string)
if (reordered) onReorderSections?.(reordered.map((typeName, order) => ({ typeName, order })))
}, [sectionIds, onReorderSections])
const viewActions = { onCreateView, onEditView, onDeleteView, onReorderViews, onMoveView }
const sectionProps: SidebarSectionProps = {
entries,
@@ -291,9 +304,7 @@ export const Sidebar = memo(function Sidebar({
onSelectFavorite={onSelectFavorite}
onReorderFavorites={onReorderFavorites}
views={views}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
{...viewActions}
folders={folders}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}

View File

@@ -21,8 +21,10 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { computeReorder } from './sidebarHooks'
import { countByFilter } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
import { canMoveView, type ViewMoveDirection } from '../../utils/viewOrdering'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
@@ -48,6 +50,9 @@ export function ViewsSection({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
sensors,
entries,
locale = 'en',
}: {
@@ -59,9 +64,35 @@ export function ViewsSection({
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onReorderViews?: (orderedFilenames: string[]) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
sensors: ReturnType<typeof useSensors>
entries: VaultEntry[]
locale?: AppLocale
}) {
const viewIds = views.map((view) => view.filename)
const handleViewDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const reordered = computeReorder(viewIds, active.id as string, over.id as string)
if (reordered) onReorderViews?.(reordered)
}
const renderViewItem = (view: ViewFile) => (
<SidebarViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
canMoveUp={canMoveView(views, view.filename, 'up')}
canMoveDown={canMoveView(views, view.filename, 'down')}
entries={entries}
locale={locale}
/>
)
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.views')} collapsed={collapsed} onToggle={onToggle}>
@@ -81,24 +112,81 @@ export function ViewsSection({
</SidebarGroupHeader>
{!collapsed && (
<div style={{ paddingBottom: 4 }}>
{views.map((view) => (
<SidebarViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
locale={locale}
/>
))}
{onReorderViews ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleViewDragEnd}>
<SortableContext items={viewIds} strategy={verticalListSortingStrategy}>
{views.map((view) => (
<SortableViewItem
key={view.filename}
view={view}
views={views}
selection={selection}
onSelect={onSelect}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
entries={entries}
locale={locale}
/>
))}
</SortableContext>
</DndContext>
) : views.map(renderViewItem)}
</div>
)}
</div>
)
}
function SortableViewItem({
view,
views,
selection,
onSelect,
onEditView,
onDeleteView,
onMoveView,
entries,
locale,
}: {
view: ViewFile
views: ViewFile[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
entries: VaultEntry[]
locale?: AppLocale
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: view.filename })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}}
>
<SidebarViewItem
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
canMoveUp={canMoveView(views, view.filename, 'up')}
canMoveDown={canMoveView(views, view.filename, 'down')}
dragHandleProps={{ ...attributes, ...listeners }}
entries={entries}
locale={locale}
/>
</div>
)
}
function SortableSection({
group,
sectionProps,

View File

@@ -1,11 +1,14 @@
import { useMemo } from 'react'
import { useMemo, type HTMLAttributes } from 'react'
import type { VaultEntry, ViewFile } from '../../types'
import { evaluateView } from '../../utils/viewFilters'
import { Funnel, PencilSimple, Trash } from '@phosphor-icons/react'
import { ArrowDown, ArrowUp, GripVertical } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { NoteTitleIcon } from '../NoteTitleIcon'
import { SidebarCountPill } from '../SidebarParts'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
import { translate, type AppLocale } from '../../lib/i18n'
import type { ViewMoveDirection } from '../../utils/viewOrdering'
interface SidebarViewItemProps {
view: ViewFile
@@ -13,6 +16,10 @@ interface SidebarViewItemProps {
onSelect: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
canMoveUp?: boolean
canMoveDown?: boolean
dragHandleProps?: HTMLAttributes<HTMLButtonElement>
entries: VaultEntry[]
locale?: AppLocale
}
@@ -23,6 +30,10 @@ export function SidebarViewItem({
onSelect,
onEditView,
onDeleteView,
onMoveView,
canMoveUp = false,
canMoveDown = false,
dragHandleProps,
entries,
locale = 'en',
}: SidebarViewItemProps) {
@@ -39,6 +50,20 @@ export function SidebarViewItem({
style={{ padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular, borderRadius: 4 }}
onClick={onSelect}
>
{dragHandleProps && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-ml-1 h-5 w-4 min-w-0 cursor-grab rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground active:cursor-grabbing"
title={translate(locale, 'sidebar.action.reorderView')}
aria-label={translate(locale, 'sidebar.action.reorderView')}
onClick={(event) => event.stopPropagation()}
{...dragHandleProps}
>
<GripVertical size={12} />
</Button>
)}
{icon}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span>
{showCount && (
@@ -51,25 +76,59 @@ export function SidebarViewItem({
)}
</div>
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onMoveView && (
<>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground disabled:opacity-30"
disabled={!canMoveUp}
onClick={(event) => { event.stopPropagation(); onMoveView(view.filename, 'up') }}
title={translate(locale, 'sidebar.action.moveViewUp')}
aria-label={translate(locale, 'sidebar.action.moveViewUp')}
>
<ArrowUp size={12} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground disabled:opacity-30"
disabled={!canMoveDown}
onClick={(event) => { event.stopPropagation(); onMoveView(view.filename, 'down') }}
title={translate(locale, 'sidebar.action.moveViewDown')}
aria-label={translate(locale, 'sidebar.action.moveViewDown')}
>
<ArrowDown size={12} />
</Button>
</>
)}
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => { event.stopPropagation(); onEditView(view.filename) }}
title={translate(locale, 'sidebar.action.editView')}
aria-label={translate(locale, 'sidebar.action.editView')}
>
<PencilSimple size={12} />
</button>
</Button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-destructive"
onClick={(event) => { event.stopPropagation(); onDeleteView(view.filename) }}
title={translate(locale, 'sidebar.action.deleteView')}
aria-label={translate(locale, 'sidebar.action.deleteView')}
>
<Trash size={12} />
</button>
</Button>
)}
</div>
</div>

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,
}
}

View File

@@ -153,6 +153,9 @@
"sidebar.action.createView": "Create view",
"sidebar.action.editView": "Edit view",
"sidebar.action.deleteView": "Delete view",
"sidebar.action.reorderView": "Reorder view",
"sidebar.action.moveViewUp": "Move view up",
"sidebar.action.moveViewDown": "Move view down",
"sidebar.action.customizeSections": "Customize sections",
"sidebar.action.renameSection": "Rename section…",
"sidebar.action.customizeIconColor": "Customize icon & color…",

View File

@@ -215,6 +215,8 @@ export interface ViewDefinition {
name: string
icon: string | null
color: string | null
/** Display order for saved Views in sidebar/list surfaces (lower = higher). */
order?: number | null
sort: string | null
listPropertiesDisplay?: string[]
filters: FilterGroup

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
}