From aeb18b488f3e5d71b2ebefae6aa6477835acadc7 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 10 Aug 2026 22:06:16 +0200 Subject: [PATCH] Revamp desktop usability and multiline fields --- apps/cli/src/main.rs | 51 +- apps/desktop/src/action.rs | 52 +- apps/desktop/src/editor.rs | 100 +- apps/desktop/src/main.rs | 1103 ++++++++++++++++------- apps/desktop/src/native_menu.rs | 34 +- apps/desktop/src/navigation.rs | 15 +- apps/tui/src/editor.rs | 46 +- apps/tui/src/ui.rs | 233 +++-- crates/storage/src/desktop.rs | 77 +- crates/storage/src/document.rs | 48 +- crates/storage/tests/entry_documents.rs | 42 +- docs/desktop-audit.md | 27 +- 12 files changed, 1283 insertions(+), 545 deletions(-) diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 11f43bc..0d18cd6 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -1534,7 +1534,7 @@ mod tests { RemoveRequest, ShowRequest, }, config::Config, - crypto::KeyInfo, + crypto::{KeyInfo, KeyStore}, git::{GitCredentialProvider as _, GitIdentity, GitRepository}, otp::OtpInput, presentation::{ClipboardTimeout, QrMatrix}, @@ -1903,6 +1903,55 @@ mod tests { Ok(()) } + #[test] + fn cli_terminal_show_preserves_multiline_field_bytes() -> TestResult { + const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; + let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/storage/tests/fixtures/compatibility"); + let temporary = tempfile::tempdir()?; + let vault = temporary.path().join("vault"); + fs::create_dir(&vault)?; + fs::write(vault.join(".gpg-id"), format!("{FINGERPRINT}\n"))?; + let repository = Repository::open(&vault)?; + let keys = KeyStore::load(fixtures.join("keys"))?; + let recipients = keys.resolve_recipients(format!("{FINGERPRINT}\n").as_bytes())?; + let plaintext = + b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n"; + let ciphertext = keys.encrypt(SecretBytes::new(plaintext.to_vec()), &recipients)?; + repository.write_entry(&EntryPath::parse("documents/multiline")?, &ciphertext)?; + let config_path = temporary.path().join("config.toml"); + fs::write( + &config_path, + format!( + "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n", + vault, + FINGERPRINT, + fixtures.join("keys"), + ), + )?; + let config = Config::load(Some(&config_path))?; + let mut secrets = fixture_secrets(FINGERPRINT)?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + execute_secure( + &config, + &CommandRequest::Show(ShowRequest { + entry: Some("documents/multiline".to_owned()), + presentation: Presentation::Terminal, + }), + &mut secrets, + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + assert_eq!(stdout, plaintext); + assert!(stderr.is_empty()); + Ok(()) + } + #[test] fn cli_prompts_for_a_missing_openpgp_passphrase_then_stores_and_reuses_it() -> TestResult { const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; diff --git a/apps/desktop/src/action.rs b/apps/desktop/src/action.rs index 3659f39..ddc878f 100644 --- a/apps/desktop/src/action.rs +++ b/apps/desktop/src/action.rs @@ -57,7 +57,6 @@ pub enum UiAction { MoveEntry, CopyEntry, DeleteEntry, - ToggleReveal, GenerateOtp, CopyOtp, ImportOtp, @@ -104,7 +103,6 @@ impl UiAction { Self::MoveEntry => "move-entry", Self::CopyEntry => "copy-entry", Self::DeleteEntry => "delete-entry", - Self::ToggleReveal => "toggle-reveal", Self::GenerateOtp => "generate-otp", Self::CopyOtp => "copy-otp", Self::ImportOtp => "import-otp", @@ -173,7 +171,6 @@ pub struct ActionContext { pub switching_vault: bool, pub modal_open: bool, pub focused_field: bool, - pub focused_sensitive: bool, pub focused_generatable: bool, pub focused_otp: bool, pub entry_path: bool, @@ -198,7 +195,12 @@ pub const ACTIONS: &[ActionSpec] = &[ "Initialize Store…", None, ), - spec(UiAction::NewFolder, MenuGroup::File, "New Folder…", None), + spec( + UiAction::NewFolder, + MenuGroup::File, + "New Folder…", + Some("⇧⌘N"), + ), spec( UiAction::Quit, MenuGroup::App, @@ -223,12 +225,17 @@ pub const ACTIONS: &[ActionSpec] = &[ spec(UiAction::Undo, MenuGroup::Edit, "Undo", Some("⌘Z")), spec(UiAction::Redo, MenuGroup::Edit, "Redo", Some("⇧⌘Z")), spec(UiAction::Cut, MenuGroup::Edit, "Cut", Some("⌘X")), - spec(UiAction::CopyField, MenuGroup::Entry, "Copy Field", None), + spec( + UiAction::CopyField, + MenuGroup::Entry, + "Copy Field", + Some("⌘C"), + ), spec( UiAction::CopyEditedField, MenuGroup::Entry, "Copy Edited Field", - None, + Some("⌘C"), ), spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")), spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")), @@ -255,9 +262,14 @@ pub const ACTIONS: &[ActionSpec] = &[ UiAction::ReloadEntry, MenuGroup::Entry, "Reload Entry", - None, + Some("⇧⌘R"), + ), + spec( + UiAction::EditEntry, + MenuGroup::Entry, + "Edit Entry", + Some("⌘E"), ), - spec(UiAction::EditEntry, MenuGroup::Entry, "Edit Entry", None), spec( UiAction::GeneratePassword, MenuGroup::Entry, @@ -272,12 +284,6 @@ pub const ACTIONS: &[ActionSpec] = &[ ), spec(UiAction::CopyEntry, MenuGroup::Entry, "Copy Entry…", None), spec(UiAction::DeleteEntry, MenuGroup::Entry, "Delete…", None), - spec( - UiAction::ToggleReveal, - MenuGroup::Entry, - "Reveal or Hide Field", - None, - ), spec( UiAction::GenerateOtp, MenuGroup::Entry, @@ -434,12 +440,6 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool { && !context.switching_vault && !context.modal_open } - UiAction::ToggleReveal => { - context.unlocked - && context.document_open - && !context.switching_vault - && context.focused_sensitive - } UiAction::GenerateOtp | UiAction::CopyOtp | UiAction::ShowOtpUri @@ -577,10 +577,6 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta 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", - UiAction::ToggleReveal => "Wait for vault validation", UiAction::GenerateOtp | UiAction::CopyOtp | UiAction::ShowOtpUri @@ -696,7 +692,6 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] { 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::GenerateOtp => &["totp", "hotp", "one time password"], UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"], UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"], @@ -734,6 +729,7 @@ pub fn shortcut_action_for( } match key.as_ref() { keyboard::Key::Character(",") => Some(UiAction::Settings), + keyboard::Key::Character("n" | "N") if modifiers.shift() => Some(UiAction::NewFolder), keyboard::Key::Character("n" | "N") => Some(UiAction::NewEntry), keyboard::Key::Character("o" | "O") => Some(UiAction::OpenFolder), keyboard::Key::Character("s" | "S") => Some(UiAction::Save), @@ -747,7 +743,9 @@ pub fn shortcut_action_for( 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") if modifiers.shift() => Some(UiAction::ReloadEntry), keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh), + keyboard::Key::Character("e" | "E") => Some(UiAction::EditEntry), keyboard::Key::Character("l" | "L") => Some(UiAction::Lock), keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize), _ => None, @@ -772,7 +770,6 @@ mod tests { switching_vault: false, modal_open: false, focused_field: true, - focused_sensitive: true, focused_generatable: true, focused_otp: true, entry_path: true, @@ -804,7 +801,6 @@ mod tests { assert!(enabled(UiAction::CopyEditedField, ready)); assert!(enabled(UiAction::GeneratePassword, ready)); assert!(!enabled(UiAction::CopyField, ready)); - assert!(enabled(UiAction::ToggleReveal, ready)); for action in [ UiAction::GitStatus, UiAction::GitPull, @@ -844,7 +840,6 @@ mod tests { UiAction::Save, UiAction::CopyField, UiAction::CopyEditedField, - UiAction::ToggleReveal, UiAction::GeneratePassword, UiAction::GenerateOtp, UiAction::CopyOtp, @@ -908,7 +903,6 @@ mod tests { UiAction::ReloadEntry, UiAction::EditEntry, UiAction::GeneratePassword, - UiAction::ToggleReveal, UiAction::Find, UiAction::SearchContents, UiAction::MoveEntry, diff --git a/apps/desktop/src/editor.rs b/apps/desktop/src/editor.rs index 289b5a8..b0f6a7f 100644 --- a/apps/desktop/src/editor.rs +++ b/apps/desktop/src/editor.rs @@ -1,17 +1,14 @@ //! Structured desktop editing state over storage-owned entry documents. -use std::{collections::BTreeSet, fmt}; +use std::fmt; use ironstorage::{ - document::{ - DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId, EntrySensitivity, - }, + document::{DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId}, repository::SecretBytes, }; pub struct EntryEditor { document: EntryDocument, - revealed: BTreeSet, focused: Option, dirty: bool, } @@ -29,7 +26,6 @@ impl EntryEditor { let focused = document.fields().first().map(EntryField::id); Self { document, - revealed: BTreeSet::new(), focused, dirty: false, } @@ -66,10 +62,6 @@ impl EntryEditor { self.dirty } - pub fn is_revealed(&self, id: EntryFieldId) -> bool { - self.revealed.contains(&id) - } - pub fn focused(&self) -> Option { self.focused } @@ -108,20 +100,7 @@ impl EntryEditor { self.focused = Some(fields[index].id()); } - pub fn toggle_reveal(&mut self, id: EntryFieldId) -> Result<(), DocumentError> { - let field = self - .document - .field(id) - .ok_or(DocumentError::UnknownField { id })?; - if field.metadata().sensitivity() != EntrySensitivity::Sensitive { - return Ok(()); - } - if !self.revealed.remove(&id) { - self.revealed.insert(id); - } - Ok(()) - } - + #[cfg(test)] pub fn update_raw(&mut self, id: EntryFieldId, value: &[u8]) -> Result<(), DocumentError> { let unchanged = self .document @@ -131,7 +110,44 @@ impl EntryEditor { return Ok(()); } self.document - .update(id, EntryFieldDraft::line(value.to_vec())?)?; + .update(id, EntryFieldDraft::multiline(value.to_vec()))?; + self.dirty = true; + Ok(()) + } + + pub fn update_value_line( + &mut self, + id: EntryFieldId, + line: usize, + replacement: &[u8], + ) -> Result<(), DocumentError> { + let field = self + .document + .field(id) + .ok_or(DocumentError::UnknownField { id })?; + let mut value = field.value().to_vec(); + let range = + value_line_range(&value, line).ok_or(DocumentError::InvalidIndex { index: line })?; + value.splice(range, replacement.iter().copied()); + self.document.replace_field_value(id, value)?; + self.dirty = true; + Ok(()) + } + + pub fn add_value_line_after( + &mut self, + id: EntryFieldId, + line: usize, + ) -> Result<(), DocumentError> { + let field = self + .document + .field(id) + .ok_or(DocumentError::UnknownField { id })?; + let mut value = field.value().to_vec(); + let insertion = + value_line_end(&value, line).ok_or(DocumentError::InvalidIndex { index: line })?; + value.splice(insertion..insertion, *b"\n"); + self.document.replace_field_value(id, value)?; self.dirty = true; Ok(()) } @@ -158,7 +174,6 @@ impl EntryEditor { .position(|field| field.id() == id) .ok_or(DocumentError::UnknownField { id })?; self.document.remove(id)?; - self.revealed.remove(&id); if self.focused == Some(id) { self.focused = self .document @@ -209,7 +224,6 @@ impl EntryEditor { ) -> Result<(), DocumentError> { self.document .replace_field_value(id, value.expose().to_vec())?; - self.revealed.remove(&id); self.dirty = true; Ok(()) } @@ -219,12 +233,42 @@ impl EntryEditor { } } +fn value_line_range(value: &[u8], target: usize) -> Option> { + let mut start = 0; + let mut lines = 0; + for (line, segment) in value.split_inclusive(|byte| *byte == b'\n').enumerate() { + lines = line + 1; + let mut end = start + segment.len() - usize::from(segment.ends_with(b"\n")); + if end > start && value[end - 1] == b'\r' { + end -= 1; + } + if line == target { + return Some(start..end); + } + start += segment.len(); + } + (target == lines && (value.is_empty() || value.ends_with(b"\n"))) + .then_some(value.len()..value.len()) +} + +fn value_line_end(value: &[u8], target: usize) -> Option { + let mut end = 0; + let mut lines = 0; + for (line, segment) in value.split_inclusive(|byte| *byte == b'\n').enumerate() { + lines = line + 1; + end += segment.len(); + if line == target { + return Some(end); + } + } + (target == lines && (value.is_empty() || value.ends_with(b"\n"))).then_some(value.len()) +} + impl fmt::Debug for EntryEditor { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("EntryEditor") .field("document", &self.document) - .field("revealed", &self.revealed) .field("focused", &self.focused) .field("dirty", &self.dirty) .finish() diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index d86d8db..d7d64b8 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -22,10 +22,11 @@ use std::{ use editor::{EntryEditor, FieldNavigation}; use iced::{ - Color, Element, Event, Length, Point, Rectangle, Renderer, Size, Subscription, Task, Theme, - event, keyboard, mouse, time, touch, + Background, Color, Element, Event, Length, Point, Rectangle, Renderer, Size, Subscription, + Task, Theme, event, keyboard, mouse, time, touch, widget::{ - button, canvas, column, container, mouse_area, pane_grid, row, scrollable, text, text_input, + button, canvas, column, container, mouse_area, pane_grid, row, scrollable, text, + text_input, tooltip, }, window, }; @@ -194,7 +195,6 @@ enum Message { SidebarNavigate(NavigationKey), TogglePaneFocus, PaneResized(pane_grid::ResizeEvent), - EntryPathChanged(String), OpenEntry, OpenFinished { generation: u64, @@ -206,13 +206,14 @@ enum Message { result: Result, }, FieldChanged(EntryFieldId, Zeroizing), + FieldLineChanged(EntryFieldId, usize, Zeroizing), + AddFieldLine(EntryFieldId, usize), AddAfter(Option), Remove(EntryFieldId), MoveUp(EntryFieldId), MoveDown(EntryFieldId), BeginEdit, SelectField(EntryFieldId), - ToggleReveal(EntryFieldId), RequestGenerate(EntryFieldId), GenerateLengthChanged(String), ToggleGenerateSymbols, @@ -858,15 +859,15 @@ fn main() -> iced::Result { fn window_settings() -> window::Settings { window::Settings { - size: Size::new(1_080.0, 720.0), - min_size: Some(Size::new(720.0, 480.0)), + size: Size::new(960.0, 680.0), + min_size: Some(Size::new(480.0, 360.0)), ..window::Settings::default() } } impl App { fn new() -> (Self, Task) { - let mut app = Self { + let app = Self { authentication: AuthenticationView::Loading, storage: None, session: None, @@ -911,11 +912,6 @@ impl App { #[cfg(target_os = "macos")] native_menu: None, }; - #[cfg(target_os = "macos")] - match NativeMenu::install(app.action_context()) { - Ok(menu) => app.native_menu = Some(menu), - Err(error) => app.status = format!("Native menu unavailable: {error}"), - } ( app, Task::perform(load_authentication(), |result| { @@ -976,7 +972,6 @@ impl App { self.status = "Wait for the active workflow to finish…".to_owned(); } else { if matches!(&self.utility, Some(UtilityView::Otp(_))) { - self.sensitive.otp = None; self.sensitive.otp_uri = None; self.sensitive.otp_qr = None; self.otp_generation = self.otp_generation.wrapping_add(1); @@ -1805,7 +1800,6 @@ impl App { outcome, }) => { let (payload, matrix) = outcome.into_parts(); - self.sensitive.otp = None; if copy { self.sensitive.otp_uri = None; self.sensitive.otp_qr = None; @@ -1855,6 +1849,19 @@ impl App { } #[cfg(target_os = "macos")] Message::PollNativeMenu => { + if self.native_menu.is_none() { + match NativeMenu::install(self.action_context()) { + Ok(menu) => self.native_menu = Some(menu), + Err(error) => { + self.status = format!("Native menu unavailable: {error}"); + return Task::none(); + } + } + } + let context = self.action_context(); + if let Some(menu) = &self.native_menu { + menu.sync(context); + } if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) { return self.invoke_action(action); } @@ -1972,10 +1979,6 @@ impl App { 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(); @@ -1998,6 +2001,10 @@ impl App { } match result { Ok(document) => { + let generate_totp = document_has_single_totp(&document); + self.sensitive.otp = None; + self.sensitive.otp_uri = None; + self.sensitive.otp_qr = None; self.entry_path = entry.clone(); let _selected = self.navigation.select_entry_path(&entry); self.pane_focus = PaneFocus::Content; @@ -2005,6 +2012,9 @@ impl App { self.content_mode = ContentMode::Viewer; self.conflict = false; self.status = format!("Viewing {entry}"); + if generate_totp { + return self.begin_otp_code_for(entry, false, false); + } } Err(error) => self.status = format!("Open failed: {error}"), } @@ -2038,7 +2048,17 @@ impl App { } Message::FieldChanged(id, value) => { self.pane_focus = PaneFocus::Content; - self.edit(|editor| editor.update_raw(id, value.as_bytes())); + self.edit(|editor| { + editor.replace_value(id, SecretBytes::new(value.as_bytes().to_vec())) + }); + } + Message::FieldLineChanged(id, line, value) => { + self.pane_focus = PaneFocus::Content; + self.edit(|editor| editor.update_value_line(id, line, value.as_bytes())); + } + Message::AddFieldLine(id, line) => { + self.pane_focus = PaneFocus::Content; + self.edit(|editor| editor.add_value_line_after(id, line)); } Message::AddAfter(id) => self.edit(|editor| editor.add_after(id)), Message::Remove(id) => self.edit(|editor| editor.remove(id)), @@ -2057,23 +2077,6 @@ impl App { editor.select(id); } } - Message::ToggleReveal(id) => { - if authentication_allows_content(&self.authentication) - && let Some(editor) = self.editor.as_mut() - { - match editor.toggle_reveal(id) { - Ok(()) => { - editor.select(id); - self.status = if editor.is_revealed(id) { - "Sensitive value revealed by explicit action.".to_owned() - } else { - "Sensitive value hidden.".to_owned() - }; - } - Err(error) => self.status = error.to_string(), - } - } - } Message::RequestGenerate(id) => { let has_value = self .editor @@ -2227,15 +2230,15 @@ impl App { .then(|| display.entry.clone()) }) }); - if refresh_totp.is_some_and(|entry| { - matches!( - &self.utility, - Some(UtilityView::Otp(form)) if form.entry.trim() == entry - ) - }) && !self.otp_pending + if let Some(entry) = refresh_totp + && self + .editor + .as_ref() + .is_some_and(|editor| editor.entry() == entry) + && !self.otp_pending && self.handle.is_some() { - return self.begin_otp_code(false, false); + return self.begin_otp_code_for(entry, false, false); } } Message::Lock => { @@ -2272,8 +2275,6 @@ impl App { || self.generation_form.is_some() || self.utility.is_some(), focused_field: focused.is_some(), - focused_sensitive: focused - .is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive), focused_generatable: focused.is_some_and(|field| { field.metadata().sensitivity() == EntrySensitivity::Sensitive && field.metadata().kind() != EntryFieldKind::OtpUri @@ -2388,11 +2389,6 @@ impl App { }; 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::ImportOtp => { self.sensitive.otp = None; self.sensitive.otp_uri = None; @@ -2419,9 +2415,11 @@ impl App { return Task::none(); }; let entry = editor.entry(); - self.sensitive.otp = None; - self.sensitive.otp_uri = None; - self.sensitive.otp_qr = None; + if matches!(action, UiAction::GenerateOtp | UiAction::CopyOtp) + && otp_kind == OtpKind::Totp + { + return self.begin_otp_code_for(entry, action == UiAction::CopyOtp, false); + } self.utility = Some(UtilityView::Otp(OtpForm::new(entry))); match action { UiAction::GenerateOtp | UiAction::CopyOtp if otp_kind == OtpKind::Hotp => { @@ -2433,9 +2431,7 @@ impl App { "Confirm HOTP generation; storage will commit the advanced counter." .to_owned(); } - UiAction::GenerateOtp | UiAction::CopyOtp => { - return self.begin_otp_code(action == UiAction::CopyOtp, false); - } + UiAction::GenerateOtp | UiAction::CopyOtp => unreachable!(), UiAction::ShowOtpUri => return self.begin_otp_uri(false, false), UiAction::CopyOtpUri => return self.begin_otp_uri(false, true), UiAction::ShowOtpQr => return self.begin_otp_uri(true, false), @@ -2752,13 +2748,22 @@ impl App { } fn begin_otp_code(&mut self, copy: bool, confirm_hotp: bool) -> Task { - let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { - return Task::none(); - }; let entry = match &self.utility { Some(UtilityView::Otp(form)) if !form.running => form.entry.trim().to_owned(), _ => return Task::none(), }; + self.begin_otp_code_for(entry, copy, confirm_hotp) + } + + fn begin_otp_code_for( + &mut self, + entry: String, + copy: bool, + confirm_hotp: bool, + ) -> Task { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { + return Task::none(); + }; let unix_seconds = match current_unix_seconds() { Ok(unix_seconds) => unix_seconds, Err(error) => { @@ -3192,20 +3197,7 @@ impl App { NavigationKey::Next => editor.navigate(FieldNavigation::Next), NavigationKey::First => editor.navigate(FieldNavigation::First), NavigationKey::Last => editor.navigate(FieldNavigation::Last), - NavigationKey::Activate => { - if let Some(id) = editor.focused() - && editor.document().field(id).is_some_and(|field| { - field.metadata().sensitivity() == EntrySensitivity::Sensitive - }) - { - let _ignored = editor.toggle_reveal(id); - self.status = if editor.is_revealed(id) { - "Sensitive value revealed by explicit keyboard action.".to_owned() - } else { - "Sensitive value hidden.".to_owned() - }; - } - } + NavigationKey::Activate => {} NavigationKey::Collapse | NavigationKey::Expand => {} } iced::widget::operation::snap_to( @@ -3311,10 +3303,6 @@ impl App { } fn view(&self) -> Element<'_, Message> { - #[cfg(target_os = "macos")] - if let Some(menu) = &self.native_menu { - menu.sync(self.action_context()); - } if let Some(action) = &self.confirmation { return confirmation_view(action); } @@ -3346,41 +3334,60 @@ impl App { }) }) .spacing(1) - .min_size(220) + .min_size(140) .on_resize(8, Message::PaneResized); - let shortcut = action::shortcut_label(UiAction::CommandPalette) - .expect("the command palette has a registered shortcut"); - let command_input = text_input( - &format!("Search commands ({shortcut})"), - self.palette.query(), - ) - .id(command_palette_input_id()) - .on_input(Message::PaletteQueryChanged) - .width(Length::Fixed(280.0)); - let mut chrome = column![ - row![ - text(authentication), - text( - self.storage - .as_ref() - .map_or("No configured vault".to_owned(), |storage| { - storage.vault().display().to_string() - }) - ) - .size(13), - command_input, - text(&self.status).size(14), - text("Tab changes pane focus").size(12), - ] - .spacing(16) - .padding(10), - platform_menu_bar(self), - ]; + let vault = self.storage.as_ref().map_or_else( + || "No vault".to_owned(), + |storage| { + storage + .vault() + .file_name() + .and_then(|name| name.to_str()) + .map_or_else( + || storage.vault().display().to_string(), + |name| name.to_owned(), + ) + }, + ); + let top_bar = row![ + text(format!("IronStorage · {vault}")) + .size(14) + .width(Length::Fill), + action_icon(Icon::Search, UiAction::Find), + action_icon(Icon::Command, UiAction::CommandPalette), + action_icon(Icon::Folder, UiAction::OpenFolder), + action_icon(Icon::Settings, UiAction::Settings), + action_icon(Icon::Power, UiAction::Lock), + ] + .align_y(iced::Alignment::Center) + .spacing(2) + .padding([5, 8]); + let mut chrome = column![top_bar, platform_menu_bar(self)]; if self.palette.is_open() { - chrome = chrome.push(command_palette_results(self)); + let shortcut = action::shortcut_label(UiAction::CommandPalette) + .expect("the command palette has a registered shortcut"); + chrome = chrome + .push( + text_input( + &format!("Search commands ({shortcut})"), + self.palette.query(), + ) + .id(command_palette_input_id()) + .on_input(Message::PaletteQueryChanged) + .padding(8), + ) + .push(command_palette_results(self)); } - chrome = chrome.push(panes); + chrome = chrome.push(panes).push( + row![ + text(authentication).size(12), + text(&self.status).size(12).width(Length::Fill), + text("Tab: next pane").size(12), + ] + .spacing(10) + .padding([4, 8]), + ); container(chrome.height(Length::Fill)) .width(Length::Fill) @@ -3414,7 +3421,271 @@ fn settings_vault_id() -> iced::widget::Id { } fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id { - format!("desktop-entry-field-{}", id.value()).into() + editor_field_line_input_id(id, 0) +} + +fn editor_field_line_input_id(id: EntryFieldId, line: usize) -> iced::widget::Id { + format!("desktop-entry-field-{}-{line}", id.value()).into() +} + +fn action_hint(action: UiAction) -> String { + let spec = action::spec_for(action); + action::shortcut_label(action).map_or_else( + || spec.label.to_owned(), + |shortcut| format!("{} ({shortcut})", spec.label), + ) +} + +#[derive(Clone, Copy)] +enum Icon { + Add, + Check, + ChevronDown, + ChevronRight, + Command, + Copy, + Delete, + Down, + Edit, + Folder, + FolderAdd, + Generate, + Link, + Key, + Move, + Power, + Qr, + Refresh, + Search, + Settings, + Up, +} + +struct IconCanvas(Icon); + +impl canvas::Program for IconCanvas { + type State = (); + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut frame = canvas::Frame::new(renderer, bounds.size()); + let path = canvas::Path::new(|path| match self.0 { + Icon::Add => { + path.move_to(Point::new(8.0, 2.0)); + path.line_to(Point::new(8.0, 14.0)); + path.move_to(Point::new(2.0, 8.0)); + path.line_to(Point::new(14.0, 8.0)); + } + Icon::Check => { + path.move_to(Point::new(2.0, 8.5)); + path.line_to(Point::new(6.0, 12.0)); + path.line_to(Point::new(14.0, 3.5)); + } + Icon::ChevronDown => { + path.move_to(Point::new(3.0, 5.5)); + path.line_to(Point::new(8.0, 10.5)); + path.line_to(Point::new(13.0, 5.5)); + } + Icon::ChevronRight => { + path.move_to(Point::new(5.5, 3.0)); + path.line_to(Point::new(10.5, 8.0)); + path.line_to(Point::new(5.5, 13.0)); + } + Icon::Command => { + path.move_to(Point::new(2.0, 4.0)); + path.line_to(Point::new(6.0, 8.0)); + path.line_to(Point::new(2.0, 12.0)); + path.move_to(Point::new(8.0, 12.0)); + path.line_to(Point::new(14.0, 12.0)); + } + Icon::Copy => { + path.rectangle(Point::new(2.0, 2.0), Size::new(9.0, 9.0)); + path.rectangle(Point::new(5.0, 5.0), Size::new(9.0, 9.0)); + } + Icon::Delete => { + path.move_to(Point::new(3.0, 4.0)); + path.line_to(Point::new(13.0, 4.0)); + path.move_to(Point::new(6.0, 2.0)); + path.line_to(Point::new(10.0, 2.0)); + path.move_to(Point::new(4.5, 4.0)); + path.line_to(Point::new(5.5, 14.0)); + path.line_to(Point::new(10.5, 14.0)); + path.line_to(Point::new(11.5, 4.0)); + path.move_to(Point::new(7.0, 6.0)); + path.line_to(Point::new(7.0, 12.0)); + path.move_to(Point::new(9.0, 6.0)); + path.line_to(Point::new(9.0, 12.0)); + } + Icon::Down => { + path.move_to(Point::new(8.0, 2.0)); + path.line_to(Point::new(8.0, 12.0)); + path.move_to(Point::new(3.5, 8.0)); + path.line_to(Point::new(8.0, 12.5)); + path.line_to(Point::new(12.5, 8.0)); + } + Icon::Edit => { + path.move_to(Point::new(3.0, 12.5)); + path.line_to(Point::new(4.0, 8.5)); + path.line_to(Point::new(11.0, 1.5)); + path.line_to(Point::new(14.0, 4.5)); + path.line_to(Point::new(7.0, 11.5)); + path.line_to(Point::new(3.0, 12.5)); + path.move_to(Point::new(3.0, 14.0)); + path.line_to(Point::new(13.0, 14.0)); + } + Icon::Folder | Icon::FolderAdd => { + path.move_to(Point::new(1.5, 4.0)); + path.line_to(Point::new(6.0, 4.0)); + path.line_to(Point::new(7.5, 6.0)); + path.line_to(Point::new(14.5, 6.0)); + path.line_to(Point::new(13.0, 13.0)); + path.line_to(Point::new(2.5, 13.0)); + path.line_to(Point::new(1.5, 4.0)); + if matches!(self.0, Icon::FolderAdd) { + path.move_to(Point::new(9.0, 9.5)); + path.line_to(Point::new(13.0, 9.5)); + path.move_to(Point::new(11.0, 7.5)); + path.line_to(Point::new(11.0, 11.5)); + } + } + Icon::Generate => { + path.rounded_rectangle(Point::new(2.0, 2.0), Size::new(12.0, 12.0), 2.0.into()); + for point in [ + (5.0, 5.0), + (11.0, 5.0), + (8.0, 8.0), + (5.0, 11.0), + (11.0, 11.0), + ] { + path.circle(Point::new(point.0, point.1), 0.5); + } + } + Icon::Link => { + path.rounded_rectangle(Point::new(1.5, 5.0), Size::new(8.5, 6.0), 3.0.into()); + path.rounded_rectangle(Point::new(6.0, 5.0), Size::new(8.5, 6.0), 3.0.into()); + } + Icon::Key => { + path.circle(Point::new(5.0, 5.0), 3.5); + path.move_to(Point::new(7.5, 7.5)); + path.line_to(Point::new(14.0, 14.0)); + path.move_to(Point::new(10.5, 10.5)); + path.line_to(Point::new(12.5, 8.5)); + path.move_to(Point::new(12.5, 12.5)); + path.line_to(Point::new(14.0, 11.0)); + } + Icon::Move => { + path.move_to(Point::new(2.0, 13.5)); + path.line_to(Point::new(13.5, 2.0)); + path.move_to(Point::new(7.0, 2.0)); + path.line_to(Point::new(13.5, 2.0)); + path.line_to(Point::new(13.5, 8.5)); + } + Icon::Power => { + path.circle(Point::new(8.0, 8.5), 5.5); + path.move_to(Point::new(8.0, 1.0)); + path.line_to(Point::new(8.0, 8.0)); + } + Icon::Qr => { + for point in [(2.0, 2.0), (9.5, 2.0), (2.0, 9.5)] { + path.rectangle(Point::new(point.0, point.1), Size::new(4.5, 4.5)); + } + path.rectangle(Point::new(10.0, 10.0), Size::new(1.5, 1.5)); + path.rectangle(Point::new(13.0, 10.0), Size::new(1.5, 4.5)); + path.rectangle(Point::new(10.0, 13.0), Size::new(1.5, 1.5)); + } + Icon::Refresh => { + path.move_to(Point::new(13.5, 6.0)); + path.line_to(Point::new(13.5, 2.5)); + path.line_to(Point::new(10.0, 2.5)); + path.move_to(Point::new(13.0, 3.0)); + path.bezier_curve_to( + Point::new(9.0, 0.0), + Point::new(3.0, 2.0), + Point::new(2.5, 7.0), + ); + path.bezier_curve_to( + Point::new(2.0, 12.0), + Point::new(8.0, 16.0), + Point::new(12.5, 12.0), + ); + } + Icon::Search => { + path.circle(Point::new(6.5, 6.5), 4.5); + path.move_to(Point::new(10.0, 10.0)); + path.line_to(Point::new(14.0, 14.0)); + } + Icon::Settings => { + path.circle(Point::new(8.0, 8.0), 3.0); + path.circle(Point::new(8.0, 8.0), 5.5); + for (from, to) in [ + ((8.0, 0.5), (8.0, 2.5)), + ((8.0, 13.5), (8.0, 15.5)), + ((0.5, 8.0), (2.5, 8.0)), + ((13.5, 8.0), (15.5, 8.0)), + ] { + path.move_to(Point::new(from.0, from.1)); + path.line_to(Point::new(to.0, to.1)); + } + } + Icon::Up => { + path.move_to(Point::new(8.0, 14.0)); + path.line_to(Point::new(8.0, 4.0)); + path.move_to(Point::new(3.5, 8.0)); + path.line_to(Point::new(8.0, 3.5)); + path.line_to(Point::new(12.5, 8.0)); + } + }); + frame.stroke( + &path, + canvas::Stroke::default() + .with_color(theme.extended_palette().background.base.text) + .with_width(1.5) + .with_line_cap(canvas::LineCap::Round) + .with_line_join(canvas::LineJoin::Round), + ); + vec![frame.into_geometry()] + } +} + +fn icon_view(icon: Icon) -> Element<'static, Message> { + canvas(IconCanvas(icon)) + .width(Length::Fixed(16.0)) + .height(Length::Fixed(16.0)) + .into() +} + +fn icon_control(icon: Icon, hint: String, message: Message) -> Element<'static, Message> { + tooltip( + button(icon_view(icon)) + .padding(6) + .style(button::background) + .on_press(message), + container(text(hint).size(12)) + .padding([5, 8]) + .style(container::rounded_box), + tooltip::Position::Bottom, + ) + .gap(4) + .delay(Duration::from_millis(350)) + .into() +} + +fn action_icon(icon: Icon, action: UiAction) -> Element<'static, Message> { + icon_control(icon, action_hint(action), Message::Action(action)) +} + +fn selected_button(theme: &Theme, status: button::Status) -> button::Style { + let palette = theme.extended_palette(); + let mut style = button::text(theme, status); + style.background = Some(Background::Color(palette.primary.weak.color)); + style.text_color = palette.primary.weak.text; + style } fn command_palette_results(app: &App) -> Element<'_, Message> { @@ -3432,17 +3703,15 @@ fn command_palette_results(app: &App) -> Element<'_, Message> { let spec = action::spec_for(action); let shortcut = action::shortcut_label(action).unwrap_or_default(); let reason = action::disabled_reason(action, context); - let availability = reason.unwrap_or("Available"); let content = row![ text(spec.label).width(Length::Fill), text(shortcut).size(13), - text(availability).size(13).width(Length::Fixed(250.0)), ] .spacing(12); let item = button(content) .width(Length::Fill) .style(if index == app.palette.selected() { - button::primary + selected_button } else { button::text }); @@ -4002,12 +4271,12 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } if let Some(uri) = &app.sensitive.otp_uri { content = content - .push(text("OTP URI (explicitly revealed)").size(20)) + .push(text("OTP provisioning URI").size(20)) .push(text(String::from_utf8_lossy(uri.expose()).into_owned())); } if let Some(matrix) = &app.sensitive.otp_qr { content = content - .push(text("OTP QR (explicitly revealed)").size(20)) + .push(text("OTP provisioning QR code").size(20)) .push( canvas(QrCanvas(matrix)) .width(Length::Fixed(320.0)) @@ -4061,7 +4330,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa )) .push(text("Sensitive values, copying, and locking").size(22)) .push(text( - "Opening protected content authenticates through the shared inactivity lease. Reveal and copy are explicit field actions. Copy uses the configured cleanup timeout. Lock immediately drops decrypted entry, editor, and clipboard state.", + "Opening protected content authenticates through the shared inactivity lease. Every field remains visible while unlocked, and Copy uses the configured cleanup timeout. Lock immediately drops decrypted entry, editor, OTP code, and clipboard state.", )) .push(text("Command palette").size(22)) .push(text( @@ -4184,16 +4453,34 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa .spacing(8) } else { let mut actions = row![ - button("Refresh").on_press(Message::RunGit(DesktopGitRequest::Refresh)), - button("Pull").on_press(Message::RunGit(DesktopGitRequest::Pull)), - button("Push").on_press(Message::RunGit(DesktopGitRequest::Push)), - button("Synchronize").on_press(Message::RunGit(DesktopGitRequest::Sync)), + icon_control( + Icon::Refresh, + "Refresh Git status".to_owned(), + Message::RunGit(DesktopGitRequest::Refresh) + ), + icon_control( + Icon::Down, + "Pull from remote".to_owned(), + Message::RunGit(DesktopGitRequest::Pull) + ), + icon_control( + Icon::Up, + "Push to remote".to_owned(), + Message::RunGit(DesktopGitRequest::Push) + ), + icon_control( + Icon::Refresh, + "Synchronize with remote".to_owned(), + Message::RunGit(DesktopGitRequest::Sync) + ), ] - .spacing(8); + .spacing(2); if !form.conflicts.is_empty() { - actions = actions.push( - button("Resolve selected versions").on_press(Message::ResolveGitConflicts), - ); + actions = actions.push(icon_control( + Icon::Check, + "Resolve selected versions".to_owned(), + Message::ResolveGitConflicts, + )); } actions.push(done) } @@ -4203,31 +4490,63 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa row![button("Working…"), done].spacing(8) } else { let mut actions = row![ - button("Generate code").on_press(Message::RunOtpCode(false)), - button("Copy code").on_press(Message::RunOtpCode(true)), - button("Show URI").on_press(Message::RunOtpUri { - qr: false, - copy: false, - }), - button("Copy URI").on_press(Message::RunOtpUri { - qr: false, - copy: true, - }), - button("Show QR").on_press(Message::RunOtpUri { - qr: true, - copy: false, - }), + icon_control( + Icon::Refresh, + "Generate OTP code".to_owned(), + Message::RunOtpCode(false) + ), + icon_control( + Icon::Copy, + "Copy OTP code".to_owned(), + Message::RunOtpCode(true) + ), + icon_control( + Icon::Link, + "Show provisioning URI".to_owned(), + Message::RunOtpUri { + qr: false, + copy: false, + } + ), + icon_control( + Icon::Copy, + "Copy provisioning URI".to_owned(), + Message::RunOtpUri { + qr: false, + copy: true, + } + ), + icon_control( + Icon::Qr, + "Show provisioning QR code".to_owned(), + Message::RunOtpUri { + qr: true, + copy: false, + } + ), ] - .spacing(8); + .spacing(2); if form.hotp_confirmation { actions = actions.push( button("Confirm HOTP counter advance").on_press(Message::ConfirmHotp), ); } actions - .push(button("Import URI").on_press(Message::SubmitOtpImport)) - .push(button("Import QR image…").on_press(Message::PickOtpQr)) - .push(button("Remove OTP").on_press(Message::SubmitOtpRemoval)) + .push(icon_control( + Icon::Down, + "Import provisioning URI".to_owned(), + Message::SubmitOtpImport, + )) + .push(icon_control( + Icon::Qr, + "Import provisioning QR image".to_owned(), + Message::PickOtpQr, + )) + .push( + button("Remove OTP") + .style(button::danger) + .on_press(Message::SubmitOtpRemoval), + ) .push(done) } } @@ -4307,21 +4626,18 @@ fn sidebar_view<'a>( ) -> Element<'a, Message> { let mut rows = column![ row![ - text(if focused { - "Password Store · focused" - } else { - "Password Store" - }) - .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)), + text(if focused { "EXPLORER •" } else { "EXPLORER" }) + .size(12) + .width(Length::Fill), + action_icon(Icon::Add, UiAction::NewEntry), + action_icon(Icon::FolderAdd, UiAction::NewFolder), + action_icon(Icon::Refresh, UiAction::Refresh), ] - .spacing(8) + .align_y(iced::Alignment::Center) + .spacing(2) ] .spacing(4) - .padding(10); + .padding([6, 4]); match state { TreeState::Loading => { @@ -4338,10 +4654,17 @@ fn sidebar_view<'a>( let selected = navigation.selected(); for node in navigation.rows() { - let marker = if node.id.is_directory() { - if node.expanded { "▾" } else { "▸" } + let expander: Element<'static, Message> = if node.id.is_directory() { + icon_view(if node.expanded { + Icon::ChevronDown + } else { + Icon::ChevronRight + }) } else { - "•" + container(text("")) + .width(Length::Fixed(16.0)) + .height(Length::Fixed(16.0)) + .into() }; let mut flags = Vec::new(); if node.indicators.has_conflict() { @@ -4360,39 +4683,56 @@ fn sidebar_view<'a>( }; 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)), - text(marker), - text(format!("{}{}", node.name, suffix)), - ] - .spacing(5), - ) - .width(Length::Fill) - .on_press(Message::SidebarActivate(id.clone())) - .style(if selected { - button::primary + let kind = if node.id.is_directory() { + Icon::Folder } else { - button::text - }); + Icon::Key + }; + let count = node + .id + .is_directory() + .then(|| text(node.entry_count.to_string()).size(12)); + let mut item_row = row![ + container(text("")).width(Length::Fixed((node.depth * 16) as f32)), + expander, + icon_view(kind), + text(format!("{}{}", node.name, suffix)).width(Length::Fill), + ] + .align_y(iced::Alignment::Center) + .spacing(4); + if let Some(count) = count { + item_row = item_row.push(count); + } + let item = button(item_row) + .width(Length::Fill) + .on_press(Message::SidebarActivate(id.clone())) + .style(if selected { + selected_button + } else { + button::text + }); 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,)), + icon_control( + Icon::Move, + action_hint(UiAction::MoveEntry), + Message::SidebarContextAction(id.clone(), UiAction::MoveEntry), + ), + icon_control( + Icon::Copy, + action_hint(UiAction::CopyEntry), + Message::SidebarContextAction(id.clone(), UiAction::CopyEntry), + ), + icon_control( + Icon::Delete, + action_hint(UiAction::DeleteEntry), + Message::SidebarContextAction(id, UiAction::DeleteEntry), + ), ] - .spacing(5), + .spacing(2), ); } } @@ -4404,24 +4744,33 @@ fn sidebar_view<'a>( } fn content_view(app: &App) -> Element<'_, Message> { - let open = row![ - text(if app.pane_focus == PaneFocus::Content { - "Content · focused" + let mut identity = row![] + .align_y(iced::Alignment::Center) + .spacing(6) + .width(Length::Fill); + if !app.entry_path.is_empty() { + identity = identity.push(icon_view(Icon::Key)); + } + identity = identity.push( + text(if app.entry_path.is_empty() { + if app.pane_focus == PaneFocus::Content { + "CONTENT •" + } else { + "CONTENT" + } } else { - "Content" + &app.entry_path }) - .size(20), - text_input("Entry path", &app.entry_path) - .id(content_focus_id()) - .on_input(Message::EntryPathChanged) - .on_submit(Message::Action(UiAction::OpenEntry)), - button("Open").on_press(Message::Action(UiAction::OpenEntry)), - button("New Entry…").on_press(Message::Action(UiAction::NewEntry)), - button("Open Folder…").on_press(Message::Action(UiAction::OpenFolder)), - button("Reload").on_press(Message::Action(UiAction::ReloadEntry)), - button("Lock").on_press(Message::Action(UiAction::Lock)), + .size(15) + .width(Length::Fill), + ); + let header = row![ + identity, + action_icon(Icon::Edit, UiAction::EditEntry), + action_icon(Icon::Refresh, UiAction::ReloadEntry), ] - .spacing(8); + .align_y(iced::Alignment::Center) + .spacing(2); let body: Element<'_, Message> = if app.switching_vault { container(text( @@ -4441,7 +4790,7 @@ fn content_view(app: &App) -> Element<'_, Message> { .into() } else if let Some(editor) = &app.editor { match app.content_mode { - ContentMode::Viewer => viewer_view(editor), + ContentMode::Viewer => viewer_view(app, editor), ContentMode::Editor => editor_view(editor, app.conflict), } } else { @@ -4452,7 +4801,7 @@ fn content_view(app: &App) -> Element<'_, Message> { .into() }; - container(column![open, body].spacing(12).padding(12)) + container(column![header, body].spacing(6).padding([6, 8])) .width(Length::Fill) .height(Length::Fill) .into() @@ -4462,25 +4811,30 @@ fn authentication_allows_content(authentication: &AuthenticationView) -> bool { matches!(authentication, AuthenticationView::Unlocked(_)) } +fn document_has_single_totp(document: &EntryDocument) -> bool { + let mut otp = document + .fields() + .iter() + .filter_map(|field| field.metadata().otp()); + otp.next() + .is_some_and(|metadata| metadata.kind() == OtpKind::Totp) + && otp.next().is_none() +} + #[derive(Debug, Eq, PartialEq)] enum ViewerValue<'a> { - Masked, Text(&'a str), Unavailable, } -fn viewer_value(field: &EntryField, revealed: bool) -> ViewerValue<'_> { - if field.metadata().sensitivity() == EntrySensitivity::Sensitive && !revealed { - ViewerValue::Masked - } else { - std::str::from_utf8(field.value()) - .map(ViewerValue::Text) - .unwrap_or(ViewerValue::Unavailable) - } +fn viewer_value(field: &EntryField) -> ViewerValue<'_> { + std::str::from_utf8(field.value()) + .map(ViewerValue::Text) + .unwrap_or(ViewerValue::Unavailable) } -fn viewer_label(field: &EntryField, index: usize) -> String { - let label = field.metadata().name().map_or_else( +fn viewer_label(field: &EntryField) -> String { + field.metadata().name().map_or_else( || match field.metadata().kind() { EntryFieldKind::Password => "Password".to_owned(), EntryFieldKind::Username => "Username".to_owned(), @@ -4492,8 +4846,7 @@ fn viewer_label(field: &EntryField, index: usize) -> String { EntryFieldKind::Blank => "Blank line".to_owned(), }, str::to_owned, - ); - format!("{label} · line {}", index + 1) + ) } fn viewer_diagnostic(diagnostic: EntryFieldDiagnostic) -> &'static str { @@ -4507,89 +4860,132 @@ fn viewer_diagnostic(diagnostic: EntryFieldDiagnostic) -> &'static str { } } -fn viewer_view(editor: &EntryEditor) -> Element<'_, Message> { - let mut rows = column![ - row![ - text(editor.entry()).size(22), - button("Edit entry").on_press(Message::Action(UiAction::EditEntry)), - ] - .spacing(8), - text("Use Up/Down/Home/End to select fields; Enter reveals or hides a selected sensitive value.") - .size(12), - ] - .spacing(12); +fn otp_code_text(display: &OtpDisplay) -> String { + std::str::from_utf8(display.code.expose()).map_or_else( + |_| "(unavailable)".to_owned(), + |code| { + let middle = code.len() / 2; + if code.len() > 4 && code.is_char_boundary(middle) { + format!("{} {}", &code[..middle], &code[middle..]) + } else { + code.to_owned() + } + }, + ) +} - for (index, field) in editor.fields().iter().enumerate() { +fn viewer_view<'a>(app: &'a App, editor: &'a EntryEditor) -> Element<'a, Message> { + let entry = editor.entry(); + let mut rows = + column![text("Up/Down/Home/End select fields · ⌘C copies the selected value").size(12),] + .spacing(8); + + for field in editor.fields() { let id = field.id(); - let label = viewer_label(field, index); + let label = viewer_label(field); let selected = editor.focused() == Some(id); - let revealed = editor.is_revealed(id); - let value = match viewer_value(field, revealed) { - ViewerValue::Masked => "••••••••", + let value = match viewer_value(field) { ViewerValue::Text("") => "(empty)", ViewerValue::Text(value) => value, ViewerValue::Unavailable => "(binary value)", }; - let mut actions = row![ - button(text(format!("Copy {label}"))) - .on_press(Message::FieldAction(id, UiAction::CopyField)), - ] - .spacing(6); - if field.metadata().sensitivity() == EntrySensitivity::Sensitive { - actions = actions.push( - button(text(format!( - "{} {label}", - if revealed { "Hide" } else { "Reveal" } - ))) - .on_press(Message::FieldAction(id, UiAction::ToggleReveal)), - ); - } + let copy_hint = format!("Copy {label} (⌘C)"); let mut field_view = column![ - button(text(format!( - "{} {label}", - if selected { "●" } else { "○" } - ))) - .on_press(Message::SelectField(id)) - .style(if selected { - button::primary - } else { - button::text - }), - text(value), - actions, + row![ + button(text(label.clone()).size(12)) + .width(Length::Fill) + .padding([4, 6]) + .on_press(Message::SelectField(id)) + .style(if selected { + selected_button + } else { + button::text + }), + icon_control( + Icon::Copy, + copy_hint, + Message::FieldAction(id, UiAction::CopyField), + ), + ] + .align_y(iced::Alignment::Center) + .spacing(2), + text(value).size(15), ] - .spacing(5); + .spacing(3); if let Some(otp) = field.metadata().otp() { let cadence = otp.period().map_or_else( || format!("counter {}", otp.counter().unwrap_or_default()), |period| format!("{period}s period"), ); - field_view = field_view.push(text(format!( - "{:?} · {} · {} · {:?} · {} digits · {cadence}", - otp.kind(), - otp.issuer().unwrap_or("unknown issuer"), - otp.account(), - otp.algorithm(), - otp.digits(), - ))); + let code = app + .sensitive + .otp + .as_ref() + .filter(|display| display.entry == entry); + field_view = field_view.push(match code { + Some(display) => column![ + text(otp_code_text(display)).size(28), + text(display.remaining_at(display.observed_at).map_or_else( + || format!( + "HOTP counter {}", + display.validity.counter().unwrap_or_default() + ), + |remaining| format!("Valid for {remaining}s"), + )) + .size(12), + ] + .spacing(1), + None if app.otp_pending => column![text("Generating one-time password…").size(13)], + None => column![ + text(if otp.kind() == OtpKind::Hotp { + "Generate the next HOTP code to advance its counter." + } else { + "One-time password unavailable. Refresh to retry." + }) + .size(13) + ], + }); + field_view = field_view.push( + text(format!( + "{:?} · {} · {} · {:?} · {} digits · {cadence}", + otp.kind(), + otp.issuer().unwrap_or("unknown issuer"), + otp.account(), + otp.algorithm(), + otp.digits(), + )) + .size(12), + ); field_view = field_view.push( row![ - button("Generate code") - .on_press(Message::FieldAction(id, UiAction::GenerateOtp)), - button("Copy code").on_press(Message::FieldAction(id, UiAction::CopyOtp)), - button("Show URI").on_press(Message::FieldAction(id, UiAction::ShowOtpUri)), - button("Copy URI").on_press(Message::FieldAction(id, UiAction::CopyOtpUri)), - button("Show QR").on_press(Message::FieldAction(id, UiAction::ShowOtpQr)), - button("Remove OTP").on_press(Message::FieldAction(id, UiAction::RemoveOtp)), + icon_control( + Icon::Refresh, + "Generate current OTP code".to_owned(), + Message::FieldAction(id, UiAction::GenerateOtp), + ), + icon_control( + Icon::Copy, + "Copy current OTP code".to_owned(), + Message::FieldAction(id, UiAction::CopyOtp), + ), + icon_control( + Icon::Link, + "Copy OTP provisioning URI".to_owned(), + Message::FieldAction(id, UiAction::CopyOtpUri), + ), + icon_control( + Icon::Qr, + "Show OTP provisioning QR code".to_owned(), + Message::FieldAction(id, UiAction::ShowOtpQr), + ), ] - .spacing(6) - .wrap(), + .spacing(2), ); } if let Some(diagnostic) = field.metadata().diagnostic() { field_view = field_view.push(text(viewer_diagnostic(diagnostic)).size(12)); } - rows = rows.push(container(field_view).padding(10).width(Length::Fill)); + rows = rows.push(container(field_view).padding([6, 4]).width(Length::Fill)); } scrollable(rows) @@ -4622,11 +5018,13 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { editor.entry(), if editor.is_dirty() { " •" } else { "" } )) - .size(22), - button("Save").on_press(Message::Action(UiAction::Save)), - button("Add line").on_press(Message::AddAfter(None)), + .size(16) + .width(Length::Fill), + action_icon(Icon::Check, UiAction::Save), + icon_control(Icon::Add, "Add field".to_owned(), Message::AddAfter(None),), ] - .spacing(8) + .align_y(iced::Alignment::Center) + .spacing(2) ] .spacing(10); @@ -4644,50 +5042,67 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { for field in editor.fields() { let id = field.id(); let sensitive = field.metadata().sensitivity() == EntrySensitivity::Sensitive; - let value = std::str::from_utf8(field.contents().expose()).ok(); + let value = std::str::from_utf8(field.value()).ok(); let label = field .metadata() .name() .map(str::to_owned) .unwrap_or_else(|| format!("{:?}", field.metadata().kind())); - let input = text_input( - if value.is_some() { - "Entry line" - } else { - "Non-UTF-8 line preserved" - }, - value.unwrap_or(""), - ) - .id(editor_field_input_id(id)) - .secure(sensitive && !editor.is_revealed(id)) - .on_input_maybe( - value - .is_some() - .then_some(move |value| Message::FieldChanged(id, Zeroizing::new(value))), - ) - .on_submit(Message::AddAfter(Some(id))); - let mut actions = row![ - button("↑").on_press(Message::MoveUp(id)), - button("↓").on_press(Message::MoveDown(id)), - button("Add below").on_press(Message::AddAfter(Some(id))), - button("Remove").on_press(Message::Remove(id)), - button("Copy value").on_press(Message::FieldAction(id, UiAction::CopyEditedField)), - ] - .spacing(6); - if sensitive { - actions = actions.push( - button(if editor.is_revealed(id) { - "Hide" + let input: Element<'_, Message> = if let Some(value) = value { + let multiline = value.contains('\n'); + let mut lines = column![].spacing(2); + for (line_index, line) in value.split('\n').enumerate() { + let line = line.strip_suffix('\r').unwrap_or(line); + let submit = if matches!( + field.metadata().kind(), + EntryFieldKind::Password | EntryFieldKind::OtpUri + ) { + Message::AddAfter(Some(id)) } else { - "Reveal" - }) - .on_press(Message::FieldAction(id, UiAction::ToggleReveal)), - ); - } + Message::AddFieldLine(id, line_index) + }; + let input = text_input("Entry value", line) + .id(editor_field_line_input_id(id, line_index)) + .on_input(move |value| { + if multiline { + Message::FieldLineChanged(id, line_index, Zeroizing::new(value)) + } else { + Message::FieldChanged(id, Zeroizing::new(value)) + } + }) + .on_submit(submit); + lines = lines.push(input); + } + lines.into() + } else { + text_input("Non-UTF-8 value preserved", "").into() + }; + let mut actions = row![ + icon_control(Icon::Up, "Move field up".to_owned(), Message::MoveUp(id)), + icon_control( + Icon::Down, + "Move field down".to_owned(), + Message::MoveDown(id) + ), + icon_control( + Icon::Add, + "Add field below".to_owned(), + Message::AddAfter(Some(id)), + ), + icon_control(Icon::Delete, "Remove field".to_owned(), Message::Remove(id)), + icon_control( + Icon::Copy, + "Copy field value (⌘C)".to_owned(), + Message::FieldAction(id, UiAction::CopyEditedField), + ), + ] + .spacing(2); if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri { - actions = actions.push( - button("Generate").on_press(Message::FieldAction(id, UiAction::GeneratePassword)), - ); + actions = actions.push(icon_control( + Icon::Generate, + "Generate password".to_owned(), + Message::FieldAction(id, UiAction::GeneratePassword), + )); } fields = fields.push(column![text(label).size(14), input, actions].spacing(4)); } @@ -5112,8 +5527,8 @@ mod tests { #[test] fn window_contract_keeps_both_scrollable_panes_usable_at_narrow_size() { let settings = window_settings(); - assert_eq!(settings.size, Size::new(1_080.0, 720.0)); - assert_eq!(settings.min_size, Some(Size::new(720.0, 480.0))); + assert_eq!(settings.size, Size::new(960.0, 680.0)); + assert_eq!(settings.min_size, Some(Size::new(480.0, 360.0))); let app = test_app(None); let _view = app.view(); } @@ -5383,7 +5798,7 @@ mod tests { } #[test] - fn viewer_covers_lossless_fields_navigation_and_explicit_sensitive_actions() { + fn viewer_exposes_every_unlocked_field_and_clears_it_on_lock() { let (_temporary, storage) = fixture_storage(); let mut editor = empty_editor(&storage, "documents/viewer"); for value in [ @@ -5393,6 +5808,7 @@ mod tests { b"custom: second", b"first note", b"second note", + b"comments: Recovery codes:\none\ntwo", b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example", b"otpauth://broken", b"binary: \xff", @@ -5408,56 +5824,61 @@ mod tests { assert_eq!(fields[1].metadata().kind(), EntryFieldKind::Username); assert_eq!(fields[2].metadata().name(), Some("custom")); assert_eq!(fields[3].metadata().name(), Some("custom")); - assert_ne!(viewer_label(&fields[2], 2), viewer_label(&fields[3], 3)); + assert_eq!(viewer_label(&fields[2]), viewer_label(&fields[3])); assert_eq!(fields[4].metadata().kind(), EntryFieldKind::Note); assert_eq!(fields[5].metadata().kind(), EntryFieldKind::Note); - assert!(fields[6].metadata().otp().is_some()); + assert_eq!(fields[6].metadata().name(), Some("comments")); + assert_eq!(fields[6].value(), b"Recovery codes:\none\ntwo"); + assert!(fields[7].metadata().otp().is_some()); assert_eq!( - fields[7].metadata().diagnostic(), + fields[8].metadata().diagnostic(), Some(EntryFieldDiagnostic::MalformedOtpUri) ); assert_eq!( - fields[8].metadata().diagnostic(), + fields[9].metadata().diagnostic(), Some(EntryFieldDiagnostic::NonUtf8Value) ); let password = fields[0].id(); - assert_eq!(viewer_value(&fields[0], false), ViewerValue::Masked); - editor.toggle_reveal(password).expect("reveal password"); assert_eq!( - viewer_value(&editor.fields()[0], editor.is_revealed(password)), + viewer_value(&editor.fields()[0]), ViewerValue::Text("password") ); + assert_eq!( + viewer_value(&editor.fields()[7]), + ViewerValue::Text( + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example" + ) + ); + assert_eq!( + viewer_value(&editor.fields()[6]), + ViewerValue::Text("Recovery codes:\none\ntwo") + ); + assert_eq!(viewer_value(&editor.fields()[9]), ViewerValue::Unavailable); + let comments = editor.fields()[6].id(); + editor + .update_value_line(comments, 1, b"alpha") + .expect("update multiline row"); + editor + .add_value_line_after(comments, 2) + .expect("append multiline row"); + assert_eq!(editor.fields()[6].value(), b"Recovery codes:\nalpha\ntwo\n"); + assert!(document_has_single_totp(editor.document())); editor.navigate(FieldNavigation::First); assert_eq!(editor.focused(), Some(password)); editor.navigate(FieldNavigation::Next); assert_eq!(editor.focused(), Some(editor.fields()[1].id())); editor.navigate(FieldNavigation::Last); - assert_eq!(editor.focused(), Some(editor.fields()[8].id())); + assert_eq!(editor.focused(), Some(editor.fields()[9].id())); assert!(!authentication_allows_content(&AuthenticationView::Locked)); assert!(authentication_allows_content( &AuthenticationView::Unlocked(Duration::from_secs(1)) )); - editor.toggle_reveal(password).expect("hide password"); let mut app = test_app(Some(editor)); - let _task = app.update(Message::ToggleReveal(password)); - assert!( - !app.editor - .as_ref() - .expect("locked document fixture") - .is_revealed(password) - ); let _task = app.update(Message::BeginEdit); assert_eq!(app.content_mode, ContentMode::Viewer); app.authentication = AuthenticationView::Unlocked(Duration::from_secs(1)); - let _task = app.update(Message::ToggleReveal(password)); - assert!( - app.editor - .as_ref() - .expect("unlocked document fixture") - .is_revealed(password) - ); let _task = app.update(Message::BeginEdit); assert_eq!(app.content_mode, ContentMode::Editor); app.authentication_lost("locked".to_owned()); diff --git a/apps/desktop/src/native_menu.rs b/apps/desktop/src/native_menu.rs index 5d4e9f6..dcce13c 100644 --- a/apps/desktop/src/native_menu.rs +++ b/apps/desktop/src/native_menu.rs @@ -8,7 +8,9 @@ use muda::{ use crate::action::{self, ActionContext, MenuGroup, UiAction}; pub struct NativeMenu { - _menu: Menu, + menu: Menu, + window_menu: Submenu, + help_menu: Submenu, items: Vec<(UiAction, MenuItem)>, } @@ -16,6 +18,8 @@ impl NativeMenu { pub fn install(context: ActionContext) -> Result { let menu = Menu::new(); let mut items = Vec::new(); + let mut window_menu = None; + let mut help_menu = None; for group in MenuGroup::ALL { let submenu = Submenu::new(group.label(), true); match group { @@ -50,10 +54,27 @@ impl NativeMenu { } _ => append_actions(&submenu, group, context, &mut items)?, } + if group == MenuGroup::Window { + window_menu = Some(submenu.clone()); + } else if group == MenuGroup::Help { + help_menu = Some(submenu.clone()); + } menu.append(&submenu)?; } - menu.init_for_nsapp(); - Ok(Self { _menu: menu, items }) + let native = Self { + menu, + window_menu: window_menu.expect("the Window menu is registered"), + help_menu: help_menu.expect("the Help menu is registered"), + items, + }; + native.activate(); + Ok(native) + } + + pub fn activate(&self) { + self.menu.init_for_nsapp(); + self.window_menu.set_as_windows_menu_for_nsapp(); + self.help_menu.set_as_help_menu_for_nsapp(); } pub fn sync(&self, context: ActionContext) { @@ -117,6 +138,7 @@ fn accelerator(action: UiAction) -> Option { let (modifiers, code) = match action { UiAction::Settings => (command, Code::Comma), UiAction::NewEntry => (command, Code::KeyN), + UiAction::NewFolder => (command | Modifiers::SHIFT, Code::KeyN), UiAction::OpenFolder => (command, Code::KeyO), UiAction::Save => (command, Code::KeyS), UiAction::CloseWindow => (command, Code::KeyW), @@ -129,22 +151,20 @@ fn accelerator(action: UiAction) -> Option { UiAction::SearchContents => (command | Modifiers::SHIFT, Code::KeyF), UiAction::CommandPalette => (command, Code::KeyK), UiAction::Refresh => (command, Code::KeyR), + UiAction::ReloadEntry => (command | Modifiers::SHIFT, Code::KeyR), + UiAction::EditEntry => (command, Code::KeyE), UiAction::Lock => (command, Code::KeyL), UiAction::Help => (Modifiers::empty(), Code::F1), UiAction::About | UiAction::InitializeStore - | UiAction::NewFolder | UiAction::CopyField | UiAction::CopyEditedField | UiAction::TogglePaneFocus | UiAction::OpenEntry - | UiAction::ReloadEntry - | UiAction::EditEntry | UiAction::GeneratePassword | UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry - | UiAction::ToggleReveal | UiAction::GenerateOtp | UiAction::CopyOtp | UiAction::ImportOtp diff --git a/apps/desktop/src/navigation.rs b/apps/desktop/src/navigation.rs index 781ec77..b0a4085 100644 --- a/apps/desktop/src/navigation.rs +++ b/apps/desktop/src/navigation.rs @@ -41,6 +41,14 @@ impl NavigationNode { children: node.children().iter().map(Self::from_storage).collect(), } } + + fn entry_count(&self) -> usize { + if self.id.is_directory() { + self.children.iter().map(Self::entry_count).sum() + } else { + 1 + } + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -51,6 +59,7 @@ pub struct NavigationRow { pub depth: usize, pub expanded: bool, pub has_children: bool, + pub entry_count: usize, parent: Option, } @@ -263,6 +272,7 @@ fn flatten( depth, expanded: is_expanded, has_children: !node.children.is_empty(), + entry_count: node.entry_count(), parent: parent.cloned(), }); if node.id.is_directory() && is_expanded { @@ -387,7 +397,10 @@ mod tests { Some(Path::new("personal")) ); assert_eq!(tree.navigate(NavigationKey::Expand), NavigationIntent::None); - assert_eq!(tree.rows().len(), 4); + let rows = tree.rows(); + assert_eq!(rows.len(), 4); + assert_eq!(rows[0].entry_count, 2); + assert_eq!(rows[1].entry_count, 1); assert_eq!(tree.navigate(NavigationKey::Expand), NavigationIntent::None); assert_eq!( tree.navigate(NavigationKey::Activate), diff --git a/apps/tui/src/editor.rs b/apps/tui/src/editor.rs index 65be1be..7d5a313 100644 --- a/apps/tui/src/editor.rs +++ b/apps/tui/src/editor.rs @@ -12,9 +12,10 @@ use ironstorage::{ /// Presentation state for editing one storage-owned document. /// -/// The focused buffer contains one complete pass entry line. Committing that -/// buffer delegates classification back to `EntryDocument`, so names, duplicate -/// fields, notes, and OTP URI recognition remain storage behavior. +/// The focused buffer contains one complete storage-owned logical field. +/// Committing it delegates classification back to `EntryDocument`, so names, +/// multiline values, duplicate fields, notes, and OTP URI recognition remain +/// storage behavior. pub struct EntryEditor { document: EntryDocument, focused: usize, @@ -261,6 +262,22 @@ impl EntryEditor { let Some(field) = self.focused_field() else { return Ok(()); }; + if matches!( + field.metadata().kind(), + EntryFieldKind::Username + | EntryFieldKind::Email + | EntryFieldKind::Url + | EntryFieldKind::Field + | EntryFieldKind::Note + ) { + let mut replacement = Vec::with_capacity(self.buffer.expose().len() + 1); + replacement.extend_from_slice(&self.buffer.expose()[..self.cursor]); + replacement.push(b'\n'); + replacement.extend_from_slice(&self.buffer.expose()[self.cursor..]); + self.cursor += 1; + self.replace_buffer(replacement); + return Ok(()); + } let id = field.id(); let left = self.buffer.expose()[..self.cursor].to_vec(); let right = self.buffer.expose()[self.cursor..].to_vec(); @@ -281,8 +298,10 @@ impl EntryEditor { return Ok(()); } let id = field.id(); - self.document - .update(id, EntryFieldDraft::line(self.buffer.expose().to_vec())?)?; + self.document.update( + id, + EntryFieldDraft::multiline(self.buffer.expose().to_vec()), + )?; self.dirty = true; Ok(()) } @@ -303,8 +322,7 @@ impl EntryEditor { let Some(id) = self.focused_field().map(EntryField::id) else { return; }; - let draft = EntryFieldDraft::line(self.buffer.expose().to_vec()) - .expect("the single-line editor never inserts line endings into its buffer"); + let draft = EntryFieldDraft::multiline(self.buffer.expose().to_vec()); self.document .update(id, draft) .expect("the focused field identifier belongs to the document"); @@ -376,7 +394,7 @@ mod tests { } #[test] - fn add_split_remove_and_reorder_keep_stable_focus() { + fn add_multiline_remove_and_reorder_keep_stable_focus() { let mut editor = EntryEditor::new(fixture_document("email/personal")); let initial = editor.document().fields().len(); editor.add_after_focused().expect("add"); @@ -385,11 +403,15 @@ mod tests { editor.insert_character('b'); editor.move_cursor_left(); editor.split_line().expect("split"); - assert_eq!(editor.document().fields().len(), initial + 2); - editor.move_focused_up().expect("move up"); - assert_eq!(editor.focused_index(), Some(1)); - editor.remove_focused().expect("remove"); assert_eq!(editor.document().fields().len(), initial + 1); + assert_eq!( + editor.focused_contents(editor.focused_field().expect("field").id()), + Some(b"a\nb".as_slice()) + ); + editor.move_focused_up().expect("move up"); + assert_eq!(editor.focused_index(), Some(0)); + editor.remove_focused().expect("remove"); + assert_eq!(editor.document().fields().len(), initial); } #[test] diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index e9c6705..f005e8c 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -332,10 +332,19 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect, capability: ColorCap Mode::Editor => app.editor().map_or_else( || Paragraph::new(main_text(app)), |editor| { - Paragraph::new(editor_lines(editor)).scroll(( - u16::try_from(editor.focused_index().unwrap_or_default()).unwrap_or(u16::MAX), - 0, - )) + let focused = editor.focused_index().unwrap_or_default(); + let scroll = editor + .document() + .fields() + .iter() + .take(focused) + .map(|field| { + std::str::from_utf8(field.contents().expose()) + .map_or(1, |value| value.split('\n').count()) + }) + .sum::(); + Paragraph::new(editor_lines(editor)) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)) }, ), Mode::Browser if app.git_view().is_some() => { @@ -524,8 +533,22 @@ fn viewer_scroll_in_content( }) .collect::>(); let focused = viewer.focused_index().unwrap_or_default(); - let focused_start = row_heights.iter().take(focused).sum(); - let focused_height = row_heights.get(focused).copied().unwrap_or(1); + let field_heights = viewer + .document() + .fields() + .iter() + .map(|field| { + std::str::from_utf8(field.value()).map_or(1, |value| value.split('\n').count()) + }) + .collect::>(); + let focused_line = field_heights.iter().take(focused).sum(); + let focused_lines = field_heights.get(focused).copied().unwrap_or(1); + let focused_start = row_heights.iter().take(focused_line).sum(); + let focused_height = row_heights + .iter() + .skip(focused_line) + .take(focused_lines) + .sum(); let total_rows = row_heights.iter().sum(); let scroll = viewer.ensure_focus_visible( focused_start, @@ -694,39 +717,52 @@ fn viewer_lines<'a>( return vec![Line::from("This entry is empty.")]; } - viewer - .document() - .fields() - .iter() - .enumerate() - .map(|(index, field)| { - let metadata = field.metadata(); - let label = metadata.name().map_or_else( - || match metadata.kind() { - ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1), - ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1), - kind => format!("{kind:?}").to_ascii_lowercase(), - }, - str::to_owned, - ); - let value = if field.value().is_empty() { - Span::styled("(empty)", Style::default().fg(Color::DarkGray)) - } else { - match std::str::from_utf8(field.value()) { - Ok(value) => Span::raw(value), - Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)), - } - }; - let mut spans = vec![ - Span::styled( + let mut lines = Vec::new(); + for (index, field) in viewer.document().fields().iter().enumerate() { + let metadata = field.metadata(); + let label = metadata.name().map_or_else( + || match metadata.kind() { + ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1), + ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1), + kind => format!("{kind:?}").to_ascii_lowercase(), + }, + str::to_owned, + ); + let values = std::str::from_utf8(field.value()).ok(); + let value_lines = values.map_or(1, |value| value.split('\n').count()); + for line_index in 0..value_lines { + let mut spans = Vec::new(); + if line_index == 0 { + spans.push(Span::styled( format!("{label}: "), Style::default() .fg(ACCENT_COLOR) .add_modifier(Modifier::BOLD), - ), - value, - ]; - if let Some(otp) = metadata.otp() { + )); + } else { + spans.push(Span::raw(" ")); + } + match values { + Some("") if line_index == 0 => spans.push(Span::styled( + "(empty)", + Style::default().fg(Color::DarkGray), + )), + Some(value) => spans.push(Span::raw( + value + .split('\n') + .nth(line_index) + .unwrap_or_default() + .strip_suffix('\r') + .unwrap_or_else(|| value.split('\n').nth(line_index).unwrap_or_default()), + )), + None => spans.push(Span::styled( + "[non-UTF-8 value]", + Style::default().fg(Color::Yellow), + )), + } + if line_index == 0 + && let Some(otp) = metadata.otp() + { let timing = otp.period().map_or_else( || format!("counter {}", otp.counter().unwrap_or_default()), |period| format!("period {period}s"), @@ -767,13 +803,14 @@ fn viewer_lines<'a>( ); } } - if focused == Some(index) { + lines.push(if focused == Some(index) { selected_line(spans) } else { Line::from(spans) - } - }) - .collect() + }); + } + } + lines } fn editor_lines(editor: &EntryEditor) -> Vec> { @@ -784,29 +821,38 @@ fn editor_lines(editor: &EntryEditor) -> Vec> { )]; } - editor - .document() - .fields() - .iter() - .enumerate() - .map(|(index, field)| { - let selected = focused == Some(index); - let contents = editor - .focused_contents(field.id()) - .unwrap_or_else(|| field.contents().expose()); - let label = field.metadata().name().map_or_else( - || format!("{:?}", field.metadata().kind()), - |name| format!("{:?} ({name})", field.metadata().kind()), - ); - let mut spans = vec![Span::styled( - format!("#{:02} {label}: ", index + 1), - Style::default() - .fg(ACCENT_COLOR) - .add_modifier(Modifier::BOLD), - )]; - if let Ok(value) = std::str::from_utf8(contents) { - if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) { - let (before, after) = value.split_at(editor.cursor()); + let mut lines = Vec::new(); + for (index, field) in editor.document().fields().iter().enumerate() { + let selected = focused == Some(index); + let contents = editor + .focused_contents(field.id()) + .unwrap_or_else(|| field.contents().expose()); + let label = field.metadata().name().map_or_else( + || format!("{:?}", field.metadata().kind()), + |name| format!("{:?} ({name})", field.metadata().kind()), + ); + if let Ok(value) = std::str::from_utf8(contents) { + let mut offset = 0; + for (line_index, value_line) in value.split('\n').enumerate() { + let value_line = value_line.strip_suffix('\r').unwrap_or(value_line); + let mut spans = vec![Span::styled( + if line_index == 0 { + format!("#{:02} {label}: ", index + 1) + } else { + " ".to_owned() + }, + Style::default() + .fg(ACCENT_COLOR) + .add_modifier(Modifier::BOLD), + )]; + let cursor = editor.cursor().saturating_sub(offset); + if selected + && editor.is_input_active() + && editor.cursor() >= offset + && cursor <= value_line.len() + && value_line.is_char_boundary(cursor) + { + let (before, after) = value_line.split_at(cursor); spans.push(Span::raw(before)); spans.push(Span::styled("▏", Style::default().fg(Color::Yellow))); spans.push(Span::raw(after)); @@ -816,21 +862,36 @@ fn editor_lines(editor: &EntryEditor) -> Vec> { Style::default().fg(Color::DarkGray), )); } else { - spans.push(Span::raw(value)); + spans.push(Span::raw(value_line)); } - } else { - spans.push(Span::styled( + lines.push(if selected { + selected_line(spans) + } else { + Line::from(spans) + }); + offset += value_line.len() + 1; + } + } else { + let spans = vec![ + Span::styled( + format!("#{:02} {label}: ", index + 1), + Style::default() + .fg(ACCENT_COLOR) + .add_modifier(Modifier::BOLD), + ), + Span::styled( "[non-UTF-8 field; editing will preserve bytes]", Style::default().fg(Color::Yellow), - )); - } - if selected { + ), + ]; + lines.push(if selected { selected_line(spans) } else { Line::from(spans) - } - }) - .collect() + }); + } + } + lines } fn grep_lines(view: &crate::search::GrepView) -> Vec> { @@ -1512,6 +1573,36 @@ mod tests { assert!(!format!("{app:?}").contains("pässwörd-猫")); } + #[test] + fn multiline_fields_render_copy_and_select_as_one_tui_field() { + let (_store, document) = fixture_document_from_plaintext( + "documents/multiline", + b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n", + ); + assert_eq!(document.fields().len(), 3); + let mut app = App::new(); + app.open_test_document("documents/multiline", document); + app.dispatch(crate::action::Action::FocusNext); + + let viewer = app.viewer().expect("viewer"); + assert_eq!( + viewer.copy_focused().expect("multiline copy").expose(), + b"Recovery codes:\none\ntwo" + ); + let lines = viewer_lines(viewer, None); + assert_eq!(lines.len(), 5); + for line in &lines[1..4] { + assert!(line.spans.iter().all(|span| { + span.style.fg == Some(SELECTED_FOREGROUND) + && span.style.bg == Some(SELECTED_BACKGROUND) + })); + } + let rendered = render(80, 14, &app); + for expected in ["comments: Recovery codes:", "one", "two"] { + assert!(rendered.contains(expected), "missing {expected}"); + } + } + #[test] fn authenticated_viewer_renders_every_value_until_entry_close_or_relock() { let (_store, document) = fixture_document_from_plaintext( diff --git a/crates/storage/src/desktop.rs b/crates/storage/src/desktop.rs index db7769b..95d896e 100644 --- a/crates/storage/src/desktop.rs +++ b/crates/storage/src/desktop.rs @@ -1,6 +1,11 @@ //! Storage-owned service boundary for the Iced desktop presentation adapter. -use std::{error::Error, fmt, path::Path}; +use std::{ + error::Error, + fmt, + path::Path, + sync::{Arc, OnceLock}, +}; use crate::{ authentication::{ @@ -120,6 +125,7 @@ impl Error for DesktopError {} #[derive(Clone, Debug)] pub struct DesktopStorage { config: Config, + keys: Arc>, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -299,9 +305,12 @@ impl DesktopMutationRequest { impl DesktopStorage { pub fn load(explicit: Option<&Path>) -> Result { - Config::load(explicit) - .map(|config| Self { config }) - .map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error)) + let config = Config::load(explicit) + .map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?; + Ok(Self { + config, + keys: Arc::new(OnceLock::new()), + }) } pub fn system() -> Result { @@ -309,8 +318,7 @@ impl DesktopStorage { } pub fn bootstrap(self) -> Result { - let keys = KeyStore::load(self.config.key_material()) - .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; + let keys = self.keys()?; let handle = keys .resolve(self.config.default_key().as_str()) .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; @@ -386,7 +394,10 @@ impl DesktopStorage { .config .with_settings(settings) .map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?; - let storage = Self { config }; + let storage = Self { + config, + keys: Arc::clone(&self.keys), + }; let keys = storage.keys()?; keys.resolve(storage.config.default_key().as_str()) .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; @@ -411,7 +422,7 @@ impl DesktopStorage { pub fn tree(&self) -> Result { let repository = self.repository()?; let keys = self.keys()?; - VaultReader::new(&repository, &keys) + VaultReader::new(&repository, keys) .list(&DirectoryPath::root()) .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) } @@ -505,7 +516,7 @@ impl DesktopStorage { pub fn find(&self, request: &FindRequest) -> Result { let repository = self.repository()?; let keys = self.keys()?; - VaultReader::new(&repository, &keys) + VaultReader::new(&repository, keys) .find(&request.terms) .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) } @@ -529,7 +540,7 @@ impl DesktopStorage { ) -> Result { let repository = self.repository()?; let keys = self.keys()?; - VaultReader::new(&repository, &keys) + VaultReader::new(&repository, keys) .grep(request, provider) .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) } @@ -557,7 +568,7 @@ impl DesktopStorage { ) -> Result { let repository = self.repository()?; let keys = self.keys()?; - let service = OtpService::new(&repository, &keys); + let service = OtpService::new(&repository, keys); let uri = service .uri(entry, provider) .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; @@ -582,7 +593,7 @@ impl DesktopStorage { let tree = changed.then(|| self.tree()).transpose()?; let document = changed .then(|| { - EntryDocumentService::new(&repository, &keys) + EntryDocumentService::new(&repository, keys) .open(entry, provider) .map_err(DesktopError::document) }) @@ -617,7 +628,7 @@ impl DesktopStorage { ) -> Result { let repository = self.repository()?; let keys = self.keys()?; - let uri = OtpService::new(&repository, &keys) + let uri = OtpService::new(&repository, keys) .uri(entry, provider) .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; let payload = SecretBytes::new(uri.encoded().expose().to_vec()); @@ -680,11 +691,11 @@ impl DesktopStorage { let encoded = parsed.encoded().expose().to_vec(); let repository = self.repository()?; let keys = self.keys()?; - let exists = VaultWriter::new(&repository, &keys) + let exists = VaultWriter::new(&repository, keys) .entry_exists(entry) .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; if exists { - let mut document = EntryDocumentService::new(&repository, &keys) + let mut document = EntryDocumentService::new(&repository, keys) .open(entry, provider) .map_err(DesktopError::document)?; if let Some(field) = otp_field(&document)? { @@ -708,7 +719,7 @@ impl DesktopStorage { .map_err(DesktopError::document)?; } self.save_document(&document)?; - let document = EntryDocumentService::new(&repository, &keys) + let document = EntryDocumentService::new(&repository, keys) .open(entry, provider) .map_err(DesktopError::document)?; return Ok(DesktopOtpMutation { @@ -725,7 +736,7 @@ impl DesktopStorage { }; let input = OtpInput::line(encoded) .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; - let service = OtpService::new(&repository, &keys); + let service = OtpService::new(&repository, keys); let plan = service .prepare_insert(&request, input) .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; @@ -741,7 +752,7 @@ impl DesktopStorage { &mut committer, ) .map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?; - let document = EntryDocumentService::new(&repository, &keys) + let document = EntryDocumentService::new(&repository, keys) .open(entry, provider) .map_err(DesktopError::document)?; Ok(DesktopOtpMutation { @@ -770,7 +781,7 @@ impl DesktopStorage { ) -> Result { let repository = self.repository()?; let keys = self.keys()?; - let mut document = EntryDocumentService::new(&repository, &keys) + let mut document = EntryDocumentService::new(&repository, keys) .open(entry, provider) .map_err(DesktopError::document)?; let field = otp_field(&document)?.ok_or_else(|| { @@ -781,7 +792,7 @@ impl DesktopStorage { })?; document.remove(field).map_err(DesktopError::document)?; self.save_document(&document)?; - let document = EntryDocumentService::new(&repository, &keys) + let document = EntryDocumentService::new(&repository, keys) .open(entry, provider) .map_err(DesktopError::document)?; Ok(DesktopOtpMutation { @@ -818,7 +829,7 @@ impl DesktopStorage { GitIdentity::ironstorage(), ) .map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?; - let mutator = TreeMutator::new(&repository, &keys); + let mutator = TreeMutator::new(&repository, keys); match request { DesktopMutationRequest::Remove(request) => { mutator.remove(request, overwrite, &mut committer) @@ -892,7 +903,7 @@ impl DesktopStorage { replacement: &replacement.config, persisted: false, }; - let outcome = RecipientPolicyManager::new(&repository, &keys) + let outcome = RecipientPolicyManager::new(&repository, keys) .apply_init(request, None, provider, &mut committer) .map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?; if !committer.persisted { @@ -922,7 +933,7 @@ impl DesktopStorage { ) -> Result { let repository = self.repository()?; let keys = self.keys()?; - if VaultWriter::new(&repository, &keys) + if VaultWriter::new(&repository, keys) .entry_exists(entry) .map_err(|error| DesktopError::new(DesktopErrorKind::Document, error))? { @@ -931,7 +942,7 @@ impl DesktopStorage { format!("password-store entry already exists: {entry}"), )); } - EntryDocumentService::new(&repository, &keys) + EntryDocumentService::new(&repository, keys) .open(entry, provider) .map_err(DesktopError::document) } @@ -943,7 +954,7 @@ impl DesktopStorage { ) -> Result { let repository = self.repository()?; let keys = self.keys()?; - EntryDocumentService::new(&repository, &keys) + EntryDocumentService::new(&repository, keys) .open(entry, secrets) .map_err(DesktopError::document) } @@ -955,7 +966,7 @@ impl DesktopStorage { let mut committer = AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage()) .map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?; - EntryDocumentService::new(&repository, &keys) + EntryDocumentService::new(&repository, keys) .save_recoverable(document, None, &mut committer) .map_err(DesktopError::document) } @@ -976,9 +987,17 @@ impl DesktopStorage { .map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error)) } - fn keys(&self) -> Result { - KeyStore::load(self.config.key_material()) - .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error)) + fn keys(&self) -> Result<&KeyStore, DesktopError> { + if let Some(keys) = self.keys.get() { + return Ok(keys); + } + let keys = KeyStore::load(self.config.key_material()) + .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; + let _already_initialized = self.keys.set(keys); + Ok(self + .keys + .get() + .expect("the key store was initialized by this or another caller")) } } diff --git a/crates/storage/src/document.rs b/crates/storage/src/document.rs index 91bbb94..b5a7bf9 100644 --- a/crates/storage/src/document.rs +++ b/crates/storage/src/document.rs @@ -172,7 +172,6 @@ impl EntryFieldDraft { pub fn field(name: impl Into, value: Vec) -> Result { let name = name.into(); validate_name(&name)?; - validate_line(&value)?; let value = SecretBytes::new(value); let mut contents = Vec::with_capacity(name.len() + value.expose().len() + 2); contents.extend_from_slice(name.as_bytes()); @@ -197,6 +196,12 @@ impl EntryFieldDraft { } } + pub fn multiline(contents: Vec) -> Self { + Self { + contents: SecretBytes::new(contents), + } + } + fn render(self) -> SecretBytes { self.contents } @@ -283,9 +288,8 @@ impl EntryDocument { let field = self.field(id).ok_or(DocumentError::UnknownField { id })?; let draft = match field.metadata().kind() { EntryFieldKind::OtpUri => EntryFieldDraft::otp_uri(value)?, - EntryFieldKind::Password | EntryFieldKind::Note | EntryFieldKind::Blank => { - EntryFieldDraft::line(value)? - } + EntryFieldKind::Password => EntryFieldDraft::line(value)?, + EntryFieldKind::Note | EntryFieldKind::Blank => EntryFieldDraft::multiline(value), EntryFieldKind::Username | EntryFieldKind::Email | EntryFieldKind::Url @@ -540,7 +544,32 @@ fn parse_lines(contents: &[u8]) -> Vec { }); start = end; } - fields + let mut logical = Vec::::new(); + for field in fields { + let continuation = classify(logical.len(), field.contents.expose()).kind + == EntryFieldKind::Note + && logical.last().is_some_and(|previous| { + let metadata = + classify(logical.len().saturating_sub(1), previous.contents.expose()); + metadata.name.is_some() && metadata.kind != EntryFieldKind::OtpUri + }); + if continuation { + let previous = logical + .last_mut() + .expect("continuation has a previous field"); + let mut contents = previous.contents.expose().to_vec(); + contents.extend_from_slice(&previous.ending); + contents.extend_from_slice(field.contents.expose()); + previous.contents = SecretBytes::new(contents); + previous.ending = field.ending; + } else { + logical.push(field); + } + } + for (id, field) in logical.iter_mut().enumerate() { + field.id = EntryFieldId(id as u64); + } + logical } fn classify_all(fields: &mut [EntryField]) { @@ -595,8 +624,13 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata { value: 0..line.len(), }; } - if let Some(colon) = line.iter().position(|byte| *byte == b':') { - let raw_name = trim_ascii(&line[..colon]); + let first_line_end = line + .iter() + .position(|byte| *byte == b'\n') + .unwrap_or(line.len()); + let first_line = &line[..first_line_end]; + if let Some(colon) = first_line.iter().position(|byte| *byte == b':') { + let raw_name = trim_ascii(&first_line[..colon]); if !raw_name.is_empty() && let Ok(name) = std::str::from_utf8(raw_name) { diff --git a/crates/storage/tests/entry_documents.rs b/crates/storage/tests/entry_documents.rs index e7d60a1..ca87936 100644 --- a/crates/storage/tests/entry_documents.rs +++ b/crates/storage/tests/entry_documents.rs @@ -79,7 +79,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { document.password().expect("password").value(), "pässwörd".as_bytes() ); - assert_eq!(document.fields().len(), 12); + assert_eq!(document.fields().len(), 11); let kinds = document .fields() @@ -100,7 +100,6 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { EntryFieldKind::Note, EntryFieldKind::Note, EntryFieldKind::Field, - EntryFieldKind::Note, ] ); assert_eq!(document.fields()[1].metadata().name(), Some("username")); @@ -125,7 +124,10 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { assert!(document.fields()[5].value().is_empty()); assert_ne!(document.fields()[4].id(), document.fields()[5].id()); assert_eq!(document.fields()[10].metadata().name(), Some("鍵")); - assert_eq!(document.fields()[10].value(), "值".as_bytes()); + assert_eq!( + document.fields()[10].value(), + "值\r\nunrecognized line".as_bytes() + ); assert_eq!( document.fields()[6].metadata().sensitivity(), EntrySensitivity::Sensitive @@ -148,6 +150,36 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult { Ok(()) } +#[test] +fn named_multiline_fields_are_one_lossless_logical_field() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut secrets = FixtureSecrets::all(&fixture); + let plaintext = b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n"; + write_plaintext(&repository, &keys, "documents/multiline", plaintext)?; + + let service = EntryDocumentService::new(&repository, &keys); + let mut document = service.open("documents/multiline", &mut secrets)?; + assert_eq!(document.serialize().expose(), plaintext); + assert_eq!(document.fields().len(), 3); + let comments = &document.fields()[1]; + assert_eq!(comments.metadata().name(), Some("comments")); + assert_eq!(comments.value(), b"Recovery codes:\none\ntwo"); + assert_eq!( + document.copy_field_value(comments.id())?.expose(), + b"Recovery codes:\none\ntwo" + ); + + document.replace_field_value(comments.id(), b"New heading\nalpha\nbeta".to_vec())?; + assert_eq!( + document.serialize().expose(), + b"password\ncomments: New heading\nalpha\nbeta\nurl: https://example.test\n" + ); + Ok(()) +} + #[test] fn pass_otp_only_entries_keep_typed_metadata_in_the_first_line() -> TestResult { let fixture = FixtureSet::load()?; @@ -189,7 +221,7 @@ fn field_ids_survive_updates_removal_and_reordering() -> TestResult { &repository, &keys, "documents/editable", - b"password\nusername: alice\nnote one\nnote two\n", + b"password\nusername: alice\nnote: one\nnote: two\n", )?; let service = EntryDocumentService::new(&repository, &keys); let mut document = service.open("documents/editable", &mut secrets)?; @@ -223,7 +255,7 @@ fn field_ids_survive_updates_removal_and_reordering() -> TestResult { assert_eq!(document.fields()[2].id(), note_two_id); assert_eq!( document.serialize().expose(), - "password\nusername: bob\nnote two\nemail: bob@例.test\n".as_bytes() + "password\nusername: bob\nnote: two\nemail: bob@例.test\n".as_bytes() ); let mut empty = service.open("documents/missing", &mut secrets)?; diff --git a/docs/desktop-audit.md b/docs/desktop-audit.md index 69aa653..fbe64a2 100644 --- a/docs/desktop-audit.md +++ b/docs/desktop-audit.md @@ -10,17 +10,17 @@ requires every registered action ID to remain present in this document. | Area and compatible operation | Registered desktop action and menu | Direct control, dialog, or view | Command palette | | --- | --- | --- | --- | -| Configuration and lock: open configured store | `open-folder` (File) | Open Folder button and native folder picker; storage validates and persists configuration | Yes | +| Configuration and lock: open configured store | `open-folder` (File) | Compact toolbar control and native folder picker; storage validates and persists configuration | Yes | | Configuration and lock: edit shared settings | `settings` (IronStorage) | Labelled Settings form for vault, default key, and inactivity timeout | Yes | -| Configuration and lock: refresh typed tree | `refresh` (View) | Sidebar Refresh button | Yes | -| Configuration and lock: lock/unlock | `lock` (Tools) | Lock button; opening protected content starts storage authentication | Lock only; unlock is the protected action being resumed | +| Configuration and lock: refresh typed tree | `refresh` (View) | Sidebar Refresh control | Yes | +| Configuration and lock: lock/unlock | `lock` (Tools) | Toolbar Lock control; opening protected content starts storage authentication | Lock only; unlock is the protected action being resumed | | Base pass: `init` root or nested recipient policy | `initialize-store`, `new-folder` (File) | Recipient/default-key form and explicit replacement confirmation | Yes | -| Base pass: default/list/`ls`/`list` | `refresh` (View) | Expandable, scrollable storage-provided sidebar tree | Yes for refresh; browsing is direct navigation | -| Base pass: `show` and reload | `open-entry`, `reload-entry` (Entry) | Entry path control, tree activation, structured viewer, Reload button | Yes | +| Base pass: default/list/`ls`/`list` | `refresh` (View) | Expandable storage-provided tree with object icons and descendant entry counts | Yes for refresh; browsing is direct navigation | +| Base pass: `show` and reload | `open-entry`, `reload-entry` (Entry) | Tree activation, typed identity header, structured viewer, Reload control | Yes | | Base pass: `insert`/`add` | `new-entry` (File) | New Entry form creates a lossless draft, then the structured editor saves it | Yes | -| Base pass: `edit` and save | `edit-entry`, `save` (Entry/File) | Edit and Save buttons; shared Save/Discard/Cancel guard | Yes | +| Base pass: `edit` and save | `edit-entry`, `save` (Entry/File) | Compact Edit and Save controls; shared Save/Discard/Cancel guard | Yes | | Base pass: `generate` and replace | `generate-password` (Entry) | Field Generate control and explicit replacement confirmation | Yes | -| Base pass: explicit secret display and clipboard | `toggle-reveal`, `copy-field`, `copy-edited-field` (Entry) | Per-field labelled Reveal/Hide and Copy controls | Yes | +| Base pass: authenticated values and clipboard | `copy-field`, `copy-edited-field` (Entry) | Every field is visible while unlocked; compact Copy controls retain the configured cleanup countdown | Yes | | Base pass: `find` | `find` (Edit) | Name-search form and typed result activation | Yes | | Base pass: `grep` | `search-contents` (Edit) | Authenticated decrypted-search form and typed result activation | Yes | | Base pass: `mv`/`rename`, `cp`/`copy`, `rm`/`remove` | `move-entry`, `copy-entry`, `delete-entry` (Entry) | Sidebar context controls and validated mutation forms; delete is confirmed | Yes | @@ -54,13 +54,12 @@ These are deliberate presentation differences, not storage-feature gaps. accelerators. The same controls are mouse/touch activatable; sidebar context actions also accept a secondary click. - Both panes are independently scrollable, the divider is resizable, action - rows wrap, long names and multiline values are retained, and the supported - narrow window floor is 720 by 480 logical pixels. Iced/winit applies native + rows wrap, long names and storage-grouped multiline values are retained, and + the supported narrow window floor is 480 by 360 logical pixels. Iced/winit applies native display scaling before layout. -- Interactive controls use visible, operation-specific text instead of icon- - only labels. Focused/selected controls use the theme's primary contrast pair; - light, dark, and operating-system high-contrast palettes retain a visible - text label as a non-colour focus cue. +- Compact toolbar controls use one 16-by-16 vector icon system with descriptive + delayed tooltips and registered shortcuts. Focused/selected rows use the + theme's primary contrast pair; field labels remain visible as a non-colour cue. - The app implements no animation or motion-driven state transition. The one- second subscription updates lease, OTP, Git, and clipboard presentation state without moving focus or renewing authentication, so reduced-motion mode has @@ -104,7 +103,7 @@ remain native-host smoke checks because CI cannot emulate those OS services. | Risk | Enforced behavior and executable evidence | | --- | --- | | Plaintext lifetime and persistence | Entry/OTP values use storage `SecretBytes` or zeroizing edit buffers. Lock, expiry, vault switch, and stale completion paths drop the editor and sensitive presentation state. The source audit rejects desktop filesystem writes. | -| Masking, errors, and diagnostics | Storage supplies sensitivity and redacted typed errors. Viewer/editor tests require masking until explicit reveal; malformed and non-UTF-8 fields remain lossless. Desktop messages are not `Debug`, and the source audit rejects print/debug/log-style output. | +| Authenticated values, errors, and diagnostics | Storage supplies sensitivity and redacted typed errors. Viewer/editor tests require every field to remain visible until lock or lease expiry; malformed and non-UTF-8 fields remain lossless. Desktop messages are not `Debug`, and the source audit rejects print/debug/log-style output. | | Clipboard | `NativeClipboardManager` owns timeout and replacement-safe cleanup. Desktop state shows a live remaining-seconds value, cancels cleanup on lock, and ignores stale completions. | | Authentication expiry | Storage authentication leases own the clock and policy. Passive ticks, rendering, pointer movement, and window events do not renew activity; deterministic tests cover expiry during protected state. | | Dirty documents and conflicts | Every entry/vault/window/Git worktree replacement routes through one Save/Discard/Cancel decision. Failed saves and conflicts keep the complete draft. |