feat: allow manual saved view ordering
This commit is contained in:
21
src/App.tsx
21
src/App.tsx
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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…",
|
||||
|
||||
@@ -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
|
||||
|
||||
81
src/utils/viewOrdering.test.ts
Normal file
81
src/utils/viewOrdering.test.ts
Normal 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
62
src/utils/viewOrdering.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user