From fd92ff5e9b4871c37451fa89b7bed0762febda67 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 28 Apr 2026 20:41:07 +0200 Subject: [PATCH] feat: allow manual saved view ordering --- docs/ABSTRACTIONS.md | 6 + docs/ARCHITECTURE.md | 2 +- src-tauri/src/commands/vault.rs | 1 + src-tauri/src/vault/views.rs | 331 ++++++++++++--------- src/App.tsx | 21 +- src/components/Sidebar.test.tsx | 27 ++ src/components/Sidebar.tsx | 17 +- src/components/sidebar/SidebarSections.tsx | 112 ++++++- src/components/sidebar/SidebarViewItem.tsx | 73 ++++- src/hooks/commands/viewCommands.ts | 27 ++ src/hooks/useAppCommands.ts | 15 + src/hooks/useCommandRegistry.test.ts | 47 +++ src/hooks/useCommandRegistry.ts | 8 + src/hooks/useSavedViewOrdering.test.ts | 99 ++++++ src/hooks/useSavedViewOrdering.ts | 96 ++++++ src/lib/locales/en.json | 3 + src/types.ts | 2 + src/utils/viewOrdering.test.ts | 81 +++++ src/utils/viewOrdering.ts | 62 ++++ ui-design.pen | 95 ++++++ 20 files changed, 964 insertions(+), 161 deletions(-) create mode 100644 src/hooks/useSavedViewOrdering.test.ts create mode 100644 src/hooks/useSavedViewOrdering.ts create mode 100644 src/utils/viewOrdering.test.ts create mode 100644 src/utils/viewOrdering.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 19fd2a45..05feb950 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -304,6 +304,12 @@ type SidebarSelection = - A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected. - Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection. +### Saved Views + +Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior. + +The renderer uses `viewOrdering` helpers to convert drag or move-button intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar exposes both a drag handle and explicit move-up/move-down controls, and the command palette mirrors those move actions for the currently selected saved View. + ### Neighborhood Mode `SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3df09fc3..c33694ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -186,7 +186,7 @@ flowchart TD └──────────────────────────────────────────────────────────────┘ ``` -- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`. +- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the handle, while keyboard users can use explicit move buttons or command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`. - **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. - **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs index 48423d00..bce95aad 100644 --- a/src-tauri/src/commands/vault.rs +++ b/src-tauri/src/commands/vault.rs @@ -53,6 +53,7 @@ mod tests { name: "Inbox".to_string(), icon: None, color: None, + order: None, sort: None, list_properties_display: vec![], filters: crate::vault::FilterGroup::All(vec![]), diff --git a/src-tauri/src/vault/views.rs b/src-tauri/src/vault/views.rs index 4e1d44a8..4fd9089d 100644 --- a/src-tauri/src/vault/views.rs +++ b/src-tauri/src/vault/views.rs @@ -1,7 +1,8 @@ use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc}; -use regex::RegexBuilder; +use regex::{Regex, RegexBuilder}; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::cmp::Ordering; use std::fmt; use std::fs; use std::path::Path; @@ -15,6 +16,8 @@ pub struct ViewDefinition { pub icon: Option, #[serde(default)] pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order: Option, #[serde(default)] pub sort: Option, #[serde( @@ -254,10 +257,19 @@ pub fn scan_views(vault_path: &Path) -> Vec { } } - views.sort_by(|a, b| a.filename.cmp(&b.filename)); + views.sort_by(compare_views); views } +fn compare_views(left: &ViewFile, right: &ViewFile) -> Ordering { + let order = left + .definition + .order + .unwrap_or(i64::MAX) + .cmp(&right.definition.order.unwrap_or(i64::MAX)); + order.then_with(|| left.filename.cmp(&right.filename)) +} + /// Save a view definition as YAML to `vault_path/views/{filename}`. pub fn save_view( vault_path: &Path, @@ -422,120 +434,157 @@ fn supports_regex(op: &FilterOp) -> bool { ) } +enum ConditionField<'a> { + Scalar(Option), + Relationship(&'a [String]), +} + fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool { let field = cond.field.as_str(); - let mut relationship_values: Option<&[String]> = None; - - // Boolean fields - match field { - "archived" => return evaluate_bool_field(entry.archived, &cond.op, &cond.value), - "favorite" => return evaluate_bool_field(entry.favorite, &cond.op, &cond.value), - _ => {} + if let Some(result) = evaluate_condition_bool_field(field, entry, &cond.op, &cond.value) { + return result; } - // String/option fields - let field_value: Option = match field { - "type" | "isA" => entry.is_a.clone(), - "status" => entry.status.clone(), - "title" => Some(entry.title.clone()), - "body" => Some(entry.snippet.clone()), - _ => { - // Check properties first, then relationships - if let Some(prop) = entry.properties.get(field) { - match prop { - serde_json::Value::String(s) => Some(s.clone()), - serde_json::Value::Number(n) => Some(n.to_string()), - serde_json::Value::Bool(b) => Some(b.to_string()), - _ => None, - } - } else if let Some(rels) = entry.relationships.get(field) { - relationship_values = Some(rels); - None - } else { - None - } - } - }; - + let field_value = resolve_condition_field(field, entry); let cond_value = cond.value.as_ref().and_then(yaml_value_to_string); - let regex = if cond.regex && supports_regex(&cond.op) { - cond_value.as_deref().and_then(build_regex) - } else { - None - }; + let regex = condition_regex(cond, cond_value.as_deref()); if cond.regex && supports_regex(&cond.op) && regex.is_none() { return false; } if let Some(re) = regex.as_ref() { - let matched = if let Some(prop) = field_value.as_deref() { - re.is_match(prop) - } else if let Some(rels) = relationship_values { - rels.iter().any(|item| { - relationship_candidates(item) - .into_iter() - .any(|candidate| re.is_match(&candidate)) - }) - } else { - false - }; - return match cond.op { - FilterOp::Contains | FilterOp::Equals => matched, - FilterOp::NotContains | FilterOp::NotEquals => !matched, - _ => false, - }; + return evaluate_regex_condition(&cond.op, &field_value, re); } - if let Some(rels) = relationship_values { - return evaluate_relationship_op(&cond.op, rels, &cond.value); + match field_value { + ConditionField::Relationship(rels) => evaluate_relationship_op(&cond.op, rels, &cond.value), + ConditionField::Scalar(value) => evaluate_scalar_op( + &cond.op, + value.as_deref(), + cond_value.as_deref(), + &cond.value, + ), } +} - match cond.op { - FilterOp::Equals => match (&field_value, &cond_value) { +fn evaluate_condition_bool_field( + field: &str, + entry: &VaultEntry, + op: &FilterOp, + value: &Option, +) -> Option { + match field { + "archived" => Some(evaluate_bool_field(entry.archived, op, value)), + "favorite" => Some(evaluate_bool_field(entry.favorite, op, value)), + _ => None, + } +} + +fn resolve_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> { + match field { + "type" | "isA" => ConditionField::Scalar(entry.is_a.clone()), + "status" => ConditionField::Scalar(entry.status.clone()), + "title" => ConditionField::Scalar(Some(entry.title.clone())), + "body" => ConditionField::Scalar(Some(entry.snippet.clone())), + _ => resolve_dynamic_condition_field(field, entry), + } +} + +fn resolve_dynamic_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> { + if let Some(prop) = entry.properties.get(field) { + return ConditionField::Scalar(json_scalar_to_string(prop)); + } + if let Some(relationships) = entry.relationships.get(field) { + return ConditionField::Relationship(relationships); + } + ConditionField::Scalar(None) +} + +fn json_scalar_to_string(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + serde_json::Value::Bool(value) => Some(value.to_string()), + _ => None, + } +} + +fn condition_regex(cond: &FilterCondition, cond_value: Option<&str>) -> Option { + if cond.regex && supports_regex(&cond.op) { + cond_value.and_then(build_regex) + } else { + None + } +} + +fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &Regex) -> bool { + let matched = match field { + ConditionField::Scalar(Some(value)) => regex.is_match(value), + ConditionField::Relationship(values) => values.iter().any(|item| { + relationship_candidates(item) + .into_iter() + .any(|candidate| regex.is_match(&candidate)) + }), + ConditionField::Scalar(None) => false, + }; + + match op { + FilterOp::Contains | FilterOp::Equals => matched, + FilterOp::NotContains | FilterOp::NotEquals => !matched, + _ => false, + } +} + +fn evaluate_scalar_op( + op: &FilterOp, + field_value: Option<&str>, + cond_value: Option<&str>, + raw_value: &Option, +) -> bool { + match op { + FilterOp::Equals => match (field_value, cond_value) { (Some(f), Some(v)) => f.eq_ignore_ascii_case(v), (None, None) => true, _ => false, }, - FilterOp::NotEquals => match (&field_value, &cond_value) { + FilterOp::NotEquals => match (field_value, cond_value) { (Some(f), Some(v)) => !f.eq_ignore_ascii_case(v), (None, None) => false, _ => true, }, - FilterOp::Contains => match (&field_value, &cond_value) { + FilterOp::Contains => match (field_value, cond_value) { (Some(f), Some(v)) => f.to_lowercase().contains(&v.to_lowercase()), _ => false, }, - FilterOp::NotContains => match (&field_value, &cond_value) { + FilterOp::NotContains => match (field_value, cond_value) { (Some(f), Some(v)) => !f.to_lowercase().contains(&v.to_lowercase()), (None, _) => true, _ => true, }, FilterOp::AnyOf => { - let values = cond - .value + let values = raw_value .as_ref() .and_then(yaml_value_to_string_vec) .unwrap_or_default(); - match &field_value { + match field_value { Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)), None => false, } } FilterOp::NoneOf => { - let values = cond - .value + let values = raw_value .as_ref() .and_then(yaml_value_to_string_vec) .unwrap_or_default(); - match &field_value { + match field_value { Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)), None => true, } } - FilterOp::IsEmpty => field_value.as_deref().map_or(true, |s| s.is_empty()), - FilterOp::IsNotEmpty => field_value.as_deref().is_some_and(|s| !s.is_empty()), - FilterOp::Before => match (&field_value, &cond_value) { + FilterOp::IsEmpty => field_value.map_or(true, str::is_empty), + FilterOp::IsNotEmpty => field_value.is_some_and(|s| !s.is_empty()), + FilterOp::Before => match (field_value, cond_value) { (Some(f), Some(v)) => match ( parse_date_filter_timestamp(f, Utc::now()), parse_date_filter_timestamp(v, Utc::now()), @@ -545,7 +594,7 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool { }, _ => false, }, - FilterOp::After => match (&field_value, &cond_value) { + FilterOp::After => match (field_value, cond_value) { (Some(f), Some(v)) => match ( parse_date_filter_timestamp(f, Utc::now()), parse_date_filter_timestamp(v, Utc::now()), @@ -581,80 +630,61 @@ fn evaluate_relationship_op( ) -> bool { match op { FilterOp::Contains => { - let target = value.as_ref().and_then(yaml_value_to_string); - match target { - Some(t) => { - let t_stem = wikilink_stem(&t).to_lowercase(); - rels.iter() - .any(|r| wikilink_stem(r).to_lowercase() == t_stem) - } - None => false, - } + relationship_target(value).is_some_and(|target| relationship_contains(rels, &target)) } FilterOp::NotContains => { - let target = value.as_ref().and_then(yaml_value_to_string); - match target { - Some(t) => { - let t_stem = wikilink_stem(&t).to_lowercase(); - !rels - .iter() - .any(|r| wikilink_stem(r).to_lowercase() == t_stem) - } - None => true, - } - } - FilterOp::AnyOf => { - let values = value - .as_ref() - .and_then(yaml_value_to_string_vec) - .unwrap_or_default(); - rels.iter().any(|r| { - let r_stem = wikilink_stem(r).to_lowercase(); - values - .iter() - .any(|v| wikilink_stem(v).to_lowercase() == r_stem) - }) - } - FilterOp::NoneOf => { - let values = value - .as_ref() - .and_then(yaml_value_to_string_vec) - .unwrap_or_default(); - !rels.iter().any(|r| { - let r_stem = wikilink_stem(r).to_lowercase(); - values - .iter() - .any(|v| wikilink_stem(v).to_lowercase() == r_stem) - }) + relationship_target(value).map_or(true, |target| !relationship_contains(rels, &target)) } + FilterOp::AnyOf => relationship_any_of(rels, &relationship_values(value)), + FilterOp::NoneOf => !relationship_any_of(rels, &relationship_values(value)), FilterOp::IsEmpty => rels.is_empty(), FilterOp::IsNotEmpty => !rels.is_empty(), - FilterOp::Equals => { - let target = value.as_ref().and_then(yaml_value_to_string); - match target { - Some(t) => { - rels.len() == 1 - && wikilink_stem(&rels[0]).to_lowercase() - == wikilink_stem(&t).to_lowercase() - } - None => rels.is_empty(), - } - } - FilterOp::NotEquals => { - let target = value.as_ref().and_then(yaml_value_to_string); - match target { - Some(t) => { - rels.len() != 1 - || wikilink_stem(&rels[0]).to_lowercase() - != wikilink_stem(&t).to_lowercase() - } - None => !rels.is_empty(), - } - } + FilterOp::Equals => relationship_target(value).map_or_else( + || rels.is_empty(), + |target| relationship_equals(rels, &target), + ), + FilterOp::NotEquals => relationship_target(value).map_or_else( + || !rels.is_empty(), + |target| !relationship_equals(rels, &target), + ), _ => false, } } +fn relationship_target(value: &Option) -> Option { + value.as_ref().and_then(yaml_value_to_string) +} + +fn relationship_values(value: &Option) -> Vec { + value + .as_ref() + .and_then(yaml_value_to_string_vec) + .unwrap_or_default() +} + +fn normalized_wikilink_stem(value: &str) -> String { + wikilink_stem(value).to_lowercase() +} + +fn relationship_contains(rels: &[String], target: &str) -> bool { + let target_stem = normalized_wikilink_stem(target); + rels.iter() + .any(|relationship| normalized_wikilink_stem(relationship) == target_stem) +} + +fn relationship_any_of(rels: &[String], values: &[String]) -> bool { + rels.iter().any(|relationship| { + let relationship_stem = normalized_wikilink_stem(relationship); + values + .iter() + .any(|value| normalized_wikilink_stem(value) == relationship_stem) + }) +} + +fn relationship_equals(rels: &[String], target: &str) -> bool { + rels.len() == 1 && relationship_contains(rels, target) +} + fn yaml_value_to_string(v: &serde_yaml::Value) -> Option { match v { serde_yaml::Value::String(s) => Some(s.clone()), @@ -686,6 +716,7 @@ mod tests { name: name.to_string(), icon: None, color: None, + order: None, sort: None, list_properties_display: Vec::new(), filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition { @@ -963,6 +994,34 @@ filters: assert_eq!(views[1].definition.name, "Beta"); } + #[test] + fn test_scan_views_sorts_by_persisted_order_then_filename() { + let dir = tempfile::TempDir::new().unwrap(); + let views_dir = dir.path().join("views"); + fs::create_dir_all(&views_dir).unwrap(); + + let alpha = "name: Alpha\norder: 20\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n"; + let beta = "name: Beta\norder: 10\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n"; + let gamma = "name: Gamma\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n"; + fs::write(views_dir.join("alpha.yml"), alpha).unwrap(); + fs::write(views_dir.join("beta.yml"), beta).unwrap(); + fs::write(views_dir.join("gamma.yml"), gamma).unwrap(); + + let views = scan_views(dir.path()); + + assert_eq!( + views + .iter() + .map(|view| (view.filename.as_str(), view.definition.order)) + .collect::>(), + vec![ + ("beta.yml", Some(10)), + ("alpha.yml", Some(20)), + ("gamma.yml", None), + ] + ); + } + #[test] fn test_migrate_views_from_old_location() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/App.tsx b/src/App.tsx index c9ea6793..44d50a81 100644 --- a/src/App.tsx +++ b/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 && ( <>
- +
diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index bc63a47c..80479b18 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -1384,6 +1384,33 @@ describe('Sidebar', () => { }, ] + it('renders keyboard-accessible move buttons for saved views', () => { + const onMoveView = vi.fn() + render( + {}} + 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( {}} views={mockViews} /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6e05bf83..4c03029b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 onRenameFolder?: (folderPath: string, nextName: string) => Promise | 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} diff --git a/src/components/sidebar/SidebarSections.tsx b/src/components/sidebar/SidebarSections.tsx index 062260d9..1c47e422 100644 --- a/src/components/sidebar/SidebarSections.tsx +++ b/src/components/sidebar/SidebarSections.tsx @@ -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 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) => ( + 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 (
@@ -81,24 +112,81 @@ export function ViewsSection({ {!collapsed && (
- {views.map((view) => ( - onSelect({ kind: 'view', filename: view.filename })} - onEditView={onEditView} - onDeleteView={onDeleteView} - entries={entries} - locale={locale} - /> - ))} + {onReorderViews ? ( + + + {views.map((view) => ( + + ))} + + + ) : views.map(renderViewItem)}
)}
) } +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 ( +
+ 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} + /> +
+ ) +} + function SortableSection({ group, sectionProps, diff --git a/src/components/sidebar/SidebarViewItem.tsx b/src/components/sidebar/SidebarViewItem.tsx index 31e27260..73529907 100644 --- a/src/components/sidebar/SidebarViewItem.tsx +++ b/src/components/sidebar/SidebarViewItem.tsx @@ -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 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 && ( + + )} {icon} {view.definition.name} {showCount && ( @@ -51,25 +76,59 @@ export function SidebarViewItem({ )}
+ {onMoveView && ( + <> + + + + )} {onEditView && ( - + )} {onDeleteView && ( - + )}
diff --git a/src/hooks/commands/viewCommands.ts b/src/hooks/commands/viewCommands.ts index c6dcb79e..91d9a72b 100644 --- a/src/hooks/commands/viewCommands.ts +++ b/src/hooks/commands/viewCommands.ts @@ -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 }, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 6b5df1f7..5156f77d 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -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, diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 0a6a6c7b..2825448f 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -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)) diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 30c2e2c2..fdac15ed 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -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({ diff --git a/src/hooks/useSavedViewOrdering.test.ts b/src/hooks/useSavedViewOrdering.test.ts new file mode 100644 index 00000000..2952105c --- /dev/null +++ b/src/hooks/useSavedViewOrdering.test.ts @@ -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() + }) +}) diff --git a/src/hooks/useSavedViewOrdering.ts b/src/hooks/useSavedViewOrdering.ts new file mode 100644 index 00000000..7620c941 --- /dev/null +++ b/src/hooks/useSavedViewOrdering.ts @@ -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 + loadModifiedFiles: () => Promise + 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(async (orderedFilenames) => { + const orderedViews = orderViewsByFilename(views, orderedFilenames) + if (!orderedViews) return + await persistViewOrder(orderedViews) + }, [persistViewOrder, views]) + + const onMoveView = useCallback(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, + } +} diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index 48fab025..e437dcd1 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -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…", diff --git a/src/types.ts b/src/types.ts index b1a72ae0..179df4e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 diff --git a/src/utils/viewOrdering.test.ts b/src/utils/viewOrdering.test.ts new file mode 100644 index 00000000..a1c6858b --- /dev/null +++ b/src/utils/viewOrdering.test.ts @@ -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) + }) +}) diff --git a/src/utils/viewOrdering.ts b/src/utils/viewOrdering.ts new file mode 100644 index 00000000..348df5bf --- /dev/null +++ b/src/utils/viewOrdering.ts @@ -0,0 +1,62 @@ +import type { ViewDefinition, ViewFile } from '../types' + +export type ViewMoveDirection = 'up' | 'down' +export type ViewMoveHandler = (filename: string, direction: ViewMoveDirection) => Promise | void +export type ViewReorderHandler = (orderedFilenames: string[]) => Promise | 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 +} diff --git a/ui-design.pen b/ui-design.pen index f16cdd32..b4502303 100644 --- a/ui-design.pen +++ b/ui-design.pen @@ -31,6 +31,101 @@ } ] }, + { + "type": "frame", + "id": "manual_view_ordering_sidebar", + "x": 0, + "y": 12740, + "name": "Manual saved View ordering - sidebar row", + "theme": { + "Mode": "Light" + }, + "width": 420, + "height": 206, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--sidebar-border" + }, + "layout": "vertical", + "gap": 10, + "padding": 16, + "children": [ + { + "type": "text", + "id": "manual_view_ordering_hdr", + "fill": "$--muted-foreground", + "content": "VIEWS", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "700", + "letterSpacing": 0 + }, + { + "type": "frame", + "id": "manual_view_ordering_row", + "width": "fill_container", + "height": 32, + "fill": "$--accent", + "cornerRadius": 4, + "padding": [ + 6, + 8, + 6, + 8 + ], + "alignItems": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "manual_view_ordering_grip", + "fill": "$--muted-foreground", + "content": "grip", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500", + "letterSpacing": 0 + }, + { + "type": "text", + "id": "manual_view_ordering_name", + "fill": "$--foreground", + "width": "fill_container", + "content": "Active Projects", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "500", + "letterSpacing": 0 + }, + { + "type": "text", + "id": "manual_view_ordering_actions", + "fill": "$--muted-foreground", + "content": "up down edit delete", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500", + "letterSpacing": 0 + } + ] + }, + { + "type": "text", + "id": "manual_view_ordering_note", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Saved Views follow the ordered Types mental model: drag handle for pointer users, explicit up/down icon buttons for keyboard and command-palette flows.", + "lineHeight": 1.45, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal", + "letterSpacing": 0 + } + ] + }, { "type": "frame", "id": "mOf4J",