feat: allow manual saved view ordering

This commit is contained in:
lucaronin
2026-04-28 20:41:07 +02:00
parent a39b346a75
commit fd92ff5e9b
20 changed files with 964 additions and 161 deletions

View File

@@ -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. - 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. - 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 ### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing. `SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.

View File

@@ -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. - **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. - **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). - **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).

View File

@@ -53,6 +53,7 @@ mod tests {
name: "Inbox".to_string(), name: "Inbox".to_string(),
icon: None, icon: None,
color: None, color: None,
order: None,
sort: None, sort: None,
list_properties_display: vec![], list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]), filters: crate::vault::FilterGroup::All(vec![]),

View File

@@ -1,7 +1,8 @@
use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc}; use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc};
use regex::RegexBuilder; use regex::{Regex, RegexBuilder};
use serde::de::{self, MapAccess, Visitor}; use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::cmp::Ordering;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
@@ -15,6 +16,8 @@ pub struct ViewDefinition {
pub icon: Option<String>, pub icon: Option<String>,
#[serde(default)] #[serde(default)]
pub color: Option<String>, pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<i64>,
#[serde(default)] #[serde(default)]
pub sort: Option<String>, pub sort: Option<String>,
#[serde( #[serde(
@@ -254,10 +257,19 @@ pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
} }
} }
views.sort_by(|a, b| a.filename.cmp(&b.filename)); views.sort_by(compare_views);
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}`. /// Save a view definition as YAML to `vault_path/views/{filename}`.
pub fn save_view( pub fn save_view(
vault_path: &Path, vault_path: &Path,
@@ -422,120 +434,157 @@ fn supports_regex(op: &FilterOp) -> bool {
) )
} }
enum ConditionField<'a> {
Scalar(Option<String>),
Relationship(&'a [String]),
}
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool { fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
let field = cond.field.as_str(); let field = cond.field.as_str();
let mut relationship_values: Option<&[String]> = None; if let Some(result) = evaluate_condition_bool_field(field, entry, &cond.op, &cond.value) {
return result;
// 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),
_ => {}
} }
// String/option fields let field_value = resolve_condition_field(field, entry);
let field_value: Option<String> = 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 cond_value = cond.value.as_ref().and_then(yaml_value_to_string); let cond_value = cond.value.as_ref().and_then(yaml_value_to_string);
let regex = if cond.regex && supports_regex(&cond.op) { let regex = condition_regex(cond, cond_value.as_deref());
cond_value.as_deref().and_then(build_regex)
} else {
None
};
if cond.regex && supports_regex(&cond.op) && regex.is_none() { if cond.regex && supports_regex(&cond.op) && regex.is_none() {
return false; return false;
} }
if let Some(re) = regex.as_ref() { if let Some(re) = regex.as_ref() {
let matched = if let Some(prop) = field_value.as_deref() { return evaluate_regex_condition(&cond.op, &field_value, re);
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,
};
} }
if let Some(rels) = relationship_values { match field_value {
return evaluate_relationship_op(&cond.op, rels, &cond.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 { fn evaluate_condition_bool_field(
FilterOp::Equals => match (&field_value, &cond_value) { field: &str,
entry: &VaultEntry,
op: &FilterOp,
value: &Option<serde_yaml::Value>,
) -> Option<bool> {
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<String> {
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<Regex> {
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<serde_yaml::Value>,
) -> bool {
match op {
FilterOp::Equals => match (field_value, cond_value) {
(Some(f), Some(v)) => f.eq_ignore_ascii_case(v), (Some(f), Some(v)) => f.eq_ignore_ascii_case(v),
(None, None) => true, (None, None) => true,
_ => false, _ => 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), (Some(f), Some(v)) => !f.eq_ignore_ascii_case(v),
(None, None) => false, (None, None) => false,
_ => true, _ => 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()), (Some(f), Some(v)) => f.to_lowercase().contains(&v.to_lowercase()),
_ => false, _ => 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()), (Some(f), Some(v)) => !f.to_lowercase().contains(&v.to_lowercase()),
(None, _) => true, (None, _) => true,
_ => true, _ => true,
}, },
FilterOp::AnyOf => { FilterOp::AnyOf => {
let values = cond let values = raw_value
.value
.as_ref() .as_ref()
.and_then(yaml_value_to_string_vec) .and_then(yaml_value_to_string_vec)
.unwrap_or_default(); .unwrap_or_default();
match &field_value { match field_value {
Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)), Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => false, None => false,
} }
} }
FilterOp::NoneOf => { FilterOp::NoneOf => {
let values = cond let values = raw_value
.value
.as_ref() .as_ref()
.and_then(yaml_value_to_string_vec) .and_then(yaml_value_to_string_vec)
.unwrap_or_default(); .unwrap_or_default();
match &field_value { match field_value {
Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)), Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => true, None => true,
} }
} }
FilterOp::IsEmpty => field_value.as_deref().map_or(true, |s| s.is_empty()), FilterOp::IsEmpty => field_value.map_or(true, str::is_empty),
FilterOp::IsNotEmpty => field_value.as_deref().is_some_and(|s| !s.is_empty()), FilterOp::IsNotEmpty => field_value.is_some_and(|s| !s.is_empty()),
FilterOp::Before => match (&field_value, &cond_value) { FilterOp::Before => match (field_value, cond_value) {
(Some(f), Some(v)) => match ( (Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()), parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()), parse_date_filter_timestamp(v, Utc::now()),
@@ -545,7 +594,7 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
}, },
_ => false, _ => false,
}, },
FilterOp::After => match (&field_value, &cond_value) { FilterOp::After => match (field_value, cond_value) {
(Some(f), Some(v)) => match ( (Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()), parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()), parse_date_filter_timestamp(v, Utc::now()),
@@ -581,80 +630,61 @@ fn evaluate_relationship_op(
) -> bool { ) -> bool {
match op { match op {
FilterOp::Contains => { FilterOp::Contains => {
let target = value.as_ref().and_then(yaml_value_to_string); relationship_target(value).is_some_and(|target| relationship_contains(rels, &target))
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,
}
} }
FilterOp::NotContains => { FilterOp::NotContains => {
let target = value.as_ref().and_then(yaml_value_to_string); relationship_target(value).map_or(true, |target| !relationship_contains(rels, &target))
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)
})
} }
FilterOp::AnyOf => relationship_any_of(rels, &relationship_values(value)),
FilterOp::NoneOf => !relationship_any_of(rels, &relationship_values(value)),
FilterOp::IsEmpty => rels.is_empty(), FilterOp::IsEmpty => rels.is_empty(),
FilterOp::IsNotEmpty => !rels.is_empty(), FilterOp::IsNotEmpty => !rels.is_empty(),
FilterOp::Equals => { FilterOp::Equals => relationship_target(value).map_or_else(
let target = value.as_ref().and_then(yaml_value_to_string); || rels.is_empty(),
match target { |target| relationship_equals(rels, &target),
Some(t) => { ),
rels.len() == 1 FilterOp::NotEquals => relationship_target(value).map_or_else(
&& wikilink_stem(&rels[0]).to_lowercase() || !rels.is_empty(),
== wikilink_stem(&t).to_lowercase() |target| !relationship_equals(rels, &target),
} ),
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(),
}
}
_ => false, _ => false,
} }
} }
fn relationship_target(value: &Option<serde_yaml::Value>) -> Option<String> {
value.as_ref().and_then(yaml_value_to_string)
}
fn relationship_values(value: &Option<serde_yaml::Value>) -> Vec<String> {
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<String> { fn yaml_value_to_string(v: &serde_yaml::Value) -> Option<String> {
match v { match v {
serde_yaml::Value::String(s) => Some(s.clone()), serde_yaml::Value::String(s) => Some(s.clone()),
@@ -686,6 +716,7 @@ mod tests {
name: name.to_string(), name: name.to_string(),
icon: None, icon: None,
color: None, color: None,
order: None,
sort: None, sort: None,
list_properties_display: Vec::new(), list_properties_display: Vec::new(),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition { filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
@@ -963,6 +994,34 @@ filters:
assert_eq!(views[1].definition.name, "Beta"); 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<_>>(),
vec![
("beta.yml", Some(10)),
("alpha.yml", Some(20)),
("gamma.yml", None),
]
);
}
#[test] #[test]
fn test_migrate_views_from_old_location() { fn test_migrate_views_from_old_location() {
let dir = tempfile::TempDir::new().unwrap(); let dir = tempfile::TempDir::new().unwrap();

View File

@@ -72,7 +72,9 @@ import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave' import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi' import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge' import { useVaultBridge } from './hooks/useVaultBridge'
import { useSavedViewOrdering } from './hooks/useSavedViewOrdering'
import { createViewFilename } from './utils/viewFilename' import { createViewFilename } from './utils/viewFilename'
import { nextViewOrder } from './utils/viewOrdering'
import type { CommitDiffRequest } from './hooks/useDiffMode' import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal' import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog' import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
@@ -1076,7 +1078,9 @@ function App() {
const filename = editing const filename = editing
? editing.filename ? editing.filename
: createViewFilename(definition.name, vault.views.map((view) => view.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 const target = isTauri() ? invoke : mockInvoke
try { try {
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition }) await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
@@ -1319,6 +1323,14 @@ function App() {
? 'Customize All Notes columns' ? 'Customize All Notes columns'
: 'Customize Inbox columns' : 'Customize Inbox columns'
}, [effectiveSelection, vault.views]) }, [effectiveSelection, vault.views])
const viewOrdering = useSavedViewOrdering({
views: vault.views,
selection: effectiveSelection,
vaultPath: resolvedPath,
reloadViews: vault.reloadViews,
loadModifiedFiles: vault.loadModifiedFiles,
onToast: setToastMessage,
})
const activeNoteModified = useMemo( const activeNoteModified = useMemo(
() => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath), () => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath),
[notes.activeTabPath, vault.modifiedFiles], [notes.activeTabPath, vault.modifiedFiles],
@@ -1420,6 +1432,11 @@ function App() {
onToggleRawEditor: toggleRawEditorCommand, onToggleRawEditor: toggleRawEditorCommand,
noteLayout, noteLayout,
onToggleNoteLayout: toggleNoteLayout, 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, onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel, zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection, onSelect: handleSetSelection,
@@ -1570,7 +1587,7 @@ function App() {
{sidebarVisible && ( {sidebarVisible && (
<> <>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}> <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> </div>
<ResizeHandle onResize={layout.handleSidebarResize} /> <ResizeHandle onResize={layout.handleSidebarResize} />
</> </>

View File

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

View File

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

View File

@@ -21,8 +21,10 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion' import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader' import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem' import { SidebarViewItem } from './SidebarViewItem'
import { computeReorder } from './sidebarHooks'
import { countByFilter } from '../../utils/noteListHelpers' import { countByFilter } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n' import { translate, type AppLocale } from '../../lib/i18n'
import { canMoveView, type ViewMoveDirection } from '../../utils/viewOrdering'
export { SidebarTopNav } from './SidebarTopNav' export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection' export { FavoritesSection } from './FavoritesSection'
@@ -48,6 +50,9 @@ export function ViewsSection({
onCreateView, onCreateView,
onEditView, onEditView,
onDeleteView, onDeleteView,
onReorderViews,
onMoveView,
sensors,
entries, entries,
locale = 'en', locale = 'en',
}: { }: {
@@ -59,9 +64,35 @@ export function ViewsSection({
onCreateView?: () => void onCreateView?: () => void
onEditView?: (filename: string) => void onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void onDeleteView?: (filename: string) => void
onReorderViews?: (orderedFilenames: string[]) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
sensors: ReturnType<typeof useSensors>
entries: VaultEntry[] entries: VaultEntry[]
locale?: AppLocale 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 ( return (
<div className="border-b border-border" style={{ padding: '0 6px' }}> <div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.views')} collapsed={collapsed} onToggle={onToggle}> <SidebarGroupHeader label={translate(locale, 'sidebar.group.views')} collapsed={collapsed} onToggle={onToggle}>
@@ -81,24 +112,81 @@ export function ViewsSection({
</SidebarGroupHeader> </SidebarGroupHeader>
{!collapsed && ( {!collapsed && (
<div style={{ paddingBottom: 4 }}> <div style={{ paddingBottom: 4 }}>
{views.map((view) => ( {onReorderViews ? (
<SidebarViewItem <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleViewDragEnd}>
key={view.filename} <SortableContext items={viewIds} strategy={verticalListSortingStrategy}>
view={view} {views.map((view) => (
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })} <SortableViewItem
onSelect={() => onSelect({ kind: 'view', filename: view.filename })} key={view.filename}
onEditView={onEditView} view={view}
onDeleteView={onDeleteView} views={views}
entries={entries} selection={selection}
locale={locale} onSelect={onSelect}
/> onEditView={onEditView}
))} onDeleteView={onDeleteView}
onMoveView={onMoveView}
entries={entries}
locale={locale}
/>
))}
</SortableContext>
</DndContext>
) : views.map(renderViewItem)}
</div> </div>
)} )}
</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({ function SortableSection({
group, group,
sectionProps, sectionProps,

View File

@@ -1,11 +1,14 @@
import { useMemo } from 'react' import { useMemo, type HTMLAttributes } from 'react'
import type { VaultEntry, ViewFile } from '../../types' import type { VaultEntry, ViewFile } from '../../types'
import { evaluateView } from '../../utils/viewFilters' import { evaluateView } from '../../utils/viewFilters'
import { Funnel, PencilSimple, Trash } from '@phosphor-icons/react' 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 { NoteTitleIcon } from '../NoteTitleIcon'
import { SidebarCountPill } from '../SidebarParts' import { SidebarCountPill } from '../SidebarParts'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles' import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
import { translate, type AppLocale } from '../../lib/i18n' import { translate, type AppLocale } from '../../lib/i18n'
import type { ViewMoveDirection } from '../../utils/viewOrdering'
interface SidebarViewItemProps { interface SidebarViewItemProps {
view: ViewFile view: ViewFile
@@ -13,6 +16,10 @@ interface SidebarViewItemProps {
onSelect: () => void onSelect: () => void
onEditView?: (filename: string) => void onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void onDeleteView?: (filename: string) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
canMoveUp?: boolean
canMoveDown?: boolean
dragHandleProps?: HTMLAttributes<HTMLButtonElement>
entries: VaultEntry[] entries: VaultEntry[]
locale?: AppLocale locale?: AppLocale
} }
@@ -23,6 +30,10 @@ export function SidebarViewItem({
onSelect, onSelect,
onEditView, onEditView,
onDeleteView, onDeleteView,
onMoveView,
canMoveUp = false,
canMoveDown = false,
dragHandleProps,
entries, entries,
locale = 'en', locale = 'en',
}: SidebarViewItemProps) { }: SidebarViewItemProps) {
@@ -39,6 +50,20 @@ export function SidebarViewItem({
style={{ padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular, borderRadius: 4 }} style={{ padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular, borderRadius: 4 }}
onClick={onSelect} 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} {icon}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span> <span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span>
{showCount && ( {showCount && (
@@ -51,25 +76,59 @@ export function SidebarViewItem({
)} )}
</div> </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"> <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 && ( {onEditView && (
<button <Button
className="rounded p-0.5 text-muted-foreground hover:text-foreground" 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) }} onClick={(event) => { event.stopPropagation(); onEditView(view.filename) }}
title={translate(locale, 'sidebar.action.editView')} title={translate(locale, 'sidebar.action.editView')}
aria-label={translate(locale, 'sidebar.action.editView')} aria-label={translate(locale, 'sidebar.action.editView')}
> >
<PencilSimple size={12} /> <PencilSimple size={12} />
</button> </Button>
)} )}
{onDeleteView && ( {onDeleteView && (
<button <Button
className="rounded p-0.5 text-muted-foreground hover:text-destructive" 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) }} onClick={(event) => { event.stopPropagation(); onDeleteView(view.filename) }}
title={translate(locale, 'sidebar.action.deleteView')} title={translate(locale, 'sidebar.action.deleteView')}
aria-label={translate(locale, 'sidebar.action.deleteView')} aria-label={translate(locale, 'sidebar.action.deleteView')}
> >
<Trash size={12} /> <Trash size={12} />
</button> </Button>
)} )}
</div> </div>
</div> </div>

View File

@@ -28,6 +28,11 @@ interface ViewCommandsConfig {
onCustomizeNoteListColumns?: () => void onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean canCustomizeNoteListColumns?: boolean
noteListColumnsLabel: string noteListColumnsLabel: string
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
} }
function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: () => void): CommandAction { 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[] { export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
const { const {
hasActiveNote, activeNoteModified, hasActiveNote, activeNoteModified,
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout = 'centered', onToggleNoteLayout, onToggleAIChat, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout = 'centered', onToggleNoteLayout, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel, onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown,
canMoveSelectedViewUp, canMoveSelectedViewDown,
} = config } = config
return [ 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: '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: '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 }, { 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: '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-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 }, { id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomOut), keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },

View File

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

View File

@@ -359,6 +359,53 @@ describe('useCommandRegistry', () => {
expect(onToggleNoteLayout).toHaveBeenCalledOnce() 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', () => { it('updates note layout command copy when left alignment is active', () => {
const config = makeConfig({ noteLayout: 'left' }) const config = makeConfig({ noteLayout: 'left' })
const { result } = renderHook(() => useCommandRegistry(config)) const { result } = renderHook(() => useCommandRegistry(config))

View File

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

View File

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

View File

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

View File

@@ -153,6 +153,9 @@
"sidebar.action.createView": "Create view", "sidebar.action.createView": "Create view",
"sidebar.action.editView": "Edit view", "sidebar.action.editView": "Edit view",
"sidebar.action.deleteView": "Delete 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.customizeSections": "Customize sections",
"sidebar.action.renameSection": "Rename section…", "sidebar.action.renameSection": "Rename section…",
"sidebar.action.customizeIconColor": "Customize icon & color…", "sidebar.action.customizeIconColor": "Customize icon & color…",

View File

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

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import type { ViewFile } from '../types'
import {
buildViewOrderUpdates,
canMoveView,
moveView,
nextViewOrder,
orderViewsByFilename,
} from './viewOrdering'
function makeView(filename: string, name: string, order: number | null = null): ViewFile {
return {
filename,
definition: {
name,
icon: null,
color: null,
order,
sort: null,
filters: { all: [] },
},
}
}
describe('viewOrdering', () => {
const views = [
makeView('alpha.yml', 'Alpha', 0),
makeView('beta.yml', 'Beta', 1),
makeView('gamma.yml', 'Gamma', 2),
]
it('moves a view up or down without mutating the input order', () => {
expect(moveView(views, 'beta.yml', 'up')?.map((view) => view.filename)).toEqual([
'beta.yml',
'alpha.yml',
'gamma.yml',
])
expect(moveView(views, 'beta.yml', 'down')?.map((view) => view.filename)).toEqual([
'alpha.yml',
'gamma.yml',
'beta.yml',
])
expect(views.map((view) => view.filename)).toEqual(['alpha.yml', 'beta.yml', 'gamma.yml'])
})
it('blocks moves outside the list bounds', () => {
expect(canMoveView(views, 'alpha.yml', 'up')).toBe(false)
expect(canMoveView(views, 'gamma.yml', 'down')).toBe(false)
expect(moveView(views, 'missing.yml', 'up')).toBeNull()
})
it('orders views by a complete filename list', () => {
expect(orderViewsByFilename(views, ['gamma.yml', 'alpha.yml', 'beta.yml'])?.map((view) => view.filename)).toEqual([
'gamma.yml',
'alpha.yml',
'beta.yml',
])
})
it('rejects incomplete, duplicate, or unknown filename orders', () => {
expect(orderViewsByFilename(views, ['alpha.yml', 'beta.yml'])).toBeNull()
expect(orderViewsByFilename(views, ['alpha.yml', 'alpha.yml', 'gamma.yml'])).toBeNull()
expect(orderViewsByFilename(views, ['alpha.yml', 'beta.yml', 'missing.yml'])).toBeNull()
})
it('builds dense order updates while preserving view definitions', () => {
expect(buildViewOrderUpdates([views[2], views[0]]).map(({ filename, definition }) => ({
filename,
name: definition.name,
order: definition.order,
}))).toEqual([
{ filename: 'gamma.yml', name: 'Gamma', order: 0 },
{ filename: 'alpha.yml', name: 'Alpha', order: 1 },
])
})
it('places new views after the highest explicit order', () => {
expect(nextViewOrder(views)).toBe(3)
expect(nextViewOrder([makeView('untitled.yml', 'Untitled')])).toBe(1)
})
})

62
src/utils/viewOrdering.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { ViewDefinition, ViewFile } from '../types'
export type ViewMoveDirection = 'up' | 'down'
export type ViewMoveHandler = (filename: string, direction: ViewMoveDirection) => Promise<void> | void
export type ViewReorderHandler = (orderedFilenames: string[]) => Promise<void> | void
export interface ViewOrderUpdate {
filename: string
definition: ViewDefinition
}
function orderedViewIndex(views: ViewFile[], filename: string): number {
return views.findIndex((view) => view.filename === filename)
}
function destinationIndex(index: number, direction: ViewMoveDirection): number {
return direction === 'up' ? index - 1 : index + 1
}
export function canMoveView(views: ViewFile[], filename: string, direction: ViewMoveDirection): boolean {
const index = orderedViewIndex(views, filename)
if (index === -1) return false
const nextIndex = destinationIndex(index, direction)
return nextIndex >= 0 && nextIndex < views.length
}
export function moveView(views: ViewFile[], filename: string, direction: ViewMoveDirection): ViewFile[] | null {
if (!canMoveView(views, filename, direction)) return null
const index = orderedViewIndex(views, filename)
const nextIndex = destinationIndex(index, direction)
const reordered = [...views]
const [view] = reordered.splice(index, 1)
reordered.splice(nextIndex, 0, view)
return reordered
}
export function orderViewsByFilename(views: ViewFile[], orderedFilenames: string[]): ViewFile[] | null {
if (views.length !== orderedFilenames.length) return null
if (new Set(orderedFilenames).size !== orderedFilenames.length) return null
const viewByFilename = new Map(views.map((view) => [view.filename, view]))
const ordered = orderedFilenames.map((filename) => viewByFilename.get(filename))
if (ordered.some((view) => view === undefined)) return null
return ordered as ViewFile[]
}
export function buildViewOrderUpdates(views: ViewFile[]): ViewOrderUpdate[] {
return views.map((view, order) => ({
filename: view.filename,
definition: { ...view.definition, order },
}))
}
export function nextViewOrder(views: ViewFile[]): number {
const explicitOrders = views
.map((view) => view.definition.order)
.filter((order): order is number => typeof order === 'number' && Number.isFinite(order))
return explicitOrders.length > 0 ? Math.max(...explicitOrders) + 1 : views.length
}

View File

@@ -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", "type": "frame",
"id": "mOf4J", "id": "mOf4J",