Implement the in-process structured entry editor

Closes #21
This commit is contained in:
Hermes Agent
2026-08-10 07:22:55 +00:00
parent cf7a6216ac
commit b503de8b2e
14 changed files with 1547 additions and 37 deletions

482
apps/tui/src/editor.rs Normal file
View 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");
}
}