diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs index dd339f0..c4e589f 100644 --- a/apps/desktop/src/action.rs +++ b/apps/desktop/src/action.rs @@ -21,12 +21,16 @@ pub enum UiAction { CopyEditedField, Paste, Find, + SearchContents, CommandPalette, TogglePaneFocus, Refresh, ReloadEntry, EditEntry, GeneratePassword, + MoveEntry, + CopyEntry, + DeleteEntry, ToggleReveal, Lock, Minimize, @@ -53,12 +57,16 @@ impl UiAction { Self::CopyEditedField => "copy-edited-field", Self::Paste => "paste", Self::Find => "find", + Self::SearchContents => "search-contents", Self::CommandPalette => "command-palette", Self::TogglePaneFocus => "toggle-pane-focus", Self::Refresh => "refresh", Self::ReloadEntry => "reload-entry", Self::EditEntry => "edit-entry", Self::GeneratePassword => "generate-password", + Self::MoveEntry => "move-entry", + Self::CopyEntry => "copy-entry", + Self::DeleteEntry => "delete-entry", Self::ToggleReveal => "toggle-reveal", Self::Lock => "lock", Self::Minimize => "minimize", @@ -120,6 +128,7 @@ pub struct ActionContext { pub focused_sensitive: bool, pub focused_generatable: bool, pub entry_path: bool, + pub selected_object: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -173,6 +182,12 @@ pub const ACTIONS: &[ActionSpec] = &[ ), spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")), spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")), + spec( + UiAction::SearchContents, + MenuGroup::Edit, + "Search Decrypted Contents…", + Some("⇧⌘F"), + ), spec( UiAction::CommandPalette, MenuGroup::View, @@ -199,6 +214,14 @@ pub const ACTIONS: &[ActionSpec] = &[ "Generate Password…", None, ), + spec( + UiAction::MoveEntry, + MenuGroup::Entry, + "Move or Rename…", + None, + ), + spec(UiAction::CopyEntry, MenuGroup::Entry, "Copy Entry…", None), + spec(UiAction::DeleteEntry, MenuGroup::Entry, "Delete…", None), spec( UiAction::ToggleReveal, MenuGroup::Entry, @@ -289,7 +312,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool { } UiAction::CloseWindow | UiAction::Quit => !context.switching_vault, UiAction::Minimize => true, - UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false, + UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste => false, + UiAction::Find | UiAction::SearchContents => { + context.storage_ready + && !context.saving + && !context.switching_vault + && !context.modal_open + } UiAction::CopyField => { context.unlocked && context.document_open @@ -320,6 +349,13 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool { && !context.switching_vault && !context.modal_open } + UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry => { + context.storage_ready + && context.selected_object + && !context.saving + && !context.switching_vault + && !context.modal_open + } UiAction::ToggleReveal => { context.unlocked && context.document_open @@ -367,9 +403,17 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta UiAction::Save if context.saving => "A save is already running", UiAction::Save => "Wait for vault validation", UiAction::CloseWindow | UiAction::Quit => "Wait for vault validation", - UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => { + UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste => { "Use the focused native text field" } + UiAction::Find | UiAction::SearchContents if !context.storage_ready => { + "Shared configuration is unavailable" + } + UiAction::Find | UiAction::SearchContents if context.saving => "Wait for the active save", + UiAction::Find | UiAction::SearchContents if context.switching_vault => { + "Wait for vault validation" + } + UiAction::Find | UiAction::SearchContents => "Close the current screen first", UiAction::CopyField | UiAction::CopyEditedField if !context.unlocked => { "Unlock an entry first" } @@ -402,6 +446,27 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta } UiAction::GeneratePassword if context.switching_vault => "Wait for vault validation", UiAction::GeneratePassword => "Close the current screen first", + UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry + if !context.storage_ready => + { + "Shared configuration is unavailable" + } + UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry + if !context.selected_object => + { + "Select an entry or folder first" + } + UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry if context.saving => { + "Wait for the active save" + } + UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry + if context.switching_vault => + { + "Wait for vault validation" + } + UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry => { + "Close the current screen first" + } UiAction::ToggleReveal if !context.unlocked => "Unlock an entry first", UiAction::ToggleReveal if !context.document_open => "Open an entry first", UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first", @@ -436,12 +501,16 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] { UiAction::CopyField | UiAction::CopyEditedField => &["copy value", "clipboard"], UiAction::Paste => &["insert clipboard"], UiAction::Find => &["search text"], + UiAction::SearchContents => &["grep", "decrypted search", "search passwords"], UiAction::CommandPalette => &["commands", "actions", "search commands"], UiAction::TogglePaneFocus => &["next pane", "switch pane", "focus"], UiAction::Refresh => &["reload vault", "refresh tree"], UiAction::ReloadEntry => &["revert entry", "refresh entry"], UiAction::EditEntry => &["modify entry"], UiAction::GeneratePassword => &["random password", "replace password", "generate"], + UiAction::MoveEntry => &["rename", "mv", "move folder"], + UiAction::CopyEntry => &["duplicate entry", "copy folder", "pass cp"], + UiAction::DeleteEntry => &["remove", "rm", "delete folder"], UiAction::ToggleReveal => &["show password", "hide password", "reveal field"], UiAction::Lock => &["secure", "log out", "relock"], UiAction::Minimize => &["hide window"], @@ -471,6 +540,7 @@ pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> O keyboard::Key::Character("x" | "X") => Some(UiAction::Cut), keyboard::Key::Character("c" | "C") => Some(UiAction::CopyField), keyboard::Key::Character("v" | "V") => Some(UiAction::Paste), + keyboard::Key::Character("f" | "F") if modifiers.shift() => Some(UiAction::SearchContents), keyboard::Key::Character("f" | "F") => Some(UiAction::Find), keyboard::Key::Character("k" | "K") => Some(UiAction::CommandPalette), keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh), @@ -501,6 +571,7 @@ mod tests { focused_sensitive: true, focused_generatable: true, entry_path: true, + selected_object: true, } } @@ -554,6 +625,18 @@ mod tests { ] { assert!(!enabled(action, locked)); } + for action in [ + UiAction::Find, + UiAction::SearchContents, + UiAction::MoveEntry, + UiAction::CopyEntry, + UiAction::DeleteEntry, + ] { + assert!( + enabled(action, locked), + "{action:?} can initiate authentication" + ); + } assert!(!enabled( UiAction::Refresh, ActionContext { @@ -591,6 +674,11 @@ mod tests { UiAction::EditEntry, UiAction::GeneratePassword, UiAction::ToggleReveal, + UiAction::Find, + UiAction::SearchContents, + UiAction::MoveEntry, + UiAction::CopyEntry, + UiAction::DeleteEntry, ] { assert!(!enabled(action, switching), "{action:?}"); } @@ -662,6 +750,13 @@ mod tests { Some(expected) ); } + assert_eq!( + shortcut_action( + &keyboard::Key::Character("f".into()), + primary | keyboard::Modifiers::SHIFT, + ), + Some(UiAction::SearchContents) + ); assert_eq!( shortcut_action(&keyboard::Key::Named(Named::F1), keyboard::Modifiers::NONE), Some(UiAction::Help) diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 64ee61e..f439314 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -23,7 +23,7 @@ use std::{ use editor::{EntryEditor, FieldNavigation}; use iced::{ Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch, - widget::{button, column, container, pane_grid, row, scrollable, text, text_input}, + widget::{button, column, container, mouse_area, pane_grid, row, scrollable, text, text_input}, window, }; use ironstorage::{ @@ -31,19 +31,20 @@ use ironstorage::{ AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession, NativeAuthenticationHandle, NativeAuthenticationSession, }, - command::InitRequest, + command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest}, crypto::KeyInfo, - desktop::{DesktopError, DesktopErrorKind, DesktopStorage}, + desktop::{DesktopError, DesktopErrorKind, DesktopMutationRequest, DesktopStorage}, document::{ DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId, EntryFieldKind, EntrySensitivity, }, generate::GeneratorConfig, + mutation::{MutationAction, MutationOutcome, MutationSelection}, presentation::{ClipboardWait, NativeClipboardManager}, - read::{TreeModel, TreeNodeId}, + read::{FindResults, GrepResults, TreeModel, TreeNodeId}, repository::SecretBytes, secret_store::SecretStoreBackend, - write::WriteOutcome, + write::{OverwriteDecision, WriteOutcome}, }; use navigation::{NavigationIntent, NavigationKey, NavigationTree}; use palette::CommandPalette; @@ -57,6 +58,7 @@ type OpenCompletion = Arc>>>; type SaveCompletion = Arc)>>>; type TreeCompletion = Arc>>>; type CreateCompletion = Arc)>>>; +type SearchCompletion = Arc>>>; #[derive(Clone, Debug, Eq, PartialEq)] struct RecipientSummary { @@ -115,6 +117,28 @@ enum Message { generation: u64, completion: CreateCompletion, }, + SearchQueryChanged(String), + AddSearchTerm, + RemoveSearchTerm(usize), + ToggleSearchCase, + ToggleSearchInvert, + ToggleSearchLineNumbers, + ToggleSearchFixedStrings, + SubmitSearch, + SearchFinished { + generation: u64, + completion: SearchCompletion, + }, + ActivateSearchResult(TreeNodeId), + MutationDestinationChanged(String), + SelectMutationDestination(String), + ToggleMutationOverwrite, + ToggleMutationConfirmation, + SubmitMutation, + MutationFinished { + generation: u64, + result: Box>, + }, #[cfg(target_os = "macos")] PollNativeMenu, StartupLoaded(Box>), @@ -123,6 +147,8 @@ enum Message { completion: TreeCompletion, }, SidebarActivate(TreeNodeId), + SidebarContext(TreeNodeId), + SidebarContextAction(TreeNodeId, UiAction), SidebarNavigate(NavigationKey), TogglePaneFocus, PaneResized(pane_grid::ResizeEvent), @@ -224,6 +250,7 @@ struct App { vault_generation: u64, settings_generation: u64, workflow_generation: u64, + selection_after_refresh: Option, panes: pane_grid::State, pane_focus: PaneFocus, navigation: NavigationTree, @@ -241,6 +268,7 @@ struct App { status: String, open_menu: Option, utility: Option, + context_target: Option, palette: CommandPalette, #[cfg(target_os = "macos")] native_menu: Option, @@ -264,15 +292,144 @@ enum ContentMode { Editor, } -#[derive(Clone, Debug, Eq, PartialEq)] enum UtilityView { About, Settings(SettingsForm), Recipients(RecipientForm), NewEntry(NewEntryForm), + Search(SearchForm), + Mutation(MutationForm), Help, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SearchMode { + Names, + Contents, +} + +enum SearchResults { + Names(FindResults), + Contents(GrepResults), +} + +struct SearchForm { + mode: SearchMode, + query: String, + terms: Vec, + ignore_case: bool, + invert_match: bool, + line_numbers: bool, + fixed_strings: bool, + running: bool, + error: Option, + results: Option, +} + +impl SearchForm { + fn new(mode: SearchMode) -> Self { + Self { + mode, + query: String::new(), + terms: Vec::new(), + ignore_case: false, + invert_match: false, + line_numbers: true, + fixed_strings: false, + running: false, + error: None, + results: None, + } + } + + fn request(&self) -> Result { + Ok(match self.mode { + SearchMode::Names => { + let mut terms = self.terms.clone(); + if !self.query.is_empty() { + terms.push(self.query.clone()); + } + if terms.is_empty() { + return Err("Enter at least one name search term.".to_owned()); + } + SearchRequest::Names(FindRequest { terms }) + } + SearchMode::Contents => SearchRequest::Contents(GrepRequest { + pattern: self.query.clone(), + ignore_case: self.ignore_case, + invert_match: self.invert_match, + line_number: self.line_numbers, + fixed_strings: self.fixed_strings, + }), + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum SearchRequest { + Names(FindRequest), + Contents(GrepRequest), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MutationKind { + Move, + Copy, + Delete, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct MutationForm { + kind: MutationKind, + source: TreeNodeId, + destination: String, + overwrite: bool, + confirmed: bool, + running: bool, + error: Option, +} + +impl MutationForm { + fn new(kind: MutationKind, source: TreeNodeId) -> Self { + Self { + destination: source.path().display().to_string(), + kind, + source, + overwrite: false, + confirmed: false, + running: false, + error: None, + } + } + + fn request(&self) -> Result { + let source = self.source.path().display().to_string(); + match self.kind { + MutationKind::Move | MutationKind::Copy if self.destination == source => { + Err("Choose a different destination.".to_owned()) + } + MutationKind::Move => Ok(DesktopMutationRequest::Move(MoveRequest { + source, + destination: self.destination.clone(), + force: false, + })), + MutationKind::Copy => Ok(DesktopMutationRequest::Copy(CopyRequest { + source, + destination: self.destination.clone(), + force: false, + })), + MutationKind::Delete if !self.confirmed => { + Err("Confirm permanent removal first.".to_owned()) + } + MutationKind::Delete => Ok(DesktopMutationRequest::Remove(RemoveRequest { + entry: source, + recursive: self.source.is_directory(), + force: false, + })), + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum RecipientWorkflowKind { InitializeStore, @@ -508,6 +665,8 @@ enum PendingAction { CloseWindow(window::Id), ApplyRecipients(RecipientForm), CreateEntry(NewEntryForm), + SearchContents(GrepRequest), + Mutate(MutationForm), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -551,6 +710,7 @@ impl App { vault_generation: 0, settings_generation: 0, workflow_generation: 0, + selection_after_refresh: None, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, @@ -573,6 +733,7 @@ impl App { status: "Loading shared configuration…".to_owned(), open_menu: None, utility: None, + context_target: None, palette: CommandPalette::default(), #[cfg(target_os = "macos")] native_menu: None, @@ -634,6 +795,8 @@ impl App { matches!(utility, UtilityView::Settings(form) if form.saving) || matches!(utility, UtilityView::Recipients(form) if form.running) || matches!(utility, UtilityView::NewEntry(form) if form.running) + || matches!(utility, UtilityView::Search(form) if form.running) + || matches!(utility, UtilityView::Mutation(form) if form.running) }) { self.status = "Wait for the active workflow to finish…".to_owned(); } else { @@ -996,6 +1159,203 @@ impl App { } } } + Message::SearchQueryChanged(query) => { + if let Some(UtilityView::Search(form)) = &mut self.utility + && !form.running + { + form.query = query; + form.error = None; + form.results = None; + } + } + Message::AddSearchTerm => { + if let Some(UtilityView::Search(form)) = &mut self.utility + && !form.running + && form.mode == SearchMode::Names + && !form.query.is_empty() + { + form.terms.push(std::mem::take(&mut form.query)); + form.error = None; + form.results = None; + } + } + Message::RemoveSearchTerm(index) => { + if let Some(UtilityView::Search(form)) = &mut self.utility + && !form.running + && form.mode == SearchMode::Names + && index < form.terms.len() + { + form.terms.remove(index); + form.error = None; + form.results = None; + } + } + Message::ToggleSearchCase => self.update_search_form(|form| { + form.ignore_case = !form.ignore_case; + }), + Message::ToggleSearchInvert => self.update_search_form(|form| { + form.invert_match = !form.invert_match; + }), + Message::ToggleSearchLineNumbers => self.update_search_form(|form| { + form.line_numbers = !form.line_numbers; + }), + Message::ToggleSearchFixedStrings => self.update_search_form(|form| { + form.fixed_strings = !form.fixed_strings; + }), + Message::SubmitSearch => { + let request = match &self.utility { + Some(UtilityView::Search(form)) if !form.running => form.request(), + _ => return Task::none(), + }; + let request = match request { + Ok(request) => request, + Err(error) => { + if let Some(UtilityView::Search(form)) = &mut self.utility { + form.error = Some(error); + } + return Task::none(); + } + }; + if let SearchRequest::Contents(request) = &request + && self.handle.is_none() + { + self.after_authentication = + Some(PendingAction::SearchContents(request.clone())); + return self.begin_authentication(); + } + return self.begin_search(request); + } + Message::SearchFinished { + generation, + completion, + } => { + let Some(result) = take_completion(&completion) else { + return Task::none(); + }; + if generation != self.workflow_generation { + return Task::none(); + } + match result { + Ok(results) => { + let count = match &results { + SearchResults::Names(results) => results.matches().len(), + SearchResults::Contents(results) => results.entries().len(), + }; + if let Some(UtilityView::Search(form)) = &mut self.utility { + form.running = false; + form.error = None; + form.results = Some(results); + } + self.status = format!("Search completed with {count} matching object(s)."); + } + Err(error) => { + if let Some(UtilityView::Search(form)) = &mut self.utility { + form.running = false; + form.error = Some(error.clone()); + } + self.status = format!("Search failed: {error}"); + } + } + } + Message::ActivateSearchResult(id) => { + self.utility = None; + self.pane_focus = if id.is_directory() { + PaneFocus::Sidebar + } else { + PaneFocus::Content + }; + let _selected = self.navigation.select_id(&id); + if let TreeNodeId::Entry(path) = id { + return self.request_action(PendingAction::OpenEntry(path.to_string())); + } + } + Message::MutationDestinationChanged(destination) => { + if let Some(UtilityView::Mutation(form)) = &mut self.utility + && !form.running + { + form.destination = destination; + form.overwrite = false; + form.error = None; + } + } + Message::SelectMutationDestination(destination) => { + if let Some(UtilityView::Mutation(form)) = &mut self.utility + && !form.running + { + form.destination = destination; + form.overwrite = false; + form.error = None; + } + } + Message::ToggleMutationOverwrite => { + if let Some(UtilityView::Mutation(form)) = &mut self.utility + && !form.running + { + form.overwrite = !form.overwrite; + form.error = None; + } + } + Message::ToggleMutationConfirmation => { + if let Some(UtilityView::Mutation(form)) = &mut self.utility + && !form.running + { + form.confirmed = !form.confirmed; + form.error = None; + } + } + Message::SubmitMutation => { + let form = match &self.utility { + Some(UtilityView::Mutation(form)) if !form.running => form.clone(), + _ => return Task::none(), + }; + if let Err(error) = form.request() { + if let Some(UtilityView::Mutation(form)) = &mut self.utility { + form.error = Some(error); + } + return Task::none(); + } + return self.request_action(PendingAction::Mutate(form)); + } + Message::MutationFinished { generation, result } => { + if generation != self.workflow_generation { + return Task::none(); + } + match *result { + Ok(outcome) => { + self.selection_after_refresh = + outcome.selection().map(|selection| match selection { + MutationSelection::Entry(path) => TreeNodeId::Entry(path.clone()), + MutationSelection::Directory(path) => { + TreeNodeId::Directory(path.clone()) + } + }); + let verb = match outcome.action() { + MutationAction::Remove => "Removed", + MutationAction::Move => "Moved", + MutationAction::Copy => "Copied", + }; + self.utility = None; + self.editor = None; + self.content_mode = ContentMode::Viewer; + self.entry_path = self + .selection_after_refresh + .as_ref() + .map_or_else(String::new, |id| id.path().display().to_string()); + self.status = format!( + "{verb} {} encrypted entry/entries; refreshing the tree.", + outcome.entries().len() + ); + return self.begin_tree_refresh(); + } + Err(error) => { + if let Some(UtilityView::Mutation(form)) = &mut self.utility { + form.running = false; + form.error = Some(error.clone()); + } + self.status = format!("Mutation failed: {error}. Store unchanged."); + } + } + } #[cfg(target_os = "macos")] Message::PollNativeMenu => { if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) { @@ -1031,6 +1391,9 @@ impl App { match result { Ok(model) => { self.navigation.replace(&model); + if let Some(id) = self.selection_after_refresh.take() { + let _selected = self.navigation.select_id(&id); + } self.tree_state = tree_state_from_result(Ok(self.navigation.is_empty())); self.status = if self.tree_state == TreeState::Empty { "The password store is empty.".to_owned() @@ -1045,11 +1408,22 @@ impl App { } } Message::SidebarActivate(id) => { + self.context_target = None; self.touch_user_activity(); self.pane_focus = PaneFocus::Sidebar; let intent = self.navigation.activate(id); return self.handle_navigation(intent); } + Message::SidebarContext(id) => { + let _selected = self.navigation.select_id(&id); + self.context_target = Some(id); + self.pane_focus = PaneFocus::Sidebar; + } + Message::SidebarContextAction(id, action) => { + let _selected = self.navigation.select_id(&id); + self.context_target = None; + return self.invoke_action(action); + } Message::SidebarNavigate(key) => { self.touch_user_activity(); if self.palette.is_open() { @@ -1387,6 +1761,7 @@ impl App { && field.metadata().kind() != EntryFieldKind::OtpUri }), entry_path: !self.entry_path.trim().is_empty(), + selected_object: self.navigation.selected().is_some(), } } @@ -1446,6 +1821,12 @@ impl App { self.entry_path.trim().to_owned(), ))); } + UiAction::Find => { + self.utility = Some(UtilityView::Search(SearchForm::new(SearchMode::Names))); + } + UiAction::SearchContents => { + self.utility = Some(UtilityView::Search(SearchForm::new(SearchMode::Contents))); + } UiAction::Help => self.utility = Some(UtilityView::Help), UiAction::CommandPalette => return self.toggle_palette(), UiAction::OpenFolder => { @@ -1475,13 +1856,25 @@ impl App { return self.update(Message::RequestGenerate(id)); } } + UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry => { + let Some(source) = self.navigation.selected().cloned() else { + return Task::none(); + }; + let kind = match action { + UiAction::MoveEntry => MutationKind::Move, + UiAction::CopyEntry => MutationKind::Copy, + UiAction::DeleteEntry => MutationKind::Delete, + _ => unreachable!(), + }; + self.utility = Some(UtilityView::Mutation(MutationForm::new(kind, source))); + } UiAction::ToggleReveal => { if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) { return self.update(Message::ToggleReveal(id)); } } UiAction::Lock => return self.update(Message::Lock), - UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => {} + UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste => {} } Task::none() } @@ -1613,9 +2006,110 @@ impl App { } PendingAction::ApplyRecipients(form) => self.begin_recipient_workflow(form), PendingAction::CreateEntry(form) => self.begin_create_entry(form), + PendingAction::SearchContents(request) => { + self.begin_search(SearchRequest::Contents(request)) + } + PendingAction::Mutate(form) if self.handle.is_none() => { + self.after_authentication = Some(PendingAction::Mutate(form)); + self.begin_authentication() + } + PendingAction::Mutate(form) => self.begin_mutation(form), } } + fn update_search_form(&mut self, update: impl FnOnce(&mut SearchForm)) { + if let Some(UtilityView::Search(form)) = &mut self.utility + && !form.running + && form.mode == SearchMode::Contents + { + update(form); + form.error = None; + form.results = None; + } + } + + fn begin_search(&mut self, request: SearchRequest) -> Task { + let Some(storage) = self.storage.clone() else { + return Task::none(); + }; + let handle = self.handle.clone(); + if matches!(request, SearchRequest::Contents(_)) && handle.is_none() { + if let SearchRequest::Contents(request) = request { + self.after_authentication = Some(PendingAction::SearchContents(request)); + } + return self.begin_authentication(); + } + self.workflow_generation = self.workflow_generation.wrapping_add(1); + let generation = self.workflow_generation; + if let Some(UtilityView::Search(form)) = &mut self.utility { + form.running = true; + form.error = None; + form.results = None; + } + self.status = match request { + SearchRequest::Names(_) => "Searching entry names…".to_owned(), + SearchRequest::Contents(_) => "Searching decrypted entry contents…".to_owned(), + }; + Task::perform( + async move { + Arc::new(Mutex::new(Some(match request { + SearchRequest::Names(request) => storage + .find(&request) + .map(SearchResults::Names) + .map_err(|error| error.to_string()), + SearchRequest::Contents(request) => storage + .grep_active( + &handle.expect("content search requires authentication"), + &request, + ) + .map(SearchResults::Contents) + .map_err(|error| error.to_string()), + }))) + }, + move |completion| Message::SearchFinished { + generation, + completion, + }, + ) + } + + fn begin_mutation(&mut self, form: MutationForm) -> Task { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { + return Task::none(); + }; + let request = match form.request() { + Ok(request) => request, + Err(error) => { + if let Some(UtilityView::Mutation(form)) = &mut self.utility { + form.error = Some(error); + } + return Task::none(); + } + }; + let overwrite = if form.kind == MutationKind::Delete || form.overwrite { + OverwriteDecision::Allow + } else { + OverwriteDecision::Decline + }; + self.workflow_generation = self.workflow_generation.wrapping_add(1); + let generation = self.workflow_generation; + if let Some(UtilityView::Mutation(current)) = &mut self.utility { + current.running = true; + current.error = None; + } + self.status = "Applying storage-owned tree mutation…".to_owned(); + Task::perform( + async move { + Box::new( + storage + .mutate_active(&handle, &request, overwrite) + .map_err(|error| error.to_string()), + ) + }, + move |result| Message::MutationFinished { generation, result }, + ) + } + fn begin_recipient_workflow(&mut self, form: RecipientForm) -> Task { let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { return Task::none(); @@ -1937,6 +2431,15 @@ impl App { form.running = false; form.error = Some(reason.clone()); } + Some(UtilityView::Search(form)) if form.mode == SearchMode::Contents => { + form.running = false; + form.results = None; + form.error = Some(reason.clone()); + } + Some(UtilityView::Mutation(form)) => { + form.running = false; + form.error = Some(reason.clone()); + } _ => {} } self.authentication = AuthenticationView::Locked; @@ -1984,6 +2487,7 @@ impl App { &self.navigation, &self.tree_state, self.pane_focus == PaneFocus::Sidebar, + self.context_target.as_ref(), ), PaneKind::Content => content_view(self), }) @@ -2328,6 +2832,176 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa content = content.push(text(format!("New entry error: {error}"))); } } + UtilityView::Search(form) => { + let title = match form.mode { + SearchMode::Names => "Find Entries and Folders", + SearchMode::Contents => "Search Decrypted Contents", + }; + content = content.push(text(title).size(28)); + let input = text_input( + if form.mode == SearchMode::Names { + "Name substring" + } else { + "Regular expression" + }, + &form.query, + ) + .on_input(Message::SearchQueryChanged) + .on_submit(Message::SubmitSearch); + content = if form.mode == SearchMode::Names { + let add = button("Add another term"); + content.push( + row![ + input, + if form.running || form.query.is_empty() { + add + } else { + add.on_press(Message::AddSearchTerm) + }, + ] + .spacing(8), + ) + } else { + content.push(input) + }; + if form.mode == SearchMode::Contents { + content = content + .push(text( + "This search authenticates and decrypts through crates/storage; results are removed when the lease locks.", + )) + .push( + row![ + search_option("Ignore case", form.ignore_case, Message::ToggleSearchCase, form.running), + search_option("Invert match", form.invert_match, Message::ToggleSearchInvert, form.running), + search_option("Line numbers", form.line_numbers, Message::ToggleSearchLineNumbers, form.running), + search_option("Fixed strings", form.fixed_strings, Message::ToggleSearchFixedStrings, form.running), + ] + .spacing(8), + ); + } else { + content = content.push(text( + "Name search remains available while locked. Multiple terms use the storage crate's pass-compatible OR matching semantics.", + )); + for (index, term) in form.terms.iter().enumerate() { + let remove = button(text(format!("Remove term · {term}"))); + content = content.push(if form.running { + remove + } else { + remove.on_press(Message::RemoveSearchTerm(index)) + }); + } + } + if let Some(error) = &form.error { + content = content.push(text(format!("Search error: {error}"))); + } + if let Some(results) = &form.results { + content = content.push(text("Results").size(20)); + match results { + SearchResults::Names(results) => { + if results.matches().is_empty() { + content = content.push(text("No matching entries or folders.")); + } + for matched in results.matches() { + let kind = if matched.id().is_directory() { + "Folder" + } else { + "Entry" + }; + content = content.push( + button(text(format!("{kind} · {}", matched.path()))) + .on_press(Message::ActivateSearchResult(matched.id().clone())), + ); + } + } + SearchResults::Contents(results) => { + if results.is_empty() { + content = content.push(text("No decrypted content matches.")); + } + for entry in results.entries() { + content = content.push( + button(text(format!("Entry · {}", entry.path()))).on_press( + Message::ActivateSearchResult(TreeNodeId::Entry( + entry.path().clone(), + )), + ), + ); + for line in entry.lines() { + let prefix = if results.includes_line_numbers() { + format!("{}: ", line.number()) + } else { + String::new() + }; + content = content.push(text(format!( + " {prefix}{}", + String::from_utf8_lossy(line.contents().expose()) + ))); + } + } + } + } + } + } + UtilityView::Mutation(form) => { + let title = match form.kind { + MutationKind::Move => "Move or Rename", + MutationKind::Copy => "Copy Entry or Folder", + MutationKind::Delete => "Delete Entry or Folder", + }; + content = content + .push(text(title).size(28)) + .push(text(format!("Source: {}", form.source.path().display()))); + if form.kind != MutationKind::Delete { + content = content + .push(text("Destination entry path or existing folder")) + .push( + text_input("folder/name", &form.destination) + .on_input(Message::MutationDestinationChanged) + .on_submit(Message::SubmitMutation), + ) + .push(text("Choose an existing destination folder")); + let mut destinations = row![].spacing(6); + for directory in app.navigation.directories() { + let path = directory.path().display().to_string(); + let label = if path.is_empty() { "Store root" } else { &path }; + let choice = button(text(label.to_owned())); + destinations = destinations.push(if form.running { + choice + } else { + choice.on_press(Message::SelectMutationDestination(path)) + }); + } + let overwrite = button(text(format!( + "[{}] Replace an existing destination entry", + if form.overwrite { "x" } else { " " } + ))); + content = content.push(destinations.wrap()).push(if form.running { + overwrite + } else { + overwrite.on_press(Message::ToggleMutationOverwrite) + }); + } else { + let confirmation = button(text(format!( + "[{}] Permanently remove this {} and commit the deletion", + if form.confirmed { "x" } else { " " }, + if form.source.is_directory() { + "folder" + } else { + "entry" + } + ))); + content = content.push(if form.running { + confirmation + } else { + confirmation.on_press(Message::ToggleMutationConfirmation) + }); + } + content = content.push(text( + "Validation, collisions, recipient-aware re-encryption, rollback, filesystem mutation, and Git commits are owned by crates/storage.", + )); + if let Some(error) = &form.error { + content = content.push(text(format!("Mutation error: {error}"))); + } + } UtilityView::Help => { content = content .push(text("IronStorage Help").size(28)) @@ -2422,6 +3096,42 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa ] .spacing(8) } + UtilityView::Search(form) => { + let submit = button(if form.running { + "Searching…" + } else { + "Search" + }); + row![ + if form.running { + submit + } else { + submit.on_press(Message::SubmitSearch) + }, + done, + ] + .spacing(8) + } + UtilityView::Mutation(form) => { + let submit = button(if form.running { + "Applying…" + } else { + match form.kind { + MutationKind::Move => "Move", + MutationKind::Copy => "Copy", + MutationKind::Delete => "Delete", + } + }); + row![ + if form.running { + submit + } else { + submit.on_press(Message::SubmitMutation) + }, + done, + ] + .spacing(8) + } UtilityView::About | UtilityView::Help => row![done], }; container( @@ -2434,10 +3144,28 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa .into() } +fn search_option( + label: &'static str, + selected: bool, + message: Message, + running: bool, +) -> iced::widget::Button<'static, Message> { + let option = button(text(format!( + "[{}] {label}", + if selected { "x" } else { " " } + ))); + if running { + option + } else { + option.on_press(message) + } +} + fn sidebar_view<'a>( navigation: &'a NavigationTree, state: &'a TreeState, focused: bool, + context_target: Option<&'a TreeNodeId>, ) -> Element<'a, Message> { let mut rows = column![ row![ @@ -2449,6 +3177,7 @@ fn sidebar_view<'a>( .size(20), button("Initialize…").on_press(Message::Action(UiAction::InitializeStore)), button("New Folder…").on_press(Message::Action(UiAction::NewFolder)), + button("Find…").on_press(Message::Action(UiAction::Find)), button("Refresh").on_press(Message::Action(UiAction::Refresh)), ] .spacing(8) @@ -2492,6 +3221,7 @@ fn sidebar_view<'a>( format!(" · {}", flags.join(", ")) }; let selected = selected == Some(&node.id); + let id = node.id.clone(); let item = button( row![ container(text("")).width(Length::Fixed((node.depth * 16) as f32)), @@ -2501,13 +3231,32 @@ fn sidebar_view<'a>( .spacing(5), ) .width(Length::Fill) - .on_press(Message::SidebarActivate(node.id)) + .on_press(Message::SidebarActivate(id.clone())) .style(if selected { button::primary } else { button::text }); - rows = rows.push(item); + rows = rows.push(mouse_area(item).on_right_press(Message::SidebarContext(id.clone()))); + if context_target == Some(&id) { + rows = rows.push( + row![ + container(text("")).width(Length::Fixed(((node.depth + 1) * 16) as f32)), + text("Actions:"), + button("Move…").on_press(Message::SidebarContextAction( + id.clone(), + UiAction::MoveEntry, + )), + button("Copy…").on_press(Message::SidebarContextAction( + id.clone(), + UiAction::CopyEntry, + )), + button("Delete…") + .on_press(Message::SidebarContextAction(id, UiAction::DeleteEntry,)), + ] + .spacing(5), + ); + } } scrollable(rows) @@ -2805,6 +3554,18 @@ fn confirmation_view(action: &PendingAction) -> Element<'_, Message> { RecipientWorkflowKind::NewFolder => format!("Create folder {}", form.path), }, PendingAction::CreateEntry(form) => format!("Create entry {}", form.path), + PendingAction::SearchContents(request) => { + format!("Search decrypted contents for {}", request.pattern) + } + PendingAction::Mutate(form) => format!( + "{} {}", + match form.kind { + MutationKind::Move => "Move", + MutationKind::Copy => "Copy", + MutationKind::Delete => "Delete", + }, + form.source.path().display() + ), }; container( column![ @@ -3181,6 +3942,7 @@ mod tests { vault_generation: 0, settings_generation: 0, workflow_generation: 0, + selection_after_refresh: None, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, @@ -3203,6 +3965,7 @@ mod tests { status: String::new(), open_menu: None, utility: None, + context_target: None, palette: CommandPalette::default(), #[cfg(target_os = "macos")] native_menu: None, @@ -3476,7 +4239,7 @@ mod tests { assert_eq!(app.palette.selected_action(), Some(UiAction::About)); let _task = app.update(Message::SidebarNavigate(NavigationKey::Activate)); assert!(!app.palette.is_open()); - assert_eq!(app.utility, Some(UtilityView::About)); + assert!(matches!(app.utility, Some(UtilityView::About))); assert_eq!(app.pane_focus, PaneFocus::Content); app.utility = None; @@ -3941,4 +4704,235 @@ mod tests { generate.length = "16".to_owned(); assert_eq!(generate.generate().expect("replacement").expose().len(), 16); } + + #[test] + fn desktop_search_and_mutations_use_typed_storage_contracts_and_clean_git_commits() { + let (_temporary, storage) = fixture_storage(); + let repository = Repository::open(storage.vault()).expect("repository"); + GitRepository::init(&repository, GitIdentity::ironstorage()).expect("Git init"); + + let mut source = empty_editor(&storage, "team/alpha"); + source.add_after(None).expect("password field"); + let password = source.fields()[0].id(); + source + .update_raw(password, b"needle-secret") + .expect("password value"); + save_document(&storage, &source).expect("source save"); + let commits_before_mutations = GitRepository::open(&repository, GitIdentity::ironstorage()) + .expect("Git after source save") + .log(None) + .expect("initial Git log") + .len(); + + let names = storage + .find(&FindRequest { + terms: vec!["alpha".to_owned()], + }) + .expect("name search"); + assert_eq!(names.matches().len(), 1); + assert_eq!( + names.matches()[0].id(), + &TreeNodeId::Entry(EntryPath::parse("team/alpha").expect("entry identity")) + ); + + let contents = storage + .grep( + &GrepRequest { + pattern: "needle-secret".to_owned(), + ignore_case: false, + invert_match: false, + line_number: true, + fixed_strings: true, + }, + &mut FixtureSecrets, + ) + .expect("decrypted search"); + assert_eq!(contents.entries().len(), 1); + assert_eq!(contents.entries()[0].path().to_string(), "team/alpha"); + assert_eq!(contents.entries()[0].lines()[0].number(), 1); + assert_eq!( + contents.entries()[0].lines()[0].contents().expose(), + b"needle-secret" + ); + + let copied = storage + .mutate( + &DesktopMutationRequest::Copy(CopyRequest { + source: "team/alpha".to_owned(), + destination: "team/beta".to_owned(), + force: false, + }), + OverwriteDecision::Decline, + &mut FixtureSecrets, + ) + .expect("copy"); + assert_eq!(copied.action(), MutationAction::Copy); + assert_eq!( + copied.selection(), + Some(&MutationSelection::Entry( + EntryPath::parse("team/beta").expect("copy selection") + )) + ); + assert!( + storage + .mutate( + &DesktopMutationRequest::Copy(CopyRequest { + source: "team/alpha".to_owned(), + destination: "team/beta".to_owned(), + force: false, + }), + OverwriteDecision::Decline, + &mut FixtureSecrets, + ) + .is_err() + ); + + let moved = storage + .mutate( + &DesktopMutationRequest::Move(MoveRequest { + source: "team/beta".to_owned(), + destination: "team/gamma".to_owned(), + force: false, + }), + OverwriteDecision::Decline, + &mut FixtureSecrets, + ) + .expect("move"); + assert_eq!(moved.action(), MutationAction::Move); + storage + .mutate( + &DesktopMutationRequest::Remove(RemoveRequest { + entry: "team/alpha".to_owned(), + recursive: false, + force: false, + }), + OverwriteDecision::Allow, + &mut FixtureSecrets, + ) + .expect("remove"); + assert!(!storage.vault().join("team/alpha.gpg").exists()); + assert!(!storage.vault().join("team/beta.gpg").exists()); + assert_eq!( + storage + .open_document("team/gamma", &mut FixtureSecrets) + .expect("moved document") + .serialize() + .expose(), + b"needle-secret" + ); + let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("Git reopen"); + assert!(git.status().expect("Git status").is_clean()); + assert_eq!( + git.log(None).expect("Git log").len(), + commits_before_mutations + 3 + ); + } + + #[test] + fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() { + let (_temporary, storage) = fixture_storage(); + let mut editor = empty_editor(&storage, "draft"); + editor.add_after(None).expect("dirty field"); + let draft = editor.document().serialize().expose().to_vec(); + let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft identity")); + let mut app = test_app(Some(editor)); + app.storage = Some(storage.clone()); + app.navigation + .replace_test_nodes(vec![navigation::TestNode { + id: draft_id.clone(), + name: "draft".to_owned(), + children: Vec::new(), + }]); + assert!(app.navigation.select_id(&draft_id)); + + let mut delete = MutationForm::new(MutationKind::Delete, draft_id.clone()); + assert!(delete.request().is_err()); + delete.confirmed = true; + assert!(matches!( + delete.request(), + Ok(DesktopMutationRequest::Remove(RemoveRequest { + recursive: false, + .. + })) + )); + app.utility = Some(UtilityView::Mutation(delete)); + let _task = app.update(Message::SubmitMutation); + assert!(matches!(app.confirmation, Some(PendingAction::Mutate(_)))); + let _task = app.update(Message::CancelDiscard); + assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty)); + assert_eq!( + app.editor + .as_ref() + .expect("dirty editor") + .document() + .serialize() + .expose(), + draft + ); + + app.workflow_generation = 9; + let _task = app.update(Message::MutationFinished { + generation: 9, + result: Box::new(Err("injected mutation failure".to_owned())), + }); + assert_eq!(app.navigation.selected(), Some(&draft_id)); + assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty)); + assert!(matches!( + app.utility, + Some(UtilityView::Mutation(MutationForm { error: Some(_), .. })) + )); + let _task = app.update(Message::DismissUtility); + assert!(app.utility.is_none()); + assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty)); + + let mut search = SearchForm::new(SearchMode::Contents); + search.query = "needle".to_owned(); + search.ignore_case = true; + search.invert_match = true; + search.line_numbers = false; + search.fixed_strings = true; + assert_eq!( + search.request().expect("content request"), + SearchRequest::Contents(GrepRequest { + pattern: "needle".to_owned(), + ignore_case: true, + invert_match: true, + line_number: false, + fixed_strings: true, + }) + ); + let mut names = SearchForm::new(SearchMode::Names); + names.terms.push("first term".to_owned()); + names.query = "second term".to_owned(); + assert_eq!( + names.request().expect("name request"), + SearchRequest::Names(FindRequest { + terms: vec!["first term".to_owned(), "second term".to_owned()], + }) + ); + search.results = Some(SearchResults::Contents( + storage + .grep( + &GrepRequest { + pattern: ".*".to_owned(), + ignore_case: false, + invert_match: false, + line_number: true, + fixed_strings: false, + }, + &mut FixtureSecrets, + ) + .expect("search results"), + )); + app.utility = Some(UtilityView::Search(search)); + app.authentication_lost("locked".to_owned()); + assert!(matches!( + app.utility, + Some(UtilityView::Search(SearchForm { + results: None, + error: Some(_), + .. + })) + )); + } } diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs index 99b4044..df71190 100644 --- a/apps/desktop/src/native_menu.rs +++ b/apps/desktop/src/native_menu.rs @@ -46,6 +46,7 @@ impl NativeMenu { submenu.append(&PredefinedMenuItem::select_all(None))?; submenu.append(&PredefinedMenuItem::separator())?; append_action(&submenu, UiAction::Find, context, &mut items)?; + append_action(&submenu, UiAction::SearchContents, context, &mut items)?; } _ => append_actions(&submenu, group, context, &mut items)?, } @@ -125,6 +126,7 @@ fn accelerator(action: UiAction) -> Option { UiAction::Cut => (command, Code::KeyX), UiAction::Paste => (command, Code::KeyV), UiAction::Find => (command, Code::KeyF), + UiAction::SearchContents => (command | Modifiers::SHIFT, Code::KeyF), UiAction::CommandPalette => (command, Code::KeyK), UiAction::Refresh => (command, Code::KeyR), UiAction::Lock => (command, Code::KeyL), @@ -139,6 +141,9 @@ fn accelerator(action: UiAction) -> Option { | UiAction::ReloadEntry | UiAction::EditEntry | UiAction::GeneratePassword + | UiAction::MoveEntry + | UiAction::CopyEntry + | UiAction::DeleteEntry | UiAction::ToggleReveal | UiAction::Minimize => return None, }; diff --git a/apps/desktop/src/navigation.rs b/apps/desktop/src/navigation.rs index 7b57657..781ec77 100644 --- a/apps/desktop/src/navigation.rs +++ b/apps/desktop/src/navigation.rs @@ -2,7 +2,10 @@ use std::{collections::BTreeSet, path::Path}; -use ironstorage::read::{TreeModel, TreeNode, TreeNodeId, TreeNodeIndicators, TreeNodeKind}; +use ironstorage::{ + read::{TreeModel, TreeNode, TreeNodeId, TreeNodeIndicators, TreeNodeKind}, + repository::DirectoryPath, +}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NavigationKey { @@ -86,6 +89,20 @@ impl NavigationTree { rows } + pub fn directories(&self) -> Vec { + fn visit(nodes: &[NavigationNode], directories: &mut Vec) { + for node in nodes { + if node.id.is_directory() { + directories.push(node.id.clone()); + } + visit(&node.children, directories); + } + } + let mut directories = vec![TreeNodeId::Directory(DirectoryPath::root())]; + visit(&self.nodes, &mut directories); + directories + } + pub fn selected_ratio(&self) -> f32 { let rows = self.rows(); let Some(index) = selected_index(&rows, self.selected.as_ref()) else { @@ -131,6 +148,19 @@ impl NavigationTree { true } + pub fn select_id(&mut self, id: &TreeNodeId) -> bool { + let mut lineage = Vec::new(); + if !find_id_lineage(&self.nodes, id, &mut lineage) { + return false; + } + let Some(selected) = lineage.pop() else { + 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; @@ -274,6 +304,21 @@ fn find_lineage(nodes: &[NavigationNode], path: &str, lineage: &mut Vec, +) -> bool { + for node in nodes { + lineage.push(node.id.clone()); + if &node.id == target || find_id_lineage(&node.children, target, lineage) { + return true; + } + lineage.pop(); + } + false +} + #[cfg(test)] pub(crate) struct TestNode { pub id: TreeNodeId, @@ -365,6 +410,24 @@ mod tests { assert_eq!(tree.rows().len(), 2); } + #[test] + fn typed_search_and_mutation_identities_expand_and_select_hidden_nodes() { + let mut tree = NavigationTree::default(); + tree.replace_test_nodes(populated()); + let hidden = TreeNodeId::Entry(EntryPath::parse("personal/email").expect("entry")); + assert!(tree.select_id(&hidden)); + assert_eq!(tree.selected(), Some(&hidden)); + assert!(tree.rows().iter().any(|row| row.id == hidden)); + assert_eq!( + tree.directories(), + vec![ + TreeNodeId::Directory(DirectoryPath::root()), + TreeNodeId::Directory(DirectoryPath::parse("personal").expect("personal")), + TreeNodeId::Directory(DirectoryPath::parse("work").expect("work")), + ] + ); + } + #[test] fn refresh_preserves_valid_typed_identity_and_clears_removed_state() { let mut tree = NavigationTree::default(); diff --git a/apps/desktop/src/palette.rs b/apps/desktop/src/palette.rs index 84d7359..6fbf4a4 100644 --- a/apps/desktop/src/palette.rs +++ b/apps/desktop/src/palette.rs @@ -166,6 +166,13 @@ mod tests { matches("generate").first(), Some(&UiAction::GeneratePassword) ); + assert_eq!(matches("grep").first(), Some(&UiAction::SearchContents)); + assert_eq!(matches("rename").first(), Some(&UiAction::MoveEntry)); + assert_eq!( + matches("duplicate entry").first(), + Some(&UiAction::CopyEntry) + ); + assert_eq!(matches("rm").first(), Some(&UiAction::DeleteEntry)); assert_eq!( matches(""), action::ACTIONS diff --git a/crates/storage/src/desktop.rs b/crates/storage/src/desktop.rs index b863e7f..6772c35 100644 --- a/crates/storage/src/desktop.rs +++ b/crates/storage/src/desktop.rs @@ -6,20 +6,21 @@ use crate::{ authentication::{ AuthenticationTimeout, NativeAuthenticationHandle, NativeAuthenticationSession, }, - command::InitRequest, + command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest}, config::{Config, ConfigSettings, EditorCommand}, crypto::{KeyInfo, KeyStore, SecretProvider}, document::{DocumentError, EntryDocument, EntryDocumentService}, - git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, GitIdentity}, + git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity}, + mutation::{MutationOutcome, TreeMutator}, presentation::ClipboardTimeout, - read::{TreeModel, VaultReader}, + read::{FindResults, GrepResults, TreeModel, VaultReader}, recipient::{ PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager, RecipientPolicyOutcome, }, repository::{DirectoryPath, Repository}, secret_store::SecretProtectionPolicy, - write::{VaultWriter, WriteError, WriteOutcome}, + write::{OverwriteDecision, VaultWriter, WriteError, WriteOutcome}, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -35,6 +36,7 @@ pub enum DesktopErrorKind { Unchanged, MissingDefaultKey, EntryExists, + Mutation, } #[derive(Debug)] @@ -80,6 +82,23 @@ pub struct DesktopStorage { config: Config, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DesktopMutationRequest { + Remove(RemoveRequest), + Move(MoveRequest), + Copy(CopyRequest), +} + +impl DesktopMutationRequest { + pub fn source(&self) -> &str { + match self { + Self::Remove(request) => &request.entry, + Self::Move(request) => &request.source, + Self::Copy(request) => &request.source, + } + } +} + impl DesktopStorage { pub fn load(explicit: Option<&Path>) -> Result { Config::load(explicit) @@ -199,6 +218,80 @@ impl DesktopStorage { .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) } + pub fn find(&self, request: &FindRequest) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + VaultReader::new(&repository, &keys) + .find(&request.terms) + .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) + } + + pub fn grep_active( + &self, + handle: &NativeAuthenticationHandle, + request: &GrepRequest, + ) -> Result { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let mut provider = handle.clone(); + self.grep(request, &mut provider) + } + + pub fn grep( + &self, + request: &GrepRequest, + provider: &mut impl SecretProvider, + ) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + VaultReader::new(&repository, &keys) + .grep(request, provider) + .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) + } + + pub fn mutate_active( + &self, + handle: &NativeAuthenticationHandle, + request: &DesktopMutationRequest, + overwrite: OverwriteDecision, + ) -> Result { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + let mut provider = handle.clone(); + self.mutate(request, overwrite, &mut provider) + } + + pub fn mutate( + &self, + request: &DesktopMutationRequest, + overwrite: OverwriteDecision, + provider: &mut impl SecretProvider, + ) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + let mut committer = AutomaticTreeCommitter::for_source( + &repository, + request.source(), + GitIdentity::ironstorage(), + ) + .map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?; + let mutator = TreeMutator::new(&repository, &keys); + match request { + DesktopMutationRequest::Remove(request) => { + mutator.remove(request, overwrite, &mut committer) + } + DesktopMutationRequest::Move(request) => { + mutator.move_tree(request, overwrite, None, provider, &mut committer) + } + DesktopMutationRequest::Copy(request) => { + mutator.copy(request, overwrite, None, provider, &mut committer) + } + } + .map_err(|error| DesktopError::new(DesktopErrorKind::Mutation, error)) + } + /// Enumerate storage-validated encryption keys for recipient selection. pub fn key_infos(&self) -> Result, DesktopError> { Ok(self.keys()?.infos().filter(KeyInfo::can_encrypt).collect()) diff --git a/crates/storage/src/read.rs b/crates/storage/src/read.rs index 5cc9168..399c499 100644 --- a/crates/storage/src/read.rs +++ b/crates/storage/src/read.rs @@ -229,17 +229,23 @@ impl fmt::Debug for ShowResult { #[derive(Clone, Debug, Eq, PartialEq)] pub struct NameMatch { - path: String, - kind: TreeNodeKind, + id: TreeNodeId, } impl NameMatch { pub fn path(&self) -> &str { - &self.path + self.id + .path() + .to_str() + .expect("search construction rejects non-UTF-8 paths") } pub fn kind(&self) -> TreeNodeKind { - self.kind + self.id.kind() + } + + pub fn id(&self) -> &TreeNodeId { + &self.id } } @@ -489,8 +495,7 @@ impl<'a> VaultReader<'a> { let name = display_name(directory.path().as_path())?; if folded.iter().any(|term| name.to_lowercase().contains(term)) { matches.push(NameMatch { - path: path_text(directory.path().as_path())?, - kind: TreeNodeKind::Directory, + id: TreeNodeId::Directory(directory.path().clone()), }); } } @@ -501,16 +506,12 @@ impl<'a> VaultReader<'a> { let name = display_name(entry.path().as_path())?; if folded.iter().any(|term| name.to_lowercase().contains(term)) { matches.push(NameMatch { - path: path_text(entry.path().as_path())?, - kind: TreeNodeKind::Entry, + id: TreeNodeId::Entry(entry.path().clone()), }); } } - matches.sort_by(|left, right| left.path.cmp(&right.path)); - let included = matches - .iter() - .map(|matched| matched.path.as_str()) - .collect::>(); + matches.sort_by(|left, right| left.path().cmp(right.path())); + let included = matches.iter().map(NameMatch::path).collect::>(); let tree = build_tree(&snapshot, &DirectoryPath::root(), Some(included.as_slice()))?; Ok(FindResults { terms: terms.to_vec(), diff --git a/crates/storage/tests/read_domains.rs b/crates/storage/tests/read_domains.rs index 76c72a8..79b5432 100644 --- a/crates/storage/tests/read_domains.rs +++ b/crates/storage/tests/read_domains.rs @@ -194,6 +194,10 @@ fn find_matches_entry_and_directory_names_case_insensitively() -> TestResult { ); let personal = reader.find(&["PERSONAL".to_owned()])?; assert_eq!(personal.matches()[0].path(), "email/personal"); + assert_eq!( + personal.matches()[0].id(), + &ironstorage::read::TreeNodeId::Entry(EntryPath::parse("email/personal")?) + ); assert!(matches!( reader.find(&[]), Err(ReadError::MissingSearchTerms)