Revamp desktop usability and multiline fields
This commit is contained in:
@@ -1534,7 +1534,7 @@ mod tests {
|
|||||||
RemoveRequest, ShowRequest,
|
RemoveRequest, ShowRequest,
|
||||||
},
|
},
|
||||||
config::Config,
|
config::Config,
|
||||||
crypto::KeyInfo,
|
crypto::{KeyInfo, KeyStore},
|
||||||
git::{GitCredentialProvider as _, GitIdentity, GitRepository},
|
git::{GitCredentialProvider as _, GitIdentity, GitRepository},
|
||||||
otp::OtpInput,
|
otp::OtpInput,
|
||||||
presentation::{ClipboardTimeout, QrMatrix},
|
presentation::{ClipboardTimeout, QrMatrix},
|
||||||
@@ -1903,6 +1903,55 @@ mod tests {
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn cli_prompts_for_a_missing_openpgp_passphrase_then_stores_and_reuses_it() -> TestResult {
|
fn cli_prompts_for_a_missing_openpgp_passphrase_then_stores_and_reuses_it() -> TestResult {
|
||||||
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ pub enum UiAction {
|
|||||||
MoveEntry,
|
MoveEntry,
|
||||||
CopyEntry,
|
CopyEntry,
|
||||||
DeleteEntry,
|
DeleteEntry,
|
||||||
ToggleReveal,
|
|
||||||
GenerateOtp,
|
GenerateOtp,
|
||||||
CopyOtp,
|
CopyOtp,
|
||||||
ImportOtp,
|
ImportOtp,
|
||||||
@@ -104,7 +103,6 @@ impl UiAction {
|
|||||||
Self::MoveEntry => "move-entry",
|
Self::MoveEntry => "move-entry",
|
||||||
Self::CopyEntry => "copy-entry",
|
Self::CopyEntry => "copy-entry",
|
||||||
Self::DeleteEntry => "delete-entry",
|
Self::DeleteEntry => "delete-entry",
|
||||||
Self::ToggleReveal => "toggle-reveal",
|
|
||||||
Self::GenerateOtp => "generate-otp",
|
Self::GenerateOtp => "generate-otp",
|
||||||
Self::CopyOtp => "copy-otp",
|
Self::CopyOtp => "copy-otp",
|
||||||
Self::ImportOtp => "import-otp",
|
Self::ImportOtp => "import-otp",
|
||||||
@@ -173,7 +171,6 @@ pub struct ActionContext {
|
|||||||
pub switching_vault: bool,
|
pub switching_vault: bool,
|
||||||
pub modal_open: bool,
|
pub modal_open: bool,
|
||||||
pub focused_field: bool,
|
pub focused_field: bool,
|
||||||
pub focused_sensitive: bool,
|
|
||||||
pub focused_generatable: bool,
|
pub focused_generatable: bool,
|
||||||
pub focused_otp: bool,
|
pub focused_otp: bool,
|
||||||
pub entry_path: bool,
|
pub entry_path: bool,
|
||||||
@@ -198,7 +195,12 @@ pub const ACTIONS: &[ActionSpec] = &[
|
|||||||
"Initialize Store…",
|
"Initialize Store…",
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
spec(UiAction::NewFolder, MenuGroup::File, "New Folder…", None),
|
spec(
|
||||||
|
UiAction::NewFolder,
|
||||||
|
MenuGroup::File,
|
||||||
|
"New Folder…",
|
||||||
|
Some("⇧⌘N"),
|
||||||
|
),
|
||||||
spec(
|
spec(
|
||||||
UiAction::Quit,
|
UiAction::Quit,
|
||||||
MenuGroup::App,
|
MenuGroup::App,
|
||||||
@@ -223,12 +225,17 @@ pub const ACTIONS: &[ActionSpec] = &[
|
|||||||
spec(UiAction::Undo, MenuGroup::Edit, "Undo", Some("⌘Z")),
|
spec(UiAction::Undo, MenuGroup::Edit, "Undo", Some("⌘Z")),
|
||||||
spec(UiAction::Redo, MenuGroup::Edit, "Redo", Some("⇧⌘Z")),
|
spec(UiAction::Redo, MenuGroup::Edit, "Redo", Some("⇧⌘Z")),
|
||||||
spec(UiAction::Cut, MenuGroup::Edit, "Cut", Some("⌘X")),
|
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(
|
spec(
|
||||||
UiAction::CopyEditedField,
|
UiAction::CopyEditedField,
|
||||||
MenuGroup::Entry,
|
MenuGroup::Entry,
|
||||||
"Copy Edited Field",
|
"Copy Edited Field",
|
||||||
None,
|
Some("⌘C"),
|
||||||
),
|
),
|
||||||
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
|
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
|
||||||
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
|
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
|
||||||
@@ -255,9 +262,14 @@ pub const ACTIONS: &[ActionSpec] = &[
|
|||||||
UiAction::ReloadEntry,
|
UiAction::ReloadEntry,
|
||||||
MenuGroup::Entry,
|
MenuGroup::Entry,
|
||||||
"Reload Entry",
|
"Reload Entry",
|
||||||
None,
|
Some("⇧⌘R"),
|
||||||
|
),
|
||||||
|
spec(
|
||||||
|
UiAction::EditEntry,
|
||||||
|
MenuGroup::Entry,
|
||||||
|
"Edit Entry",
|
||||||
|
Some("⌘E"),
|
||||||
),
|
),
|
||||||
spec(UiAction::EditEntry, MenuGroup::Entry, "Edit Entry", None),
|
|
||||||
spec(
|
spec(
|
||||||
UiAction::GeneratePassword,
|
UiAction::GeneratePassword,
|
||||||
MenuGroup::Entry,
|
MenuGroup::Entry,
|
||||||
@@ -272,12 +284,6 @@ pub const ACTIONS: &[ActionSpec] = &[
|
|||||||
),
|
),
|
||||||
spec(UiAction::CopyEntry, MenuGroup::Entry, "Copy Entry…", None),
|
spec(UiAction::CopyEntry, MenuGroup::Entry, "Copy Entry…", None),
|
||||||
spec(UiAction::DeleteEntry, MenuGroup::Entry, "Delete…", None),
|
spec(UiAction::DeleteEntry, MenuGroup::Entry, "Delete…", None),
|
||||||
spec(
|
|
||||||
UiAction::ToggleReveal,
|
|
||||||
MenuGroup::Entry,
|
|
||||||
"Reveal or Hide Field",
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
spec(
|
spec(
|
||||||
UiAction::GenerateOtp,
|
UiAction::GenerateOtp,
|
||||||
MenuGroup::Entry,
|
MenuGroup::Entry,
|
||||||
@@ -434,12 +440,6 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
|||||||
&& !context.switching_vault
|
&& !context.switching_vault
|
||||||
&& !context.modal_open
|
&& !context.modal_open
|
||||||
}
|
}
|
||||||
UiAction::ToggleReveal => {
|
|
||||||
context.unlocked
|
|
||||||
&& context.document_open
|
|
||||||
&& !context.switching_vault
|
|
||||||
&& context.focused_sensitive
|
|
||||||
}
|
|
||||||
UiAction::GenerateOtp
|
UiAction::GenerateOtp
|
||||||
| UiAction::CopyOtp
|
| UiAction::CopyOtp
|
||||||
| UiAction::ShowOtpUri
|
| UiAction::ShowOtpUri
|
||||||
@@ -577,10 +577,6 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
|
|||||||
UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry => {
|
UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry => {
|
||||||
"Close the current screen first"
|
"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::GenerateOtp
|
||||||
| UiAction::CopyOtp
|
| UiAction::CopyOtp
|
||||||
| UiAction::ShowOtpUri
|
| UiAction::ShowOtpUri
|
||||||
@@ -696,7 +692,6 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] {
|
|||||||
UiAction::MoveEntry => &["rename", "mv", "move folder"],
|
UiAction::MoveEntry => &["rename", "mv", "move folder"],
|
||||||
UiAction::CopyEntry => &["duplicate entry", "copy folder", "pass cp"],
|
UiAction::CopyEntry => &["duplicate entry", "copy folder", "pass cp"],
|
||||||
UiAction::DeleteEntry => &["remove", "rm", "delete folder"],
|
UiAction::DeleteEntry => &["remove", "rm", "delete folder"],
|
||||||
UiAction::ToggleReveal => &["show password", "hide password", "reveal field"],
|
|
||||||
UiAction::GenerateOtp => &["totp", "hotp", "one time password"],
|
UiAction::GenerateOtp => &["totp", "hotp", "one time password"],
|
||||||
UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"],
|
UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"],
|
||||||
UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"],
|
UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"],
|
||||||
@@ -734,6 +729,7 @@ pub fn shortcut_action_for(
|
|||||||
}
|
}
|
||||||
match key.as_ref() {
|
match key.as_ref() {
|
||||||
keyboard::Key::Character(",") => Some(UiAction::Settings),
|
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("n" | "N") => Some(UiAction::NewEntry),
|
||||||
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenFolder),
|
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenFolder),
|
||||||
keyboard::Key::Character("s" | "S") => Some(UiAction::Save),
|
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") if modifiers.shift() => Some(UiAction::SearchContents),
|
||||||
keyboard::Key::Character("f" | "F") => Some(UiAction::Find),
|
keyboard::Key::Character("f" | "F") => Some(UiAction::Find),
|
||||||
keyboard::Key::Character("k" | "K") => Some(UiAction::CommandPalette),
|
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("r" | "R") => Some(UiAction::Refresh),
|
||||||
|
keyboard::Key::Character("e" | "E") => Some(UiAction::EditEntry),
|
||||||
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
|
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
|
||||||
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
|
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -772,7 +770,6 @@ mod tests {
|
|||||||
switching_vault: false,
|
switching_vault: false,
|
||||||
modal_open: false,
|
modal_open: false,
|
||||||
focused_field: true,
|
focused_field: true,
|
||||||
focused_sensitive: true,
|
|
||||||
focused_generatable: true,
|
focused_generatable: true,
|
||||||
focused_otp: true,
|
focused_otp: true,
|
||||||
entry_path: true,
|
entry_path: true,
|
||||||
@@ -804,7 +801,6 @@ mod tests {
|
|||||||
assert!(enabled(UiAction::CopyEditedField, ready));
|
assert!(enabled(UiAction::CopyEditedField, ready));
|
||||||
assert!(enabled(UiAction::GeneratePassword, ready));
|
assert!(enabled(UiAction::GeneratePassword, ready));
|
||||||
assert!(!enabled(UiAction::CopyField, ready));
|
assert!(!enabled(UiAction::CopyField, ready));
|
||||||
assert!(enabled(UiAction::ToggleReveal, ready));
|
|
||||||
for action in [
|
for action in [
|
||||||
UiAction::GitStatus,
|
UiAction::GitStatus,
|
||||||
UiAction::GitPull,
|
UiAction::GitPull,
|
||||||
@@ -844,7 +840,6 @@ mod tests {
|
|||||||
UiAction::Save,
|
UiAction::Save,
|
||||||
UiAction::CopyField,
|
UiAction::CopyField,
|
||||||
UiAction::CopyEditedField,
|
UiAction::CopyEditedField,
|
||||||
UiAction::ToggleReveal,
|
|
||||||
UiAction::GeneratePassword,
|
UiAction::GeneratePassword,
|
||||||
UiAction::GenerateOtp,
|
UiAction::GenerateOtp,
|
||||||
UiAction::CopyOtp,
|
UiAction::CopyOtp,
|
||||||
@@ -908,7 +903,6 @@ mod tests {
|
|||||||
UiAction::ReloadEntry,
|
UiAction::ReloadEntry,
|
||||||
UiAction::EditEntry,
|
UiAction::EditEntry,
|
||||||
UiAction::GeneratePassword,
|
UiAction::GeneratePassword,
|
||||||
UiAction::ToggleReveal,
|
|
||||||
UiAction::Find,
|
UiAction::Find,
|
||||||
UiAction::SearchContents,
|
UiAction::SearchContents,
|
||||||
UiAction::MoveEntry,
|
UiAction::MoveEntry,
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
//! Structured desktop editing state over storage-owned entry documents.
|
//! Structured desktop editing state over storage-owned entry documents.
|
||||||
|
|
||||||
use std::{collections::BTreeSet, fmt};
|
use std::fmt;
|
||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
document::{
|
document::{DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId},
|
||||||
DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId, EntrySensitivity,
|
|
||||||
},
|
|
||||||
repository::SecretBytes,
|
repository::SecretBytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct EntryEditor {
|
pub struct EntryEditor {
|
||||||
document: EntryDocument,
|
document: EntryDocument,
|
||||||
revealed: BTreeSet<EntryFieldId>,
|
|
||||||
focused: Option<EntryFieldId>,
|
focused: Option<EntryFieldId>,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
}
|
}
|
||||||
@@ -29,7 +26,6 @@ impl EntryEditor {
|
|||||||
let focused = document.fields().first().map(EntryField::id);
|
let focused = document.fields().first().map(EntryField::id);
|
||||||
Self {
|
Self {
|
||||||
document,
|
document,
|
||||||
revealed: BTreeSet::new(),
|
|
||||||
focused,
|
focused,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
}
|
}
|
||||||
@@ -66,10 +62,6 @@ impl EntryEditor {
|
|||||||
self.dirty
|
self.dirty
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_revealed(&self, id: EntryFieldId) -> bool {
|
|
||||||
self.revealed.contains(&id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn focused(&self) -> Option<EntryFieldId> {
|
pub fn focused(&self) -> Option<EntryFieldId> {
|
||||||
self.focused
|
self.focused
|
||||||
}
|
}
|
||||||
@@ -108,20 +100,7 @@ impl EntryEditor {
|
|||||||
self.focused = Some(fields[index].id());
|
self.focused = Some(fields[index].id());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_reveal(&mut self, id: EntryFieldId) -> Result<(), DocumentError> {
|
#[cfg(test)]
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_raw(&mut self, id: EntryFieldId, value: &[u8]) -> Result<(), DocumentError> {
|
pub fn update_raw(&mut self, id: EntryFieldId, value: &[u8]) -> Result<(), DocumentError> {
|
||||||
let unchanged = self
|
let unchanged = self
|
||||||
.document
|
.document
|
||||||
@@ -131,7 +110,44 @@ impl EntryEditor {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.document
|
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;
|
self.dirty = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -158,7 +174,6 @@ impl EntryEditor {
|
|||||||
.position(|field| field.id() == id)
|
.position(|field| field.id() == id)
|
||||||
.ok_or(DocumentError::UnknownField { id })?;
|
.ok_or(DocumentError::UnknownField { id })?;
|
||||||
self.document.remove(id)?;
|
self.document.remove(id)?;
|
||||||
self.revealed.remove(&id);
|
|
||||||
if self.focused == Some(id) {
|
if self.focused == Some(id) {
|
||||||
self.focused = self
|
self.focused = self
|
||||||
.document
|
.document
|
||||||
@@ -209,7 +224,6 @@ impl EntryEditor {
|
|||||||
) -> Result<(), DocumentError> {
|
) -> Result<(), DocumentError> {
|
||||||
self.document
|
self.document
|
||||||
.replace_field_value(id, value.expose().to_vec())?;
|
.replace_field_value(id, value.expose().to_vec())?;
|
||||||
self.revealed.remove(&id);
|
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
Ok(())
|
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 {
|
impl fmt::Debug for EntryEditor {
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
formatter
|
formatter
|
||||||
.debug_struct("EntryEditor")
|
.debug_struct("EntryEditor")
|
||||||
.field("document", &self.document)
|
.field("document", &self.document)
|
||||||
.field("revealed", &self.revealed)
|
|
||||||
.field("focused", &self.focused)
|
.field("focused", &self.focused)
|
||||||
.field("dirty", &self.dirty)
|
.field("dirty", &self.dirty)
|
||||||
.finish()
|
.finish()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,9 @@ use muda::{
|
|||||||
use crate::action::{self, ActionContext, MenuGroup, UiAction};
|
use crate::action::{self, ActionContext, MenuGroup, UiAction};
|
||||||
|
|
||||||
pub struct NativeMenu {
|
pub struct NativeMenu {
|
||||||
_menu: Menu,
|
menu: Menu,
|
||||||
|
window_menu: Submenu,
|
||||||
|
help_menu: Submenu,
|
||||||
items: Vec<(UiAction, MenuItem)>,
|
items: Vec<(UiAction, MenuItem)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ impl NativeMenu {
|
|||||||
pub fn install(context: ActionContext) -> Result<Self, muda::Error> {
|
pub fn install(context: ActionContext) -> Result<Self, muda::Error> {
|
||||||
let menu = Menu::new();
|
let menu = Menu::new();
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
let mut window_menu = None;
|
||||||
|
let mut help_menu = None;
|
||||||
for group in MenuGroup::ALL {
|
for group in MenuGroup::ALL {
|
||||||
let submenu = Submenu::new(group.label(), true);
|
let submenu = Submenu::new(group.label(), true);
|
||||||
match group {
|
match group {
|
||||||
@@ -50,10 +54,27 @@ impl NativeMenu {
|
|||||||
}
|
}
|
||||||
_ => append_actions(&submenu, group, context, &mut items)?,
|
_ => 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.append(&submenu)?;
|
||||||
}
|
}
|
||||||
menu.init_for_nsapp();
|
let native = Self {
|
||||||
Ok(Self { _menu: menu, items })
|
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) {
|
pub fn sync(&self, context: ActionContext) {
|
||||||
@@ -117,6 +138,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
|||||||
let (modifiers, code) = match action {
|
let (modifiers, code) = match action {
|
||||||
UiAction::Settings => (command, Code::Comma),
|
UiAction::Settings => (command, Code::Comma),
|
||||||
UiAction::NewEntry => (command, Code::KeyN),
|
UiAction::NewEntry => (command, Code::KeyN),
|
||||||
|
UiAction::NewFolder => (command | Modifiers::SHIFT, Code::KeyN),
|
||||||
UiAction::OpenFolder => (command, Code::KeyO),
|
UiAction::OpenFolder => (command, Code::KeyO),
|
||||||
UiAction::Save => (command, Code::KeyS),
|
UiAction::Save => (command, Code::KeyS),
|
||||||
UiAction::CloseWindow => (command, Code::KeyW),
|
UiAction::CloseWindow => (command, Code::KeyW),
|
||||||
@@ -129,22 +151,20 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
|
|||||||
UiAction::SearchContents => (command | Modifiers::SHIFT, Code::KeyF),
|
UiAction::SearchContents => (command | Modifiers::SHIFT, Code::KeyF),
|
||||||
UiAction::CommandPalette => (command, Code::KeyK),
|
UiAction::CommandPalette => (command, Code::KeyK),
|
||||||
UiAction::Refresh => (command, Code::KeyR),
|
UiAction::Refresh => (command, Code::KeyR),
|
||||||
|
UiAction::ReloadEntry => (command | Modifiers::SHIFT, Code::KeyR),
|
||||||
|
UiAction::EditEntry => (command, Code::KeyE),
|
||||||
UiAction::Lock => (command, Code::KeyL),
|
UiAction::Lock => (command, Code::KeyL),
|
||||||
UiAction::Help => (Modifiers::empty(), Code::F1),
|
UiAction::Help => (Modifiers::empty(), Code::F1),
|
||||||
UiAction::About
|
UiAction::About
|
||||||
| UiAction::InitializeStore
|
| UiAction::InitializeStore
|
||||||
| UiAction::NewFolder
|
|
||||||
| UiAction::CopyField
|
| UiAction::CopyField
|
||||||
| UiAction::CopyEditedField
|
| UiAction::CopyEditedField
|
||||||
| UiAction::TogglePaneFocus
|
| UiAction::TogglePaneFocus
|
||||||
| UiAction::OpenEntry
|
| UiAction::OpenEntry
|
||||||
| UiAction::ReloadEntry
|
|
||||||
| UiAction::EditEntry
|
|
||||||
| UiAction::GeneratePassword
|
| UiAction::GeneratePassword
|
||||||
| UiAction::MoveEntry
|
| UiAction::MoveEntry
|
||||||
| UiAction::CopyEntry
|
| UiAction::CopyEntry
|
||||||
| UiAction::DeleteEntry
|
| UiAction::DeleteEntry
|
||||||
| UiAction::ToggleReveal
|
|
||||||
| UiAction::GenerateOtp
|
| UiAction::GenerateOtp
|
||||||
| UiAction::CopyOtp
|
| UiAction::CopyOtp
|
||||||
| UiAction::ImportOtp
|
| UiAction::ImportOtp
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ impl NavigationNode {
|
|||||||
children: node.children().iter().map(Self::from_storage).collect(),
|
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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -51,6 +59,7 @@ pub struct NavigationRow {
|
|||||||
pub depth: usize,
|
pub depth: usize,
|
||||||
pub expanded: bool,
|
pub expanded: bool,
|
||||||
pub has_children: bool,
|
pub has_children: bool,
|
||||||
|
pub entry_count: usize,
|
||||||
parent: Option<TreeNodeId>,
|
parent: Option<TreeNodeId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +272,7 @@ fn flatten(
|
|||||||
depth,
|
depth,
|
||||||
expanded: is_expanded,
|
expanded: is_expanded,
|
||||||
has_children: !node.children.is_empty(),
|
has_children: !node.children.is_empty(),
|
||||||
|
entry_count: node.entry_count(),
|
||||||
parent: parent.cloned(),
|
parent: parent.cloned(),
|
||||||
});
|
});
|
||||||
if node.id.is_directory() && is_expanded {
|
if node.id.is_directory() && is_expanded {
|
||||||
@@ -387,7 +397,10 @@ mod tests {
|
|||||||
Some(Path::new("personal"))
|
Some(Path::new("personal"))
|
||||||
);
|
);
|
||||||
assert_eq!(tree.navigate(NavigationKey::Expand), NavigationIntent::None);
|
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::Expand), NavigationIntent::None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tree.navigate(NavigationKey::Activate),
|
tree.navigate(NavigationKey::Activate),
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ use ironstorage::{
|
|||||||
|
|
||||||
/// Presentation state for editing one storage-owned document.
|
/// Presentation state for editing one storage-owned document.
|
||||||
///
|
///
|
||||||
/// The focused buffer contains one complete pass entry line. Committing that
|
/// The focused buffer contains one complete storage-owned logical field.
|
||||||
/// buffer delegates classification back to `EntryDocument`, so names, duplicate
|
/// Committing it delegates classification back to `EntryDocument`, so names,
|
||||||
/// fields, notes, and OTP URI recognition remain storage behavior.
|
/// multiline values, duplicate fields, notes, and OTP URI recognition remain
|
||||||
|
/// storage behavior.
|
||||||
pub struct EntryEditor {
|
pub struct EntryEditor {
|
||||||
document: EntryDocument,
|
document: EntryDocument,
|
||||||
focused: usize,
|
focused: usize,
|
||||||
@@ -261,6 +262,22 @@ impl EntryEditor {
|
|||||||
let Some(field) = self.focused_field() else {
|
let Some(field) = self.focused_field() else {
|
||||||
return Ok(());
|
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 id = field.id();
|
||||||
let left = self.buffer.expose()[..self.cursor].to_vec();
|
let left = self.buffer.expose()[..self.cursor].to_vec();
|
||||||
let right = self.buffer.expose()[self.cursor..].to_vec();
|
let right = self.buffer.expose()[self.cursor..].to_vec();
|
||||||
@@ -281,8 +298,10 @@ impl EntryEditor {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let id = field.id();
|
let id = field.id();
|
||||||
self.document
|
self.document.update(
|
||||||
.update(id, EntryFieldDraft::line(self.buffer.expose().to_vec())?)?;
|
id,
|
||||||
|
EntryFieldDraft::multiline(self.buffer.expose().to_vec()),
|
||||||
|
)?;
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -303,8 +322,7 @@ impl EntryEditor {
|
|||||||
let Some(id) = self.focused_field().map(EntryField::id) else {
|
let Some(id) = self.focused_field().map(EntryField::id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let draft = EntryFieldDraft::line(self.buffer.expose().to_vec())
|
let draft = EntryFieldDraft::multiline(self.buffer.expose().to_vec());
|
||||||
.expect("the single-line editor never inserts line endings into its buffer");
|
|
||||||
self.document
|
self.document
|
||||||
.update(id, draft)
|
.update(id, draft)
|
||||||
.expect("the focused field identifier belongs to the document");
|
.expect("the focused field identifier belongs to the document");
|
||||||
@@ -376,7 +394,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 mut editor = EntryEditor::new(fixture_document("email/personal"));
|
||||||
let initial = editor.document().fields().len();
|
let initial = editor.document().fields().len();
|
||||||
editor.add_after_focused().expect("add");
|
editor.add_after_focused().expect("add");
|
||||||
@@ -385,11 +403,15 @@ mod tests {
|
|||||||
editor.insert_character('b');
|
editor.insert_character('b');
|
||||||
editor.move_cursor_left();
|
editor.move_cursor_left();
|
||||||
editor.split_line().expect("split");
|
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.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]
|
#[test]
|
||||||
|
|||||||
@@ -332,10 +332,19 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect, capability: ColorCap
|
|||||||
Mode::Editor => app.editor().map_or_else(
|
Mode::Editor => app.editor().map_or_else(
|
||||||
|| Paragraph::new(main_text(app)),
|
|| Paragraph::new(main_text(app)),
|
||||||
|editor| {
|
|editor| {
|
||||||
Paragraph::new(editor_lines(editor)).scroll((
|
let focused = editor.focused_index().unwrap_or_default();
|
||||||
u16::try_from(editor.focused_index().unwrap_or_default()).unwrap_or(u16::MAX),
|
let scroll = editor
|
||||||
0,
|
.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() => {
|
Mode::Browser if app.git_view().is_some() => {
|
||||||
@@ -524,8 +533,22 @@ fn viewer_scroll_in_content(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let focused = viewer.focused_index().unwrap_or_default();
|
let focused = viewer.focused_index().unwrap_or_default();
|
||||||
let focused_start = row_heights.iter().take(focused).sum();
|
let field_heights = viewer
|
||||||
let focused_height = row_heights.get(focused).copied().unwrap_or(1);
|
.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 total_rows = row_heights.iter().sum();
|
||||||
let scroll = viewer.ensure_focus_visible(
|
let scroll = viewer.ensure_focus_visible(
|
||||||
focused_start,
|
focused_start,
|
||||||
@@ -694,12 +717,8 @@ fn viewer_lines<'a>(
|
|||||||
return vec![Line::from("This entry is empty.")];
|
return vec![Line::from("This entry is empty.")];
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer
|
let mut lines = Vec::new();
|
||||||
.document()
|
for (index, field) in viewer.document().fields().iter().enumerate() {
|
||||||
.fields()
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, field)| {
|
|
||||||
let metadata = field.metadata();
|
let metadata = field.metadata();
|
||||||
let label = metadata.name().map_or_else(
|
let label = metadata.name().map_or_else(
|
||||||
|| match metadata.kind() {
|
|| match metadata.kind() {
|
||||||
@@ -709,24 +728,41 @@ fn viewer_lines<'a>(
|
|||||||
},
|
},
|
||||||
str::to_owned,
|
str::to_owned,
|
||||||
);
|
);
|
||||||
let value = if field.value().is_empty() {
|
let values = std::str::from_utf8(field.value()).ok();
|
||||||
Span::styled("(empty)", Style::default().fg(Color::DarkGray))
|
let value_lines = values.map_or(1, |value| value.split('\n').count());
|
||||||
} else {
|
for line_index in 0..value_lines {
|
||||||
match std::str::from_utf8(field.value()) {
|
let mut spans = Vec::new();
|
||||||
Ok(value) => Span::raw(value),
|
if line_index == 0 {
|
||||||
Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)),
|
spans.push(Span::styled(
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut spans = vec![
|
|
||||||
Span::styled(
|
|
||||||
format!("{label}: "),
|
format!("{label}: "),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(ACCENT_COLOR)
|
.fg(ACCENT_COLOR)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
));
|
||||||
value,
|
} else {
|
||||||
];
|
spans.push(Span::raw(" "));
|
||||||
if let Some(otp) = metadata.otp() {
|
}
|
||||||
|
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(
|
let timing = otp.period().map_or_else(
|
||||||
|| format!("counter {}", otp.counter().unwrap_or_default()),
|
|| format!("counter {}", otp.counter().unwrap_or_default()),
|
||||||
|period| format!("period {period}s"),
|
|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)
|
selected_line(spans)
|
||||||
} else {
|
} else {
|
||||||
Line::from(spans)
|
Line::from(spans)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.collect()
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
||||||
@@ -784,12 +821,8 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
|||||||
)];
|
)];
|
||||||
}
|
}
|
||||||
|
|
||||||
editor
|
let mut lines = Vec::new();
|
||||||
.document()
|
for (index, field) in editor.document().fields().iter().enumerate() {
|
||||||
.fields()
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, field)| {
|
|
||||||
let selected = focused == Some(index);
|
let selected = focused == Some(index);
|
||||||
let contents = editor
|
let contents = editor
|
||||||
.focused_contents(field.id())
|
.focused_contents(field.id())
|
||||||
@@ -798,15 +831,28 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
|||||||
|| format!("{:?}", field.metadata().kind()),
|
|| format!("{:?}", field.metadata().kind()),
|
||||||
|name| format!("{:?} ({name})", 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(
|
let mut spans = vec![Span::styled(
|
||||||
format!("#{:02} {label}: ", index + 1),
|
if line_index == 0 {
|
||||||
|
format!("#{:02} {label}: ", index + 1)
|
||||||
|
} else {
|
||||||
|
" ".to_owned()
|
||||||
|
},
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(ACCENT_COLOR)
|
.fg(ACCENT_COLOR)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)];
|
)];
|
||||||
if let Ok(value) = std::str::from_utf8(contents) {
|
let cursor = editor.cursor().saturating_sub(offset);
|
||||||
if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) {
|
if selected
|
||||||
let (before, after) = value.split_at(editor.cursor());
|
&& 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::raw(before));
|
||||||
spans.push(Span::styled("▏", Style::default().fg(Color::Yellow)));
|
spans.push(Span::styled("▏", Style::default().fg(Color::Yellow)));
|
||||||
spans.push(Span::raw(after));
|
spans.push(Span::raw(after));
|
||||||
@@ -816,21 +862,36 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
|||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
spans.push(Span::raw(value));
|
spans.push(Span::raw(value_line));
|
||||||
}
|
}
|
||||||
} else {
|
lines.push(if selected {
|
||||||
spans.push(Span::styled(
|
|
||||||
"[non-UTF-8 field; editing will preserve bytes]",
|
|
||||||
Style::default().fg(Color::Yellow),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if selected {
|
|
||||||
selected_line(spans)
|
selected_line(spans)
|
||||||
} else {
|
} else {
|
||||||
Line::from(spans)
|
Line::from(spans)
|
||||||
|
});
|
||||||
|
offset += value_line.len() + 1;
|
||||||
}
|
}
|
||||||
})
|
} else {
|
||||||
.collect()
|
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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
lines.push(if selected {
|
||||||
|
selected_line(spans)
|
||||||
|
} else {
|
||||||
|
Line::from(spans)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
|
fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
|
||||||
@@ -1512,6 +1573,36 @@ mod tests {
|
|||||||
assert!(!format!("{app:?}").contains("pässwörd-猫"));
|
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]
|
#[test]
|
||||||
fn authenticated_viewer_renders_every_value_until_entry_close_or_relock() {
|
fn authenticated_viewer_renders_every_value_until_entry_close_or_relock() {
|
||||||
let (_store, document) = fixture_document_from_plaintext(
|
let (_store, document) = fixture_document_from_plaintext(
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
//! Storage-owned service boundary for the Iced desktop presentation adapter.
|
//! 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::{
|
use crate::{
|
||||||
authentication::{
|
authentication::{
|
||||||
@@ -120,6 +125,7 @@ impl Error for DesktopError {}
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DesktopStorage {
|
pub struct DesktopStorage {
|
||||||
config: Config,
|
config: Config,
|
||||||
|
keys: Arc<OnceLock<KeyStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -299,9 +305,12 @@ impl DesktopMutationRequest {
|
|||||||
|
|
||||||
impl DesktopStorage {
|
impl DesktopStorage {
|
||||||
pub fn load(explicit: Option<&Path>) -> Result<Self, DesktopError> {
|
pub fn load(explicit: Option<&Path>) -> Result<Self, DesktopError> {
|
||||||
Config::load(explicit)
|
let config = Config::load(explicit)
|
||||||
.map(|config| Self { config })
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
keys: Arc::new(OnceLock::new()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system() -> Result<DesktopBootstrap, DesktopError> {
|
pub fn system() -> Result<DesktopBootstrap, DesktopError> {
|
||||||
@@ -309,8 +318,7 @@ impl DesktopStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn bootstrap(self) -> Result<DesktopBootstrap, DesktopError> {
|
pub fn bootstrap(self) -> Result<DesktopBootstrap, DesktopError> {
|
||||||
let keys = KeyStore::load(self.config.key_material())
|
let keys = self.keys()?;
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
|
|
||||||
let handle = keys
|
let handle = keys
|
||||||
.resolve(self.config.default_key().as_str())
|
.resolve(self.config.default_key().as_str())
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
|
||||||
@@ -386,7 +394,10 @@ impl DesktopStorage {
|
|||||||
.config
|
.config
|
||||||
.with_settings(settings)
|
.with_settings(settings)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))?;
|
.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()?;
|
let keys = storage.keys()?;
|
||||||
keys.resolve(storage.config.default_key().as_str())
|
keys.resolve(storage.config.default_key().as_str())
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?;
|
||||||
@@ -411,7 +422,7 @@ impl DesktopStorage {
|
|||||||
pub fn tree(&self) -> Result<TreeModel, DesktopError> {
|
pub fn tree(&self) -> Result<TreeModel, DesktopError> {
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
VaultReader::new(&repository, &keys)
|
VaultReader::new(&repository, keys)
|
||||||
.list(&DirectoryPath::root())
|
.list(&DirectoryPath::root())
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
||||||
}
|
}
|
||||||
@@ -505,7 +516,7 @@ impl DesktopStorage {
|
|||||||
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
|
pub fn find(&self, request: &FindRequest) -> Result<FindResults, DesktopError> {
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
VaultReader::new(&repository, &keys)
|
VaultReader::new(&repository, keys)
|
||||||
.find(&request.terms)
|
.find(&request.terms)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
||||||
}
|
}
|
||||||
@@ -529,7 +540,7 @@ impl DesktopStorage {
|
|||||||
) -> Result<GrepResults, DesktopError> {
|
) -> Result<GrepResults, DesktopError> {
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
VaultReader::new(&repository, &keys)
|
VaultReader::new(&repository, keys)
|
||||||
.grep(request, provider)
|
.grep(request, provider)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Read, error))
|
||||||
}
|
}
|
||||||
@@ -557,7 +568,7 @@ impl DesktopStorage {
|
|||||||
) -> Result<DesktopOtpCode, DesktopError> {
|
) -> Result<DesktopOtpCode, DesktopError> {
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
let service = OtpService::new(&repository, &keys);
|
let service = OtpService::new(&repository, keys);
|
||||||
let uri = service
|
let uri = service
|
||||||
.uri(entry, provider)
|
.uri(entry, provider)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
||||||
@@ -582,7 +593,7 @@ impl DesktopStorage {
|
|||||||
let tree = changed.then(|| self.tree()).transpose()?;
|
let tree = changed.then(|| self.tree()).transpose()?;
|
||||||
let document = changed
|
let document = changed
|
||||||
.then(|| {
|
.then(|| {
|
||||||
EntryDocumentService::new(&repository, &keys)
|
EntryDocumentService::new(&repository, keys)
|
||||||
.open(entry, provider)
|
.open(entry, provider)
|
||||||
.map_err(DesktopError::document)
|
.map_err(DesktopError::document)
|
||||||
})
|
})
|
||||||
@@ -617,7 +628,7 @@ impl DesktopStorage {
|
|||||||
) -> Result<DesktopOtpUri, DesktopError> {
|
) -> Result<DesktopOtpUri, DesktopError> {
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
let uri = OtpService::new(&repository, &keys)
|
let uri = OtpService::new(&repository, keys)
|
||||||
.uri(entry, provider)
|
.uri(entry, provider)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
||||||
let payload = SecretBytes::new(uri.encoded().expose().to_vec());
|
let payload = SecretBytes::new(uri.encoded().expose().to_vec());
|
||||||
@@ -680,11 +691,11 @@ impl DesktopStorage {
|
|||||||
let encoded = parsed.encoded().expose().to_vec();
|
let encoded = parsed.encoded().expose().to_vec();
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
let exists = VaultWriter::new(&repository, &keys)
|
let exists = VaultWriter::new(&repository, keys)
|
||||||
.entry_exists(entry)
|
.entry_exists(entry)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
||||||
if exists {
|
if exists {
|
||||||
let mut document = EntryDocumentService::new(&repository, &keys)
|
let mut document = EntryDocumentService::new(&repository, keys)
|
||||||
.open(entry, provider)
|
.open(entry, provider)
|
||||||
.map_err(DesktopError::document)?;
|
.map_err(DesktopError::document)?;
|
||||||
if let Some(field) = otp_field(&document)? {
|
if let Some(field) = otp_field(&document)? {
|
||||||
@@ -708,7 +719,7 @@ impl DesktopStorage {
|
|||||||
.map_err(DesktopError::document)?;
|
.map_err(DesktopError::document)?;
|
||||||
}
|
}
|
||||||
self.save_document(&document)?;
|
self.save_document(&document)?;
|
||||||
let document = EntryDocumentService::new(&repository, &keys)
|
let document = EntryDocumentService::new(&repository, keys)
|
||||||
.open(entry, provider)
|
.open(entry, provider)
|
||||||
.map_err(DesktopError::document)?;
|
.map_err(DesktopError::document)?;
|
||||||
return Ok(DesktopOtpMutation {
|
return Ok(DesktopOtpMutation {
|
||||||
@@ -725,7 +736,7 @@ impl DesktopStorage {
|
|||||||
};
|
};
|
||||||
let input = OtpInput::line(encoded)
|
let input = OtpInput::line(encoded)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
||||||
let service = OtpService::new(&repository, &keys);
|
let service = OtpService::new(&repository, keys);
|
||||||
let plan = service
|
let plan = service
|
||||||
.prepare_insert(&request, input)
|
.prepare_insert(&request, input)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
||||||
@@ -741,7 +752,7 @@ impl DesktopStorage {
|
|||||||
&mut committer,
|
&mut committer,
|
||||||
)
|
)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Otp, error))?;
|
||||||
let document = EntryDocumentService::new(&repository, &keys)
|
let document = EntryDocumentService::new(&repository, keys)
|
||||||
.open(entry, provider)
|
.open(entry, provider)
|
||||||
.map_err(DesktopError::document)?;
|
.map_err(DesktopError::document)?;
|
||||||
Ok(DesktopOtpMutation {
|
Ok(DesktopOtpMutation {
|
||||||
@@ -770,7 +781,7 @@ impl DesktopStorage {
|
|||||||
) -> Result<DesktopOtpMutation, DesktopError> {
|
) -> Result<DesktopOtpMutation, DesktopError> {
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
let mut document = EntryDocumentService::new(&repository, &keys)
|
let mut document = EntryDocumentService::new(&repository, keys)
|
||||||
.open(entry, provider)
|
.open(entry, provider)
|
||||||
.map_err(DesktopError::document)?;
|
.map_err(DesktopError::document)?;
|
||||||
let field = otp_field(&document)?.ok_or_else(|| {
|
let field = otp_field(&document)?.ok_or_else(|| {
|
||||||
@@ -781,7 +792,7 @@ impl DesktopStorage {
|
|||||||
})?;
|
})?;
|
||||||
document.remove(field).map_err(DesktopError::document)?;
|
document.remove(field).map_err(DesktopError::document)?;
|
||||||
self.save_document(&document)?;
|
self.save_document(&document)?;
|
||||||
let document = EntryDocumentService::new(&repository, &keys)
|
let document = EntryDocumentService::new(&repository, keys)
|
||||||
.open(entry, provider)
|
.open(entry, provider)
|
||||||
.map_err(DesktopError::document)?;
|
.map_err(DesktopError::document)?;
|
||||||
Ok(DesktopOtpMutation {
|
Ok(DesktopOtpMutation {
|
||||||
@@ -818,7 +829,7 @@ impl DesktopStorage {
|
|||||||
GitIdentity::ironstorage(),
|
GitIdentity::ironstorage(),
|
||||||
)
|
)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
|
||||||
let mutator = TreeMutator::new(&repository, &keys);
|
let mutator = TreeMutator::new(&repository, keys);
|
||||||
match request {
|
match request {
|
||||||
DesktopMutationRequest::Remove(request) => {
|
DesktopMutationRequest::Remove(request) => {
|
||||||
mutator.remove(request, overwrite, &mut committer)
|
mutator.remove(request, overwrite, &mut committer)
|
||||||
@@ -892,7 +903,7 @@ impl DesktopStorage {
|
|||||||
replacement: &replacement.config,
|
replacement: &replacement.config,
|
||||||
persisted: false,
|
persisted: false,
|
||||||
};
|
};
|
||||||
let outcome = RecipientPolicyManager::new(&repository, &keys)
|
let outcome = RecipientPolicyManager::new(&repository, keys)
|
||||||
.apply_init(request, None, provider, &mut committer)
|
.apply_init(request, None, provider, &mut committer)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))?;
|
||||||
if !committer.persisted {
|
if !committer.persisted {
|
||||||
@@ -922,7 +933,7 @@ impl DesktopStorage {
|
|||||||
) -> Result<EntryDocument, DesktopError> {
|
) -> Result<EntryDocument, DesktopError> {
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
if VaultWriter::new(&repository, &keys)
|
if VaultWriter::new(&repository, keys)
|
||||||
.entry_exists(entry)
|
.entry_exists(entry)
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Document, error))?
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Document, error))?
|
||||||
{
|
{
|
||||||
@@ -931,7 +942,7 @@ impl DesktopStorage {
|
|||||||
format!("password-store entry already exists: {entry}"),
|
format!("password-store entry already exists: {entry}"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
EntryDocumentService::new(&repository, &keys)
|
EntryDocumentService::new(&repository, keys)
|
||||||
.open(entry, provider)
|
.open(entry, provider)
|
||||||
.map_err(DesktopError::document)
|
.map_err(DesktopError::document)
|
||||||
}
|
}
|
||||||
@@ -943,7 +954,7 @@ impl DesktopStorage {
|
|||||||
) -> Result<EntryDocument, DesktopError> {
|
) -> Result<EntryDocument, DesktopError> {
|
||||||
let repository = self.repository()?;
|
let repository = self.repository()?;
|
||||||
let keys = self.keys()?;
|
let keys = self.keys()?;
|
||||||
EntryDocumentService::new(&repository, &keys)
|
EntryDocumentService::new(&repository, keys)
|
||||||
.open(entry, secrets)
|
.open(entry, secrets)
|
||||||
.map_err(DesktopError::document)
|
.map_err(DesktopError::document)
|
||||||
}
|
}
|
||||||
@@ -955,7 +966,7 @@ impl DesktopStorage {
|
|||||||
let mut committer =
|
let mut committer =
|
||||||
AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage())
|
AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage())
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?;
|
||||||
EntryDocumentService::new(&repository, &keys)
|
EntryDocumentService::new(&repository, keys)
|
||||||
.save_recoverable(document, None, &mut committer)
|
.save_recoverable(document, None, &mut committer)
|
||||||
.map_err(DesktopError::document)
|
.map_err(DesktopError::document)
|
||||||
}
|
}
|
||||||
@@ -976,9 +987,17 @@ impl DesktopStorage {
|
|||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))
|
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keys(&self) -> Result<KeyStore, DesktopError> {
|
fn keys(&self) -> Result<&KeyStore, DesktopError> {
|
||||||
KeyStore::load(self.config.key_material())
|
if let Some(keys) = self.keys.get() {
|
||||||
.map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))
|
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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,6 @@ impl EntryFieldDraft {
|
|||||||
pub fn field(name: impl Into<String>, value: Vec<u8>) -> Result<Self, DocumentError> {
|
pub fn field(name: impl Into<String>, value: Vec<u8>) -> Result<Self, DocumentError> {
|
||||||
let name = name.into();
|
let name = name.into();
|
||||||
validate_name(&name)?;
|
validate_name(&name)?;
|
||||||
validate_line(&value)?;
|
|
||||||
let value = SecretBytes::new(value);
|
let value = SecretBytes::new(value);
|
||||||
let mut contents = Vec::with_capacity(name.len() + value.expose().len() + 2);
|
let mut contents = Vec::with_capacity(name.len() + value.expose().len() + 2);
|
||||||
contents.extend_from_slice(name.as_bytes());
|
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 {
|
fn render(self) -> SecretBytes {
|
||||||
self.contents
|
self.contents
|
||||||
}
|
}
|
||||||
@@ -283,9 +288,8 @@ impl EntryDocument {
|
|||||||
let field = self.field(id).ok_or(DocumentError::UnknownField { id })?;
|
let field = self.field(id).ok_or(DocumentError::UnknownField { id })?;
|
||||||
let draft = match field.metadata().kind() {
|
let draft = match field.metadata().kind() {
|
||||||
EntryFieldKind::OtpUri => EntryFieldDraft::otp_uri(value)?,
|
EntryFieldKind::OtpUri => EntryFieldDraft::otp_uri(value)?,
|
||||||
EntryFieldKind::Password | EntryFieldKind::Note | EntryFieldKind::Blank => {
|
EntryFieldKind::Password => EntryFieldDraft::line(value)?,
|
||||||
EntryFieldDraft::line(value)?
|
EntryFieldKind::Note | EntryFieldKind::Blank => EntryFieldDraft::multiline(value),
|
||||||
}
|
|
||||||
EntryFieldKind::Username
|
EntryFieldKind::Username
|
||||||
| EntryFieldKind::Email
|
| EntryFieldKind::Email
|
||||||
| EntryFieldKind::Url
|
| EntryFieldKind::Url
|
||||||
@@ -540,7 +544,32 @@ fn parse_lines(contents: &[u8]) -> Vec<EntryField> {
|
|||||||
});
|
});
|
||||||
start = end;
|
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]) {
|
fn classify_all(fields: &mut [EntryField]) {
|
||||||
@@ -595,8 +624,13 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
|||||||
value: 0..line.len(),
|
value: 0..line.len(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(colon) = line.iter().position(|byte| *byte == b':') {
|
let first_line_end = line
|
||||||
let raw_name = trim_ascii(&line[..colon]);
|
.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()
|
if !raw_name.is_empty()
|
||||||
&& let Ok(name) = std::str::from_utf8(raw_name)
|
&& let Ok(name) = std::str::from_utf8(raw_name)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
|||||||
document.password().expect("password").value(),
|
document.password().expect("password").value(),
|
||||||
"pässwörd".as_bytes()
|
"pässwörd".as_bytes()
|
||||||
);
|
);
|
||||||
assert_eq!(document.fields().len(), 12);
|
assert_eq!(document.fields().len(), 11);
|
||||||
|
|
||||||
let kinds = document
|
let kinds = document
|
||||||
.fields()
|
.fields()
|
||||||
@@ -100,7 +100,6 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
|||||||
EntryFieldKind::Note,
|
EntryFieldKind::Note,
|
||||||
EntryFieldKind::Note,
|
EntryFieldKind::Note,
|
||||||
EntryFieldKind::Field,
|
EntryFieldKind::Field,
|
||||||
EntryFieldKind::Note,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(document.fields()[1].metadata().name(), Some("username"));
|
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!(document.fields()[5].value().is_empty());
|
||||||
assert_ne!(document.fields()[4].id(), document.fields()[5].id());
|
assert_ne!(document.fields()[4].id(), document.fields()[5].id());
|
||||||
assert_eq!(document.fields()[10].metadata().name(), Some("鍵"));
|
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!(
|
assert_eq!(
|
||||||
document.fields()[6].metadata().sensitivity(),
|
document.fields()[6].metadata().sensitivity(),
|
||||||
EntrySensitivity::Sensitive
|
EntrySensitivity::Sensitive
|
||||||
@@ -148,6 +150,36 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn pass_otp_only_entries_keep_typed_metadata_in_the_first_line() -> TestResult {
|
fn pass_otp_only_entries_keep_typed_metadata_in_the_first_line() -> TestResult {
|
||||||
let fixture = FixtureSet::load()?;
|
let fixture = FixtureSet::load()?;
|
||||||
@@ -189,7 +221,7 @@ fn field_ids_survive_updates_removal_and_reordering() -> TestResult {
|
|||||||
&repository,
|
&repository,
|
||||||
&keys,
|
&keys,
|
||||||
"documents/editable",
|
"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 service = EntryDocumentService::new(&repository, &keys);
|
||||||
let mut document = service.open("documents/editable", &mut secrets)?;
|
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.fields()[2].id(), note_two_id);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document.serialize().expose(),
|
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)?;
|
let mut empty = service.open("documents/missing", &mut secrets)?;
|
||||||
|
|||||||
@@ -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 |
|
| 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: 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: refresh typed tree | `refresh` (View) | Sidebar Refresh control | 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: 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: `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: 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) | Entry path control, tree activation, structured viewer, Reload button | Yes |
|
| 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: `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: `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: `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: `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 |
|
| 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
|
accelerators. The same controls are mouse/touch activatable; sidebar context
|
||||||
actions also accept a secondary click.
|
actions also accept a secondary click.
|
||||||
- Both panes are independently scrollable, the divider is resizable, action
|
- Both panes are independently scrollable, the divider is resizable, action
|
||||||
rows wrap, long names and multiline values are retained, and the supported
|
rows wrap, long names and storage-grouped multiline values are retained, and
|
||||||
narrow window floor is 720 by 480 logical pixels. Iced/winit applies native
|
the supported narrow window floor is 480 by 360 logical pixels. Iced/winit applies native
|
||||||
display scaling before layout.
|
display scaling before layout.
|
||||||
- Interactive controls use visible, operation-specific text instead of icon-
|
- Compact toolbar controls use one 16-by-16 vector icon system with descriptive
|
||||||
only labels. Focused/selected controls use the theme's primary contrast pair;
|
delayed tooltips and registered shortcuts. Focused/selected rows use the
|
||||||
light, dark, and operating-system high-contrast palettes retain a visible
|
theme's primary contrast pair; field labels remain visible as a non-colour cue.
|
||||||
text label as a non-colour focus cue.
|
|
||||||
- The app implements no animation or motion-driven state transition. The one-
|
- The app implements no animation or motion-driven state transition. The one-
|
||||||
second subscription updates lease, OTP, Git, and clipboard presentation state
|
second subscription updates lease, OTP, Git, and clipboard presentation state
|
||||||
without moving focus or renewing authentication, so reduced-motion mode has
|
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 |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|||||||
Reference in New Issue
Block a user