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

View File

@@ -5,6 +5,7 @@
pub mod action;
pub mod app;
pub mod editor;
pub mod runtime;
pub mod sidebar;
pub mod terminal;
@@ -22,7 +23,7 @@ use ratatui::DefaultTerminal;
use crate::{
action::resolve_key,
app::{App, AppEffect, AsyncPayload, StartupData},
app::{App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData},
runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent},
};
@@ -98,6 +99,14 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
{
apply_authentication_event(&mut app, coordinator, &executor, event);
}
if !key.modifiers.intersects(
crossterm::event::KeyModifiers::CONTROL
| crossterm::event::KeyModifiers::ALT
| crossterm::event::KeyModifiers::SUPER,
) && app.handle_editor_input(key.code)
{
continue;
}
if app.sidebar().is_editing_filter() {
if handle_filter_key(&mut app, key.code) {
submit_filter(&mut app, &executor);
@@ -148,6 +157,27 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
});
}
}
AppEffect::GenerateField(target) => {
let token = app.begin_request();
executor.submit(token, move || {
ironstorage::generate::GeneratorConfig::pass_defaults()
.generate_secret(None, false)
.map(|password| AsyncPayload::GeneratedField {
target,
password,
})
.map_err(|error| error.to_string())
});
}
AppEffect::SaveDocument {
config,
entry,
editor,
} => {
let token = app.begin_request();
executor
.submit(token, move || Ok(save_document(&config, entry, editor)));
}
AppEffect::ManualLock => {
if let Some(coordinator) = authentication.as_mut()
&& let Err(error) = coordinator.lock()
@@ -240,6 +270,61 @@ fn load_document(
.map_err(|error| error.to_string())
}
fn save_document(
config: &ironstorage::config::Config,
entry: String,
editor: Box<editor::EntryEditor>,
) -> AsyncPayload {
let result = save_document_inner(config, &entry, &editor);
AsyncPayload::DocumentSaveFinished {
entry,
editor,
result,
}
}
fn save_document_inner(
config: &ironstorage::config::Config,
entry: &str,
editor: &editor::EntryEditor,
) -> Result<ironstorage::write::WriteOutcome, EditorSaveFailure> {
let repository =
ironstorage::repository::Repository::open(config.vault()).map_err(|error| {
EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string())
})?;
let keys = ironstorage::crypto::KeyStore::load(config.key_material()).map_err(|error| {
EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string())
})?;
let identity = ironstorage::git::GitIdentity::ironstorage();
let mut committer =
ironstorage::git::AutomaticEntryCommitter::for_entry(&repository, entry, identity)
.map_err(|error| {
EditorSaveFailure::new(EditorSaveFailureKind::Storage, error.to_string())
})?;
ironstorage::document::EntryDocumentService::new(&repository, &keys)
.save_recoverable(editor.document(), None, &mut committer)
.map_err(|error| {
let kind = if matches!(
&error,
ironstorage::document::DocumentError::Write(
ironstorage::write::WriteError::ConcurrentModification { .. }
)
) {
EditorSaveFailureKind::Conflict
} else if matches!(
&error,
ironstorage::document::DocumentError::Write(
ironstorage::write::WriteError::Unchanged
)
) {
EditorSaveFailureKind::Unchanged
} else {
EditorSaveFailureKind::Storage
};
EditorSaveFailure::new(kind, error.to_string())
})
}
fn load_tree(config: &ironstorage::config::Config) -> Result<ironstorage::read::TreeModel, String> {
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
@@ -288,3 +373,70 @@ fn handle_filter_key(app: &mut App, code: crossterm::event::KeyCode) -> bool {
_ => false,
}
}
#[cfg(test)]
mod tests {
use std::{fs, path::Path};
use super::*;
use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from};
#[test]
fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() {
let temporary = tempfile::tempdir().expect("temporary editor store");
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility");
let store = temporary.path().join("store");
copy_directory(&fixtures.join("stores/basic"), &store);
let repository = ironstorage::repository::Repository::open(&store).expect("repository");
let identity = ironstorage::git::GitIdentity::ironstorage();
let git = ironstorage::git::GitRepository::init(&repository, identity.clone())
.expect("initialize git");
let initial_commits = git.log(None).expect("initial log").len();
assert!(initial_commits >= 1);
drop(git);
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n",
store,
fixtures.join("keys"),
),
)
.expect("write config");
let config = ironstorage::config::Config::load(Some(&config_path)).expect("config");
let mut editor = EntryEditor::new(fixture_document_from(&store, "email/personal"));
editor.begin_input();
editor.insert_character('x');
let outcome = save_document_inner(&config, "email/personal", &editor).expect("save");
assert_eq!(outcome.path().to_string(), "email/personal");
let reopened = fixture_document_from(&store, "email/personal");
assert!(
reopened
.password()
.expect("password")
.value()
.ends_with(b"x")
);
let git = ironstorage::git::GitRepository::open(&repository, identity).expect("reopen git");
assert_eq!(git.log(None).expect("saved log").len(), initial_commits + 1);
}
fn copy_directory(source: &Path, destination: &Path) {
fs::create_dir_all(destination).expect("create destination");
for entry in fs::read_dir(source).expect("read source") {
let entry = entry.expect("directory entry");
let target = destination.join(entry.file_name());
if entry.file_type().expect("file type").is_dir() {
copy_directory(&entry.path(), &target);
} else {
fs::copy(entry.path(), target).expect("copy fixture file");
}
}
}
}