Revamp desktop usability and multiline fields
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user