482
apps/tui/src/editor.rs
Normal file
482
apps/tui/src/editor.rs
Normal file
@@ -0,0 +1,482 @@
|
||||
//! 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 pass entry line. Committing that
|
||||
/// buffer delegates classification back to `EntryDocument`, so names, duplicate
|
||||
/// fields, notes, and OTP URI recognition remain storage behavior.
|
||||
pub struct EntryEditor {
|
||||
document: EntryDocument,
|
||||
focused: usize,
|
||||
buffer: SecretBytes,
|
||||
cursor: usize,
|
||||
input_active: bool,
|
||||
revealed: 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,
|
||||
revealed: 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;
|
||||
self.revealed = false;
|
||||
}
|
||||
|
||||
pub fn is_revealed(&self, id: EntryFieldId) -> bool {
|
||||
self.revealed && self.focused_field().is_some_and(|field| field.id() == id)
|
||||
}
|
||||
|
||||
pub fn reveal_focused(&mut self) -> bool {
|
||||
if self
|
||||
.focused_field()
|
||||
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive)
|
||||
{
|
||||
self.revealed = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hide_revealed(&mut self) -> bool {
|
||||
std::mem::take(&mut self.revealed)
|
||||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
pub fn focus_next(&mut self) -> Result<(), DocumentError> {
|
||||
self.flush()?;
|
||||
self.hide_revealed();
|
||||
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()?;
|
||||
self.hide_revealed();
|
||||
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();
|
||||
}
|
||||
self.revealed = false;
|
||||
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(());
|
||||
};
|
||||
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::line(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();
|
||||
self.revealed = false;
|
||||
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::line(self.buffer.expose().to_vec())
|
||||
.expect("the single-line editor never inserts line endings into its buffer");
|
||||
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("revealed", &self.revealed)
|
||||
.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_focus_and_masking_are_predictable() {
|
||||
let mut editor = EntryEditor::new(fixture_document("unicode/咖啡"));
|
||||
assert!(editor.reveal_focused());
|
||||
editor.begin_input();
|
||||
editor.move_cursor_end();
|
||||
editor.insert_character('界');
|
||||
assert!(editor.is_revealed(editor.focused_field().expect("field").id()));
|
||||
editor.focus_next().expect("focus next");
|
||||
assert!(!editor.revealed);
|
||||
editor.focus_previous().expect("focus previous");
|
||||
assert!(
|
||||
editor
|
||||
.focused_field()
|
||||
.expect("password")
|
||||
.contents()
|
||||
.expose()
|
||||
.ends_with("界".as_bytes())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_split_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 + 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);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user