Revamp desktop usability and multiline fields

This commit is contained in:
2026-08-10 22:06:16 +02:00
parent 40b15a9713
commit aeb18b488f
12 changed files with 1283 additions and 545 deletions

View File

@@ -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";

View File

@@ -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,

View File

@@ -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<EntryFieldId>,
focused: Option<EntryFieldId>,
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<EntryFieldId> {
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<std::ops::Range<usize>> {
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<usize> {
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()

File diff suppressed because it is too large Load Diff

View File

@@ -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<Self, muda::Error> {
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<Accelerator> {
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<Accelerator> {
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

View File

@@ -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<TreeNodeId>,
}
@@ -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),

View File

@@ -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]

View File

@@ -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::<usize>();
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::<Vec<_>>();
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::<Vec<_>>();
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<Line<'_>> {
@@ -784,29 +821,38 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
)];
}
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<Line<'_>> {
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<Line<'_>> {
@@ -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(

View File

@@ -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<OnceLock<KeyStore>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -299,9 +305,12 @@ impl DesktopMutationRequest {
impl DesktopStorage {
pub fn load(explicit: Option<&Path>) -> Result<Self, DesktopError> {
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<DesktopBootstrap, DesktopError> {
@@ -309,8 +318,7 @@ impl DesktopStorage {
}
pub fn bootstrap(self) -> Result<DesktopBootstrap, DesktopError> {
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<TreeModel, DesktopError> {
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<FindResults, DesktopError> {
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<GrepResults, DesktopError> {
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<DesktopOtpCode, DesktopError> {
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<DesktopOtpUri, DesktopError> {
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<DesktopOtpMutation, DesktopError> {
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<EntryDocument, DesktopError> {
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<EntryDocument, DesktopError> {
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, DesktopError> {
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"))
}
}

View File

@@ -172,7 +172,6 @@ impl EntryFieldDraft {
pub fn field(name: impl Into<String>, value: Vec<u8>) -> Result<Self, DocumentError> {
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<u8>) -> 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<EntryField> {
});
start = end;
}
fields
let mut logical = Vec::<EntryField>::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)
{

View File

@@ -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)?;

View File

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