diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index d2cfc61..e35d444 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -2,6 +2,7 @@ #![deny(clippy::disallowed_types)] mod editor; +mod navigation; use std::{ sync::{ @@ -14,8 +15,8 @@ use std::{ use editor::EntryEditor; use iced::{ - Element, Event, Length, Subscription, Task, event, keyboard, mouse, time, touch, - widget::{button, column, container, row, scrollable, text, text_input}, + Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch, + widget::{button, column, container, pane_grid, row, scrollable, text, text_input}, window, }; use ironstorage::{ @@ -29,18 +30,30 @@ use ironstorage::{ generate::GeneratorConfig, git::{AutomaticEntryCommitter, GitIdentity}, presentation::{ClipboardWait, NativeClipboardManager}, + read::{TreeModel, TreeNodeId}, repository::{Repository, SecretBytes}, secret_store::{SecretProtectionPolicy, SecretStoreBackend}, write::{WriteError, WriteOutcome}, }; +use navigation::{NavigationIntent, NavigationKey, NavigationTree}; use zeroize::Zeroizing; type OpenCompletion = Arc>>>; type SaveCompletion = Arc)>>>; +type TreeCompletion = Arc>>>; #[derive(Clone)] enum Message { StartupLoaded(Box>), + TreeLoaded { + generation: u64, + completion: TreeCompletion, + }, + RefreshTree, + SidebarActivate(TreeNodeId), + SidebarNavigate(NavigationKey), + TogglePaneFocus, + PaneResized(pane_grid::ResizeEvent), EntryPathChanged(String), OpenEntry, OpenFinished { @@ -131,6 +144,11 @@ struct App { sensitive: SensitiveUiState, authentication_generation: u64, operation_generation: u64, + tree_generation: u64, + panes: pane_grid::State, + pane_focus: PaneFocus, + navigation: NavigationTree, + tree_state: TreeState, after_authentication: Option, entry_path: String, editor: Option, @@ -142,6 +160,25 @@ struct App { status: String, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PaneKind { + Sidebar, + Content, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PaneFocus { + Sidebar, + Content, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum TreeState { + Loading, + Ready, + Error(String), +} + #[derive(Clone, Debug, Eq, PartialEq)] enum PendingAction { OpenEntry(String), @@ -180,6 +217,11 @@ fn main() -> iced::Result { .title(ironstorage::PRODUCT_NAME) .subscription(App::subscription) .exit_on_close_request(false) + .window(window::Settings { + size: Size::new(1_080.0, 720.0), + min_size: Some(Size::new(720.0, 480.0)), + ..window::Settings::default() + }) .run() } @@ -195,6 +237,16 @@ impl App { sensitive: SensitiveUiState::default(), authentication_generation: 0, operation_generation: 0, + tree_generation: 0, + panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { + axis: pane_grid::Axis::Vertical, + ratio: 0.28, + a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)), + b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)), + }), + pane_focus: PaneFocus::Sidebar, + navigation: NavigationTree::default(), + tree_state: TreeState::Loading, after_authentication: None, entry_path: String::new(), editor: None, @@ -221,14 +273,71 @@ impl App { self.authentication = AuthenticationView::Locked; self.status = "Entry names remain available while protected content is locked." .to_owned(); + return self.begin_tree_refresh(); } Err(error) => { + self.tree_state = TreeState::Error(error.clone()); self.authentication = AuthenticationView::Unavailable(error.clone()); self.status = error; } }, - Message::EntryPathChanged(path) => self.entry_path = path, + Message::TreeLoaded { + generation, + completion, + } => { + let Some(result) = take_completion(&completion) else { + return Task::none(); + }; + if generation != self.tree_generation { + return Task::none(); + } + match result { + Ok(model) => { + self.navigation.replace(&model); + self.tree_state = TreeState::Ready; + self.status = if self.navigation.is_empty() { + "The password store is empty.".to_owned() + } else { + "Password-store tree refreshed.".to_owned() + }; + } + Err(error) => { + self.tree_state = TreeState::Error(error.clone()); + self.status = format!("Tree refresh failed: {error}"); + } + } + } + Message::RefreshTree => return self.begin_tree_refresh(), + Message::SidebarActivate(id) => { + self.touch_user_activity(); + self.pane_focus = PaneFocus::Sidebar; + let intent = self.navigation.activate(id); + return self.handle_navigation(intent); + } + Message::SidebarNavigate(key) => { + self.touch_user_activity(); + if self.pane_focus == PaneFocus::Sidebar { + let intent = self.navigation.navigate(key); + return self.handle_navigation(intent); + } + } + Message::TogglePaneFocus => { + self.touch_user_activity(); + self.pane_focus = match self.pane_focus { + PaneFocus::Sidebar => PaneFocus::Content, + PaneFocus::Content => PaneFocus::Sidebar, + }; + } + Message::PaneResized(event) => { + self.panes + .resize(event.split, event.ratio.clamp(0.18, 0.55)); + } + Message::EntryPathChanged(path) => { + self.pane_focus = PaneFocus::Content; + self.entry_path = path; + } Message::OpenEntry => { + self.pane_focus = PaneFocus::Content; let entry = self.entry_path.trim().to_owned(); if entry.is_empty() { self.status = "Enter an entry path first.".to_owned(); @@ -250,6 +359,8 @@ impl App { match result { Ok(document) => { self.entry_path = entry.clone(); + let _selected = self.navigation.select_entry_path(&entry); + self.pane_focus = PaneFocus::Content; self.editor = Some(EntryEditor::new(document)); self.conflict = false; self.status = format!("Editing {entry}"); @@ -285,6 +396,7 @@ impl App { } } Message::FieldChanged(id, value) => { + self.pane_focus = PaneFocus::Content; self.edit(|editor| editor.update_raw(id, value.as_bytes())); } Message::AddAfter(id) => self.edit(|editor| editor.add_after(id)), @@ -422,6 +534,37 @@ impl App { Task::none() } + fn begin_tree_refresh(&mut self) -> Task { + let Some(config) = self.config.clone() else { + return Task::none(); + }; + self.tree_generation = self.tree_generation.wrapping_add(1); + let generation = self.tree_generation; + self.tree_state = TreeState::Loading; + Task::perform( + async move { Arc::new(Mutex::new(Some(load_tree(&config)))) }, + move |completion| Message::TreeLoaded { + generation, + completion, + }, + ) + } + + fn handle_navigation(&mut self, intent: NavigationIntent) -> Task { + match intent { + NavigationIntent::OpenEntry(entry) => { + self.request_action(PendingAction::OpenEntry(entry)) + } + NavigationIntent::None => iced::widget::operation::snap_to( + sidebar_scroll_id(), + scrollable::RelativeOffset { + x: 0.0, + y: self.navigation.selected_ratio(), + }, + ), + } + } + fn request_action(&mut self, action: PendingAction) -> Task { if self.saving { self.after_save = Some(action); @@ -429,6 +572,9 @@ impl App { return Task::none(); } if dirty_decision(self.editor.as_ref()) == DirtyDecision::Confirm { + if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) { + let _restored = self.navigation.select_entry_path(&entry); + } self.confirmation = Some(action); Task::none() } else { @@ -597,39 +743,32 @@ impl App { } AuthenticationView::Unavailable(error) => format!("Unavailable · {error}"), }; - let lock = button("Lock").on_press(Message::Lock); - let open = row![ - text_input("Entry path", &self.entry_path) - .on_input(Message::EntryPathChanged) - .on_submit(Message::OpenEntry), - button("Edit").on_press(Message::OpenEntry), - button("Reload").on_press(Message::RequestReload), - lock, - ] - .spacing(8); - - let body: Element<'_, Message> = if self.saving { - container(text("Saving without discarding the draft…")) - .center(Length::Fill) - .into() - } else if let Some(editor) = &self.editor { - editor_view(editor, self.conflict) - } else { - container(text( - "Enter an encrypted entry path. Browsing its name does not authenticate; Edit does.", - )) - .center(Length::Fill) - .into() - }; + let panes = pane_grid(&self.panes, |_pane, kind, _maximized| { + pane_grid::Content::new(match kind { + PaneKind::Sidebar => sidebar_view( + &self.navigation, + &self.tree_state, + self.pane_focus == PaneFocus::Sidebar, + ), + PaneKind::Content => content_view(self), + }) + }) + .spacing(1) + .min_size(220) + .on_resize(8, Message::PaneResized); container( column![ - row![text(authentication), text(&self.status).size(14)].spacing(16), - open, - body, + row![ + text(authentication), + text(&self.status).size(14), + text("Tab changes pane focus").size(12), + ] + .spacing(16) + .padding(10), + panes, ] - .spacing(12) - .padding(16), + .height(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) @@ -637,6 +776,127 @@ impl App { } } +fn sidebar_scroll_id() -> iced::widget::Id { + iced::widget::Id::new("desktop-navigation-tree") +} + +fn sidebar_view<'a>( + navigation: &'a NavigationTree, + state: &'a TreeState, + focused: bool, +) -> Element<'a, Message> { + let mut rows = column![ + row![ + text(if focused { + "Password Store · focused" + } else { + "Password Store" + }) + .size(20), + button("Refresh").on_press(Message::RefreshTree), + ] + .spacing(8) + ] + .spacing(4) + .padding(10); + + match state { + TreeState::Loading => { + rows = rows.push(text("Loading password-store tree…").size(13)); + } + TreeState::Error(error) => { + rows = rows.push(text(format!("Tree unavailable: {error}")).size(13)); + } + TreeState::Ready if navigation.is_empty() => { + rows = rows.push(text("This password store is empty.").size(13)); + } + TreeState::Ready => {} + } + + let selected = navigation.selected(); + for node in navigation.rows() { + let marker = if node.id.is_directory() { + if node.expanded { "▾" } else { "▸" } + } else { + "•" + }; + let mut flags = Vec::new(); + if node.indicators.has_conflict() { + flags.push("conflict"); + } + if node.indicators.is_changed() { + flags.push("changed"); + } + if node.indicators.is_locked() { + flags.push("locked"); + } + let suffix = if flags.is_empty() { + String::new() + } else { + format!(" · {}", flags.join(", ")) + }; + let selected = selected == Some(&node.id); + let item = button( + row![ + container(text("")).width(Length::Fixed((node.depth * 16) as f32)), + text(marker), + text(format!("{}{}", node.name, suffix)), + ] + .spacing(5), + ) + .width(Length::Fill) + .on_press(Message::SidebarActivate(node.id)) + .style(if selected { + button::primary + } else { + button::text + }); + rows = rows.push(item); + } + + scrollable(rows) + .id(sidebar_scroll_id()) + .height(Length::Fill) + .into() +} + +fn content_view(app: &App) -> Element<'_, Message> { + let open = row![ + text(if app.pane_focus == PaneFocus::Content { + "Content · focused" + } else { + "Content" + }) + .size(20), + text_input("Entry path", &app.entry_path) + .on_input(Message::EntryPathChanged) + .on_submit(Message::OpenEntry), + button("Edit").on_press(Message::OpenEntry), + button("Reload").on_press(Message::RequestReload), + button("Lock").on_press(Message::Lock), + ] + .spacing(8); + + let body: Element<'_, Message> = if app.saving { + container(text("Saving without discarding the draft…")) + .center(Length::Fill) + .into() + } else if let Some(editor) = &app.editor { + editor_view(editor, app.conflict) + } else { + container(text( + "Select an entry in the navigation tree. Entry names remain visible while locked; protected content authenticates only when opened.", + )) + .center(Length::Fill) + .into() + }; + + container(column![open, body].spacing(12).padding(12)) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + impl SaveFailure { fn storage(message: String) -> Self { Self { @@ -804,6 +1064,14 @@ fn load_document( .map_err(|error| error.to_string()) } +fn load_tree(config: &Config) -> Result { + let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?; + let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?; + ironstorage::read::VaultReader::new(&repository, &keys) + .list(&ironstorage::repository::DirectoryPath::root()) + .map_err(|error| error.to_string()) +} + fn save_document(config: &Config, editor: &EntryEditor) -> Result { let repository = Repository::open(config.vault()) .map_err(|error| SaveFailure::storage(error.to_string()))?; @@ -871,11 +1139,24 @@ fn poll_lease( } fn event_message(event: &Event) -> Option { - if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event - && modifiers.command() - && matches!(key.as_ref(), keyboard::Key::Character("s" | "S")) - { - return Some(Message::Save); + if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event { + if modifiers.command() && matches!(key.as_ref(), keyboard::Key::Character("s" | "S")) { + return Some(Message::Save); + } + let navigation = match key.as_ref() { + keyboard::Key::Named(keyboard::key::Named::Tab) => { + return Some(Message::TogglePaneFocus); + } + keyboard::Key::Named(keyboard::key::Named::ArrowUp) => NavigationKey::Previous, + keyboard::Key::Named(keyboard::key::Named::ArrowDown) => NavigationKey::Next, + keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => NavigationKey::Collapse, + keyboard::Key::Named(keyboard::key::Named::ArrowRight) => NavigationKey::Expand, + keyboard::Key::Named(keyboard::key::Named::Enter) => NavigationKey::Activate, + keyboard::Key::Named(keyboard::key::Named::Home) => NavigationKey::First, + keyboard::Key::Named(keyboard::key::Named::End) => NavigationKey::Last, + _ => return is_deliberate_activity(event).then_some(Message::UserActivity), + }; + return Some(Message::SidebarNavigate(navigation)); } is_deliberate_activity(event).then_some(Message::UserActivity) } @@ -1085,6 +1366,16 @@ mod tests { sensitive: SensitiveUiState::default(), authentication_generation: 0, operation_generation: 0, + tree_generation: 0, + panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { + axis: pane_grid::Axis::Vertical, + ratio: 0.28, + a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)), + b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)), + }), + pane_focus: PaneFocus::Sidebar, + navigation: NavigationTree::default(), + tree_state: TreeState::Loading, after_authentication: None, entry_path: String::new(), editor, @@ -1190,7 +1481,27 @@ mod tests { let draft = editor.document().serialize().expose().to_vec(); let mut app = test_app(Some(editor)); - let _task = app.request_action(PendingAction::OpenEntry("other".to_owned())); + let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path")); + let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path")); + app.navigation.replace_test_nodes(vec![ + navigation::TestNode { + id: draft_id, + name: "draft".to_owned(), + children: Vec::new(), + }, + navigation::TestNode { + id: other_id.clone(), + name: "other".to_owned(), + children: Vec::new(), + }, + ]); + assert!(app.navigation.select_entry_path("draft")); + let intent = app.navigation.activate(other_id); + let _task = app.handle_navigation(intent); + assert_eq!( + app.navigation.selected().map(TreeNodeId::path), + Some(Path::new("draft")) + ); let _task = app.update(Message::CancelDiscard); assert!(app.confirmation.is_none()); assert_eq!( @@ -1266,5 +1577,39 @@ mod tests { repeat: false, }); assert!(matches!(event_message(&save), Some(Message::Save))); + + let tab = Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Tab), + modified_key: keyboard::Key::Named(keyboard::key::Named::Tab), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Tab), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::NONE, + text: None, + repeat: false, + }); + assert!(matches!( + event_message(&tab), + Some(Message::TogglePaneFocus) + )); + } + + #[test] + fn pane_focus_and_storage_errors_are_handled_without_repository_access() { + let mut app = test_app(None); + assert_eq!(app.pane_focus, PaneFocus::Sidebar); + let _task = app.update(Message::TogglePaneFocus); + assert_eq!(app.pane_focus, PaneFocus::Content); + + app.tree_generation = 7; + let completion = Arc::new(Mutex::new(Some(Err("injected tree failure".to_owned())))); + let _task = app.update(Message::TreeLoaded { + generation: 7, + completion, + }); + assert_eq!( + app.tree_state, + TreeState::Error("injected tree failure".to_owned()) + ); + assert!(app.navigation.is_empty()); } } diff --git a/apps/desktop/src/navigation.rs b/apps/desktop/src/navigation.rs new file mode 100644 index 0000000..7b57657 --- /dev/null +++ b/apps/desktop/src/navigation.rs @@ -0,0 +1,390 @@ +//! Desktop navigation over storage-owned tree models and identities. + +use std::{collections::BTreeSet, path::Path}; + +use ironstorage::read::{TreeModel, TreeNode, TreeNodeId, TreeNodeIndicators, TreeNodeKind}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NavigationKey { + Previous, + Next, + Collapse, + Expand, + Activate, + First, + Last, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NavigationIntent { + None, + OpenEntry(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct NavigationNode { + id: TreeNodeId, + name: String, + indicators: TreeNodeIndicators, + children: Vec, +} + +impl NavigationNode { + fn from_storage(node: &TreeNode) -> Self { + Self { + id: node.id().clone(), + name: node.name().to_owned(), + indicators: node.indicators(), + children: node.children().iter().map(Self::from_storage).collect(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NavigationRow { + pub id: TreeNodeId, + pub name: String, + pub indicators: TreeNodeIndicators, + pub depth: usize, + pub expanded: bool, + pub has_children: bool, + parent: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct NavigationTree { + nodes: Vec, + expanded: BTreeSet, + selected: Option, +} + +impl NavigationTree { + pub fn replace(&mut self, model: &TreeModel) { + self.nodes = model + .children() + .iter() + .map(NavigationNode::from_storage) + .collect(); + let valid = collect_ids(&self.nodes); + self.expanded.retain(|id| valid.contains(id)); + if !self.selected.as_ref().is_some_and(|id| valid.contains(id)) { + self.selected = None; + } + } + + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + pub fn selected(&self) -> Option<&TreeNodeId> { + self.selected.as_ref() + } + + pub fn rows(&self) -> Vec { + let mut rows = Vec::new(); + flatten(&self.nodes, 0, None, &self.expanded, &mut rows); + rows + } + + pub fn selected_ratio(&self) -> f32 { + let rows = self.rows(); + let Some(index) = selected_index(&rows, self.selected.as_ref()) else { + return 0.0; + }; + index as f32 / rows.len().saturating_sub(1).max(1) as f32 + } + + pub fn activate(&mut self, id: TreeNodeId) -> NavigationIntent { + if !self.rows().iter().any(|row| row.id == id) { + return NavigationIntent::None; + } + self.selected = Some(id); + self.activate_selected() + } + + pub fn navigate(&mut self, key: NavigationKey) -> NavigationIntent { + match key { + NavigationKey::Previous => self.move_by(-1), + NavigationKey::Next => self.move_by(1), + NavigationKey::Collapse => self.collapse_or_parent(), + NavigationKey::Expand => self.expand_or_child(), + NavigationKey::Activate => return self.activate_selected(), + NavigationKey::First => self.select_edge(false), + NavigationKey::Last => self.select_edge(true), + } + NavigationIntent::None + } + + pub fn select_entry_path(&mut self, path: &str) -> bool { + let mut lineage = Vec::new(); + if !find_lineage(&self.nodes, path, &mut lineage) { + return false; + } + let Some(selected) = lineage.pop() else { + return false; + }; + if selected.kind() != TreeNodeKind::Entry { + return false; + } + self.expanded.extend(lineage); + self.selected = Some(selected); + true + } + + fn activate_selected(&mut self) -> NavigationIntent { + let Some(selected) = self.selected.clone() else { + return NavigationIntent::None; + }; + if selected.is_directory() { + if !self.expanded.remove(&selected) { + self.expanded.insert(selected); + } + NavigationIntent::None + } else { + NavigationIntent::OpenEntry( + selected + .entry() + .expect("non-directory tree identity is an entry") + .to_string(), + ) + } + } + + fn move_by(&mut self, amount: isize) { + let rows = self.rows(); + if rows.is_empty() { + self.selected = None; + return; + } + let destination = selected_index(&rows, self.selected.as_ref()).map_or_else( + || usize::from(amount < 0) * (rows.len() - 1), + |index| index.saturating_add_signed(amount).min(rows.len() - 1), + ); + self.selected = Some(rows[destination].id.clone()); + } + + fn select_edge(&mut self, last: bool) { + let rows = self.rows(); + self.selected = rows + .get(if last { + rows.len().saturating_sub(1) + } else { + 0 + }) + .map(|row| row.id.clone()); + } + + fn collapse_or_parent(&mut self) { + let Some(selected) = self.selected.clone() else { + return; + }; + if selected.is_directory() && self.expanded.remove(&selected) { + return; + } + let rows = self.rows(); + self.selected = selected_index(&rows, Some(&selected)) + .and_then(|index| rows[index].parent.clone()) + .or(Some(selected)); + } + + fn expand_or_child(&mut self) { + let rows = self.rows(); + let Some(index) = selected_index(&rows, self.selected.as_ref()) else { + self.select_edge(false); + return; + }; + let row = &rows[index]; + if !row.id.is_directory() || !row.has_children { + return; + } + if self.expanded.insert(row.id.clone()) { + return; + } + let rows = self.rows(); + if let Some(child) = rows.get(index + 1).filter(|child| child.depth > row.depth) { + self.selected = Some(child.id.clone()); + } + } + + #[cfg(test)] + pub(crate) fn replace_test_nodes(&mut self, nodes: Vec) { + self.nodes = nodes.into_iter().map(TestNode::into_navigation).collect(); + let valid = collect_ids(&self.nodes); + self.expanded.retain(|id| valid.contains(id)); + if !self.selected.as_ref().is_some_and(|id| valid.contains(id)) { + self.selected = None; + } + } +} + +fn flatten( + nodes: &[NavigationNode], + depth: usize, + parent: Option<&TreeNodeId>, + expanded: &BTreeSet, + rows: &mut Vec, +) { + for node in nodes { + let is_expanded = expanded.contains(&node.id); + rows.push(NavigationRow { + id: node.id.clone(), + name: node.name.clone(), + indicators: node.indicators, + depth, + expanded: is_expanded, + has_children: !node.children.is_empty(), + parent: parent.cloned(), + }); + if node.id.is_directory() && is_expanded { + flatten(&node.children, depth + 1, Some(&node.id), expanded, rows); + } + } +} + +fn selected_index(rows: &[NavigationRow], selected: Option<&TreeNodeId>) -> Option { + let selected = selected?; + rows.iter().position(|row| &row.id == selected) +} + +fn collect_ids(nodes: &[NavigationNode]) -> BTreeSet { + fn visit(nodes: &[NavigationNode], ids: &mut BTreeSet) { + for node in nodes { + ids.insert(node.id.clone()); + visit(&node.children, ids); + } + } + let mut ids = BTreeSet::new(); + visit(nodes, &mut ids); + ids +} + +fn find_lineage(nodes: &[NavigationNode], path: &str, lineage: &mut Vec) -> bool { + for node in nodes { + lineage.push(node.id.clone()); + if node + .id + .entry() + .is_some_and(|entry| entry.as_path() == Path::new(path)) + || find_lineage(&node.children, path, lineage) + { + return true; + } + let _ = lineage.pop(); + } + false +} + +#[cfg(test)] +pub(crate) struct TestNode { + pub id: TreeNodeId, + pub name: String, + pub children: Vec, +} + +#[cfg(test)] +impl TestNode { + fn into_navigation(self) -> NavigationNode { + NavigationNode { + id: self.id, + name: self.name, + indicators: TreeNodeIndicators::default(), + children: self + .children + .into_iter() + .map(Self::into_navigation) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use ironstorage::repository::{DirectoryPath, EntryPath}; + + use super::*; + + fn directory(path: &str, children: Vec) -> TestNode { + TestNode { + id: TreeNodeId::Directory(DirectoryPath::parse(path).expect("directory path")), + name: path.rsplit('/').next().unwrap_or(path).to_owned(), + children, + } + } + + fn entry(path: &str) -> TestNode { + TestNode { + id: TreeNodeId::Entry(EntryPath::parse(path).expect("entry path")), + name: path.rsplit('/').next().unwrap_or(path).to_owned(), + children: Vec::new(), + } + } + + fn populated() -> Vec { + vec![ + directory( + "personal", + vec![ + entry("personal/email"), + entry("personal/a very long entry name that remains complete"), + ], + ), + directory("work", vec![entry("work/server")]), + ] + } + + #[test] + fn keyboard_and_mouse_navigation_expand_folders_and_open_only_entries() { + let mut tree = NavigationTree::default(); + tree.replace_test_nodes(populated()); + assert_eq!(tree.navigate(NavigationKey::Next), NavigationIntent::None); + assert_eq!( + tree.selected().map(TreeNodeId::path), + Some(Path::new("personal")) + ); + assert_eq!(tree.navigate(NavigationKey::Expand), NavigationIntent::None); + assert_eq!(tree.rows().len(), 4); + assert_eq!(tree.navigate(NavigationKey::Expand), NavigationIntent::None); + assert_eq!( + tree.navigate(NavigationKey::Activate), + NavigationIntent::OpenEntry("personal/email".to_owned()) + ); + assert_eq!( + tree.navigate(NavigationKey::Collapse), + NavigationIntent::None + ); + assert_eq!( + tree.selected().map(TreeNodeId::path), + Some(Path::new("personal")) + ); + assert_eq!( + tree.activate(TreeNodeId::Directory( + DirectoryPath::parse("personal").expect("path") + )), + NavigationIntent::None + ); + assert_eq!(tree.rows().len(), 2); + } + + #[test] + fn refresh_preserves_valid_typed_identity_and_clears_removed_state() { + let mut tree = NavigationTree::default(); + tree.replace_test_nodes(populated()); + assert!(tree.select_entry_path("personal/email")); + let selected = tree.selected().cloned(); + tree.replace_test_nodes(populated()); + assert_eq!(tree.selected(), selected.as_ref()); + assert!(tree.rows().iter().any(|row| row.name.contains("very long"))); + + tree.replace_test_nodes(vec![directory("personal", Vec::new())]); + assert!(tree.selected().is_none()); + assert_eq!(tree.expanded.len(), 1); + tree.replace_test_nodes(Vec::new()); + assert!(tree.selected().is_none()); + assert!(tree.expanded.is_empty()); + assert!(tree.rows().is_empty()); + assert_eq!( + tree.navigate(NavigationKey::Activate), + NavigationIntent::None + ); + } +} diff --git a/crates/storage/src/read.rs b/crates/storage/src/read.rs index 911157d..5cc9168 100644 --- a/crates/storage/src/read.rs +++ b/crates/storage/src/read.rs @@ -15,12 +15,49 @@ use crate::{ const EXTENSIONS_DIRECTORY: &str = ".extensions"; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum TreeNodeKind { Directory, Entry, } +/// Stable storage-owned identity for a navigation tree object. +/// +/// Frontends retain this value across refreshed [`TreeModel`] instances +/// instead of reconstructing identity from rendered labels. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum TreeNodeId { + Directory(DirectoryPath), + Entry(EntryPath), +} + +impl TreeNodeId { + pub fn path(&self) -> &Path { + match self { + Self::Directory(path) => path.as_path(), + Self::Entry(path) => path.as_path(), + } + } + + pub fn kind(&self) -> TreeNodeKind { + match self { + Self::Directory(_) => TreeNodeKind::Directory, + Self::Entry(_) => TreeNodeKind::Entry, + } + } + + pub fn is_directory(&self) -> bool { + matches!(self, Self::Directory(_)) + } + + pub fn entry(&self) -> Option<&EntryPath> { + match self { + Self::Entry(path) => Some(path), + Self::Directory(_) => None, + } + } +} + /// Presentation-safe state calculated by storage services for a tree object. /// Frontends render these flags and never infer them from names or paths. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -55,8 +92,7 @@ impl TreeNodeIndicators { #[derive(Clone, Debug, Eq, PartialEq)] pub struct TreeNode { name: String, - path: String, - kind: TreeNodeKind, + id: TreeNodeId, indicators: TreeNodeIndicators, children: Vec, } @@ -67,11 +103,18 @@ impl TreeNode { } pub fn path(&self) -> &str { - &self.path + self.id + .path() + .to_str() + .expect("tree construction rejects non-UTF-8 paths") } pub fn kind(&self) -> TreeNodeKind { - self.kind + self.id.kind() + } + + pub fn id(&self) -> &TreeNodeId { + &self.id } pub fn indicators(&self) -> TreeNodeIndicators { @@ -630,8 +673,10 @@ fn finalize_children(node: MutableNode, parent: &Path) -> Result, let children = finalize_children(child, &path)?; Ok(TreeNode { name, - path: path_text(&path)?, - kind, + id: match kind { + TreeNodeKind::Directory => TreeNodeId::Directory(DirectoryPath::parse(&path)?), + TreeNodeKind::Entry => TreeNodeId::Entry(EntryPath::parse(&path)?), + }, indicators: TreeNodeIndicators::default(), children, })