474 lines
15 KiB
Rust
474 lines
15 KiB
Rust
//! In-process structured document editing state.
|
|
|
|
use std::fmt;
|
|
|
|
use ironstorage::{
|
|
document::{
|
|
DocumentError, EntryDocument, EntryField, EntryFieldDraft, EntryFieldId, EntryFieldKind,
|
|
EntrySensitivity,
|
|
},
|
|
repository::SecretBytes,
|
|
};
|
|
|
|
/// Presentation state for editing one storage-owned document.
|
|
///
|
|
/// The focused buffer contains one complete storage-owned logical field.
|
|
/// Committing it delegates classification back to `EntryDocument`, so names,
|
|
/// multiline values, duplicate fields, notes, and OTP URI recognition remain
|
|
/// storage behavior.
|
|
pub struct EntryEditor {
|
|
document: EntryDocument,
|
|
focused: usize,
|
|
buffer: SecretBytes,
|
|
cursor: usize,
|
|
input_active: bool,
|
|
dirty: bool,
|
|
}
|
|
|
|
impl EntryEditor {
|
|
pub fn new(document: EntryDocument) -> Self {
|
|
let buffer = document.fields().first().map_or_else(
|
|
|| SecretBytes::new(Vec::new()),
|
|
|field| SecretBytes::new(field.contents().expose().to_vec()),
|
|
);
|
|
let cursor = buffer.expose().len();
|
|
Self {
|
|
document,
|
|
focused: 0,
|
|
buffer,
|
|
cursor,
|
|
input_active: false,
|
|
dirty: false,
|
|
}
|
|
}
|
|
|
|
pub fn document(&self) -> &EntryDocument {
|
|
&self.document
|
|
}
|
|
|
|
pub fn focused_index(&self) -> Option<usize> {
|
|
(!self.document.fields().is_empty()).then_some(self.focused)
|
|
}
|
|
|
|
pub fn focused_field(&self) -> Option<&EntryField> {
|
|
self.document.fields().get(self.focused)
|
|
}
|
|
|
|
pub fn focused_contents(&self, id: EntryFieldId) -> Option<&[u8]> {
|
|
self.focused_field()
|
|
.filter(|field| field.id() == id)
|
|
.map(|_| self.buffer.expose())
|
|
}
|
|
|
|
pub fn cursor(&self) -> usize {
|
|
self.cursor
|
|
}
|
|
|
|
pub fn is_input_active(&self) -> bool {
|
|
self.input_active
|
|
}
|
|
|
|
pub fn begin_input(&mut self) {
|
|
if self.focused_field().is_some() {
|
|
self.input_active = true;
|
|
}
|
|
}
|
|
|
|
pub fn end_input(&mut self) {
|
|
self.input_active = false;
|
|
}
|
|
|
|
pub fn is_dirty(&self) -> bool {
|
|
self.dirty
|
|
}
|
|
|
|
pub fn focus_next(&mut self) -> Result<(), DocumentError> {
|
|
self.flush()?;
|
|
if !self.document.fields().is_empty() {
|
|
self.focused = (self.focused + 1) % self.document.fields().len();
|
|
self.load_focused();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn focus_previous(&mut self) -> Result<(), DocumentError> {
|
|
self.flush()?;
|
|
if !self.document.fields().is_empty() {
|
|
self.focused = self
|
|
.focused
|
|
.checked_sub(1)
|
|
.unwrap_or(self.document.fields().len() - 1);
|
|
self.load_focused();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn add_after_focused(&mut self) -> Result<(), DocumentError> {
|
|
self.flush()?;
|
|
let index = if self.document.fields().is_empty() {
|
|
0
|
|
} else {
|
|
self.focused + 1
|
|
};
|
|
self.document.add(index, EntryFieldDraft::blank())?;
|
|
self.focused = index;
|
|
self.dirty = true;
|
|
self.load_focused();
|
|
self.input_active = true;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn remove_focused(&mut self) -> Result<(), DocumentError> {
|
|
self.flush()?;
|
|
let Some(field) = self.focused_field() else {
|
|
return Ok(());
|
|
};
|
|
self.document.remove(field.id())?;
|
|
self.focused = self
|
|
.focused
|
|
.min(self.document.fields().len().saturating_sub(1));
|
|
self.dirty = true;
|
|
self.load_focused();
|
|
Ok(())
|
|
}
|
|
|
|
pub fn move_focused_up(&mut self) -> Result<(), DocumentError> {
|
|
self.flush()?;
|
|
if self.focused == 0 {
|
|
return Ok(());
|
|
}
|
|
let id = self
|
|
.focused_field()
|
|
.expect("nonzero focus has a field")
|
|
.id();
|
|
self.focused -= 1;
|
|
self.document.reorder(id, self.focused)?;
|
|
self.dirty = true;
|
|
self.load_focused();
|
|
Ok(())
|
|
}
|
|
|
|
pub fn move_focused_down(&mut self) -> Result<(), DocumentError> {
|
|
self.flush()?;
|
|
if self.focused + 1 >= self.document.fields().len() {
|
|
return Ok(());
|
|
}
|
|
let id = self.focused_field().expect("focus has a field").id();
|
|
self.focused += 1;
|
|
self.document.reorder(id, self.focused)?;
|
|
self.dirty = true;
|
|
self.load_focused();
|
|
Ok(())
|
|
}
|
|
|
|
pub fn generation_target(&mut self) -> Result<Option<EntryFieldId>, DocumentError> {
|
|
self.flush()?;
|
|
Ok(self.focused_field().and_then(|field| {
|
|
(field.metadata().sensitivity() == EntrySensitivity::Sensitive
|
|
&& field.metadata().kind() != EntryFieldKind::OtpUri)
|
|
.then_some(field.id())
|
|
}))
|
|
}
|
|
|
|
pub fn apply_generated(
|
|
&mut self,
|
|
target: EntryFieldId,
|
|
password: SecretBytes,
|
|
) -> Result<(), DocumentError> {
|
|
self.flush()?;
|
|
self.document
|
|
.replace_field_value(target, password.expose().to_vec())?;
|
|
self.dirty = true;
|
|
if self
|
|
.focused_field()
|
|
.is_some_and(|field| field.id() == target)
|
|
{
|
|
self.load_focused();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn prepare_save(mut self) -> Result<Self, Box<(Self, DocumentError)>> {
|
|
match self.flush() {
|
|
Ok(()) => Ok(self),
|
|
Err(error) => Err(Box::new((self, error))),
|
|
}
|
|
}
|
|
|
|
pub fn into_document(mut self) -> Result<EntryDocument, Box<(Self, DocumentError)>> {
|
|
match self.flush() {
|
|
Ok(()) => Ok(self.document),
|
|
Err(error) => Err(Box::new((self, error))),
|
|
}
|
|
}
|
|
|
|
pub fn insert_character(&mut self, character: char) {
|
|
if !self.input_active || self.focused_field().is_none() || character.is_control() {
|
|
return;
|
|
}
|
|
let encoded = character.to_string();
|
|
let mut replacement = Vec::with_capacity(self.buffer.expose().len() + encoded.len());
|
|
replacement.extend_from_slice(&self.buffer.expose()[..self.cursor]);
|
|
replacement.extend_from_slice(encoded.as_bytes());
|
|
replacement.extend_from_slice(&self.buffer.expose()[self.cursor..]);
|
|
self.cursor += encoded.len();
|
|
self.replace_buffer(replacement);
|
|
}
|
|
|
|
pub fn backspace(&mut self) {
|
|
if !self.input_active || self.cursor == 0 {
|
|
return;
|
|
}
|
|
let start = previous_character_boundary(self.buffer.expose(), self.cursor);
|
|
let mut replacement =
|
|
Vec::with_capacity(self.buffer.expose().len() - (self.cursor - start));
|
|
replacement.extend_from_slice(&self.buffer.expose()[..start]);
|
|
replacement.extend_from_slice(&self.buffer.expose()[self.cursor..]);
|
|
self.cursor = start;
|
|
self.replace_buffer(replacement);
|
|
}
|
|
|
|
pub fn delete(&mut self) {
|
|
if !self.input_active || self.cursor >= self.buffer.expose().len() {
|
|
return;
|
|
}
|
|
let end = next_character_boundary(self.buffer.expose(), self.cursor);
|
|
let mut replacement = Vec::with_capacity(self.buffer.expose().len() - (end - self.cursor));
|
|
replacement.extend_from_slice(&self.buffer.expose()[..self.cursor]);
|
|
replacement.extend_from_slice(&self.buffer.expose()[end..]);
|
|
self.replace_buffer(replacement);
|
|
}
|
|
|
|
pub fn move_cursor_left(&mut self) {
|
|
self.cursor = previous_character_boundary(self.buffer.expose(), self.cursor);
|
|
}
|
|
|
|
pub fn move_cursor_right(&mut self) {
|
|
self.cursor = next_character_boundary(self.buffer.expose(), self.cursor);
|
|
}
|
|
|
|
pub fn move_cursor_home(&mut self) {
|
|
self.cursor = 0;
|
|
}
|
|
|
|
pub fn move_cursor_end(&mut self) {
|
|
self.cursor = self.buffer.expose().len();
|
|
}
|
|
|
|
pub fn split_line(&mut self) -> Result<(), DocumentError> {
|
|
if !self.input_active {
|
|
return Ok(());
|
|
}
|
|
let Some(field) = self.focused_field() else {
|
|
return Ok(());
|
|
};
|
|
if matches!(
|
|
field.metadata().kind(),
|
|
EntryFieldKind::Username
|
|
| EntryFieldKind::Email
|
|
| EntryFieldKind::Url
|
|
| EntryFieldKind::Field
|
|
| EntryFieldKind::Note
|
|
) {
|
|
let mut replacement = Vec::with_capacity(self.buffer.expose().len() + 1);
|
|
replacement.extend_from_slice(&self.buffer.expose()[..self.cursor]);
|
|
replacement.push(b'\n');
|
|
replacement.extend_from_slice(&self.buffer.expose()[self.cursor..]);
|
|
self.cursor += 1;
|
|
self.replace_buffer(replacement);
|
|
return Ok(());
|
|
}
|
|
let id = field.id();
|
|
let left = self.buffer.expose()[..self.cursor].to_vec();
|
|
let right = self.buffer.expose()[self.cursor..].to_vec();
|
|
self.document.update(id, EntryFieldDraft::line(left)?)?;
|
|
self.document
|
|
.add(self.focused + 1, EntryFieldDraft::line(right)?)?;
|
|
self.focused += 1;
|
|
self.dirty = true;
|
|
self.load_focused();
|
|
Ok(())
|
|
}
|
|
|
|
fn flush(&mut self) -> Result<(), DocumentError> {
|
|
let Some(field) = self.focused_field() else {
|
|
return Ok(());
|
|
};
|
|
if field.contents().expose() == self.buffer.expose() {
|
|
return Ok(());
|
|
}
|
|
let id = field.id();
|
|
self.document.update(
|
|
id,
|
|
EntryFieldDraft::multiline(self.buffer.expose().to_vec()),
|
|
)?;
|
|
self.dirty = true;
|
|
Ok(())
|
|
}
|
|
|
|
fn load_focused(&mut self) {
|
|
self.buffer = self.focused_field().map_or_else(
|
|
|| SecretBytes::new(Vec::new()),
|
|
|field| SecretBytes::new(field.contents().expose().to_vec()),
|
|
);
|
|
self.cursor = self.buffer.expose().len();
|
|
if self.focused_field().is_none() {
|
|
self.input_active = false;
|
|
}
|
|
}
|
|
|
|
fn replace_buffer(&mut self, replacement: Vec<u8>) {
|
|
self.buffer = SecretBytes::new(replacement);
|
|
let Some(id) = self.focused_field().map(EntryField::id) else {
|
|
return;
|
|
};
|
|
let draft = EntryFieldDraft::multiline(self.buffer.expose().to_vec());
|
|
self.document
|
|
.update(id, draft)
|
|
.expect("the focused field identifier belongs to the document");
|
|
self.dirty = true;
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for EntryEditor {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("EntryEditor")
|
|
.field("document", &self.document)
|
|
.field("focused", &self.focused)
|
|
.field("buffer", &"[REDACTED]")
|
|
.field("cursor", &self.cursor)
|
|
.field("input_active", &self.input_active)
|
|
.field("dirty", &self.dirty)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
fn previous_character_boundary(value: &[u8], cursor: usize) -> usize {
|
|
let mut boundary = cursor.saturating_sub(1);
|
|
while boundary > 0 && value[boundary].is_ascii_continuation() {
|
|
boundary -= 1;
|
|
}
|
|
boundary
|
|
}
|
|
|
|
fn next_character_boundary(value: &[u8], cursor: usize) -> usize {
|
|
let mut boundary = cursor.saturating_add(1).min(value.len());
|
|
while boundary < value.len() && value[boundary].is_ascii_continuation() {
|
|
boundary += 1;
|
|
}
|
|
boundary
|
|
}
|
|
|
|
trait Utf8Continuation {
|
|
fn is_ascii_continuation(&self) -> bool;
|
|
}
|
|
|
|
impl Utf8Continuation for u8 {
|
|
fn is_ascii_continuation(&self) -> bool {
|
|
self & 0b1100_0000 == 0b1000_0000
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::viewer::test_support::fixture_document;
|
|
|
|
#[test]
|
|
fn unicode_input_and_focus_are_predictable() {
|
|
let mut editor = EntryEditor::new(fixture_document("unicode/咖啡"));
|
|
editor.begin_input();
|
|
editor.move_cursor_end();
|
|
editor.insert_character('界');
|
|
editor.focus_next().expect("focus next");
|
|
editor.focus_previous().expect("focus previous");
|
|
assert!(
|
|
editor
|
|
.focused_field()
|
|
.expect("password")
|
|
.contents()
|
|
.expose()
|
|
.ends_with("界".as_bytes())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn add_multiline_remove_and_reorder_keep_stable_focus() {
|
|
let mut editor = EntryEditor::new(fixture_document("email/personal"));
|
|
let initial = editor.document().fields().len();
|
|
editor.add_after_focused().expect("add");
|
|
assert_eq!(editor.focused_index(), Some(1));
|
|
editor.insert_character('a');
|
|
editor.insert_character('b');
|
|
editor.move_cursor_left();
|
|
editor.split_line().expect("split");
|
|
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]
|
|
fn generated_values_preserve_structured_names_and_empty_documents_are_editable() {
|
|
let mut editor = EntryEditor::new(fixture_document("email/personal"));
|
|
editor.focus_next().expect("username");
|
|
assert_eq!(editor.generation_target().expect("target"), None);
|
|
editor.focus_previous().expect("password");
|
|
let target = editor
|
|
.generation_target()
|
|
.expect("target")
|
|
.expect("sensitive target");
|
|
editor
|
|
.apply_generated(target, SecretBytes::new(b"generated".to_vec()))
|
|
.expect("apply generated");
|
|
assert_eq!(
|
|
editor.focused_field().expect("password").value(),
|
|
b"generated"
|
|
);
|
|
|
|
let mut empty = EntryEditor::new(fixture_document("new/empty-document"));
|
|
assert_eq!(empty.focused_index(), None);
|
|
empty.add_after_focused().expect("add first field");
|
|
assert_eq!(empty.focused_index(), Some(0));
|
|
|
|
let mut otp = EntryEditor::new(fixture_document("otp/totp"));
|
|
otp.focus_next().expect("OTP URI");
|
|
assert_eq!(otp.generation_target().expect("OTP target"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_labels_and_arbitrary_reordering_round_trip_without_schema_assumptions() {
|
|
let mut document = fixture_document("new/duplicate-document");
|
|
document
|
|
.add(
|
|
0,
|
|
EntryFieldDraft::line(b"password".to_vec()).expect("password"),
|
|
)
|
|
.expect("add password");
|
|
document
|
|
.add(
|
|
1,
|
|
EntryFieldDraft::field("custom", b"one".to_vec()).expect("field"),
|
|
)
|
|
.expect("add first duplicate");
|
|
document
|
|
.add(
|
|
2,
|
|
EntryFieldDraft::field("custom", b"two".to_vec()).expect("field"),
|
|
)
|
|
.expect("add second duplicate");
|
|
let mut editor = EntryEditor::new(document);
|
|
editor.focus_next().expect("first duplicate");
|
|
editor.focus_next().expect("second duplicate");
|
|
editor.move_focused_up().expect("reorder duplicate");
|
|
let serialized = editor.into_document().expect("valid editor").serialize();
|
|
assert_eq!(serialized.expose(), b"password\ncustom: two\ncustom: one");
|
|
}
|
|
}
|