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(