Implement embedded Git synchronization TUI

This commit is contained in:
Hermes Agent
2026-08-10 10:39:31 +00:00
parent ce3e4a9f79
commit 1850846696
8 changed files with 1402 additions and 36 deletions

View File

@@ -9,6 +9,7 @@ use ironstorage::{
config::Config,
crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot},
presentation::ClipboardDisposition,
read::{FindResults, GrepResults, TreeModel},
repository::SecretBytes,
@@ -79,6 +80,29 @@ pub struct WorkflowSuccess {
pub status: String,
}
#[derive(Debug)]
pub struct GitView {
snapshot: GitSnapshot,
message: String,
conflicts: Vec<GitConflict>,
details: Option<SecretBytes>,
}
impl GitView {
pub fn snapshot(&self) -> &GitSnapshot {
&self.snapshot
}
pub fn message(&self) -> &str {
&self.message
}
pub fn conflicts(&self) -> &[GitConflict] {
&self.conflicts
}
pub fn details(&self) -> Option<&SecretBytes> {
self.details.as_ref()
}
}
#[derive(Debug)]
pub enum AsyncPayload {
Startup(Box<StartupData>),
@@ -106,6 +130,14 @@ pub enum AsyncPayload {
result: Result<WriteOutcome, EditorSaveFailure>,
},
WorkflowFinished(Result<Box<WorkflowSuccess>, String>),
GitProgress(GitProgressPhase),
GitFinished {
snapshot: Box<GitSnapshot>,
tree: Option<TreeModel>,
message: String,
conflicts: Vec<GitConflict>,
details: Option<SecretBytes>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -160,7 +192,9 @@ pub enum AppEffect {
editor: Box<EntryEditor>,
},
AuthenticateWorkflow(Box<WorkflowSubmission>),
OpenWorkflow(WorkflowAction),
AuthenticateGit(ironstorage::command::GitRequest),
CancelGit,
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
RunCommand(CommandRequest),
ManualLock,
}
@@ -193,6 +227,8 @@ pub struct App {
workflow: Option<WorkflowForm>,
workflow_pending: bool,
grep_view: Option<GrepView>,
git_view: Option<GitView>,
git_pending: bool,
remaining_lease: Option<std::time::Duration>,
terminal_size: (u16, u16),
ticks: u64,
@@ -231,6 +267,8 @@ impl App {
workflow: None,
workflow_pending: false,
grep_view: None,
git_view: None,
git_pending: false,
remaining_lease: None,
terminal_size: (0, 0),
ticks: 0,
@@ -321,6 +359,19 @@ impl App {
self.grep_view.as_ref()
}
pub fn git_view(&self) -> Option<&GitView> {
self.git_view.as_ref()
}
pub fn git_pending(&self) -> bool {
self.git_pending
}
pub fn begin_git_operation(&mut self, label: &str) {
self.git_pending = true;
self.status = format!("{label} queued for secure-storage authentication…");
}
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
self.remaining_lease
}
@@ -373,9 +424,12 @@ impl App {
}
pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition {
if result.token.generation != self.generation || !self.pending.remove(&result.token.id) {
if result.token.generation != self.generation || !self.pending.contains(&result.token.id) {
return ResultDisposition::Stale;
}
if !matches!(result.payload, Ok(AsyncPayload::GitProgress(_))) {
self.pending.remove(&result.token.id);
}
match result.payload {
Ok(AsyncPayload::Startup(startup)) => {
self.status = format!("Vault: {}", startup.config.vault().display());
@@ -543,7 +597,36 @@ impl App {
}
}
}
Ok(AsyncPayload::GitProgress(phase)) => {
self.status = format!("Git {}… (Esc cancels)", git_phase_name(phase));
}
Ok(AsyncPayload::GitFinished {
snapshot,
tree,
message,
conflicts,
details,
}) => {
self.git_pending = false;
if let Some(tree) = tree {
self.sidebar.replace_tree(&tree);
}
self.git_view = Some(GitView {
snapshot: *snapshot,
message: message.clone(),
conflicts,
details,
});
self.grep_view = None;
self.selected_entry = None;
self.viewer = None;
self.editor = None;
self.mode = Mode::Browser;
self.focus = PaneFocus::Main;
self.status = message;
}
Err(error) => {
self.git_pending = false;
self.status = error;
self.editor_generation_pending = None;
self.command_open_target = None;
@@ -588,7 +671,14 @@ impl App {
self.transition(Transition::OpenCommand);
}
Action::Cancel => {
if self.grep_view.is_some() && self.mode == Mode::Browser {
if self.git_pending {
self.status = "Cancelling Git operation…".to_owned();
return AppEffect::CancelGit;
} else if self.git_view.is_some() && self.mode == Mode::Browser {
self.git_view = None;
self.focus = PaneFocus::Sidebar;
self.status = "Git status closed".to_owned();
} else if self.grep_view.is_some() && self.mode == Mode::Browser {
self.grep_view = None;
self.focus = PaneFocus::Sidebar;
self.status = "Decrypted search results closed".to_owned();
@@ -833,7 +923,18 @@ impl App {
return AppEffect::None;
}
self.status = format!("Selected {} workflow", workflow_label(workflow));
return AppEffect::OpenWorkflow(workflow);
self.git_pending = true;
return AppEffect::AuthenticateGit(match workflow {
WorkflowAction::GitPull => ironstorage::command::GitRequest::Pull {
remote: None,
branch: None,
},
WorkflowAction::GitPush => ironstorage::command::GitRequest::Push {
remote: None,
branch: None,
},
_ => unreachable!("only Git workflows reach this branch"),
});
}
Action::Quit => {}
}
@@ -959,6 +1060,29 @@ impl App {
self.should_quit = true;
AppEffect::None
}
CommandInvocation::ResolveGit(choice) => {
let Some(view) = self.git_view.as_ref() else {
self.status = "There is no active Git conflict set".to_owned();
return AppEffect::None;
};
if view.conflicts.is_empty() {
self.status = "The active Git snapshot has no conflicts".to_owned();
return AppEffect::None;
}
let resolutions = view
.conflicts
.iter()
.map(|conflict| {
ironstorage::git::GitConflictResolution::new(
conflict.path().to_owned(),
choice,
)
})
.collect();
self.git_pending = true;
self.status = format!("Resolving all conflicts with {choice:?} versions…");
AppEffect::ResolveGit(resolutions)
}
CommandInvocation::Lock | CommandInvocation::Unlock | CommandInvocation::Quit => {
self.status = "Command is unavailable in the current mode".to_owned();
AppEffect::None
@@ -1371,6 +1495,7 @@ impl App {
}
pub fn authentication_failed(&mut self, message: String) {
self.git_pending = false;
self.authentication_pending = None;
self.selected_entry = None;
self.viewer = None;
@@ -1397,6 +1522,8 @@ impl App {
let discarded_edit = self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor);
self.transition(Transition::Lock);
self.authentication_pending = None;
self.git_pending = false;
self.git_view = None;
self.remaining_lease = None;
self.status = if discarded_edit {
format!("Locked: {reason}; unsaved edits were discarded")
@@ -1496,6 +1623,17 @@ impl App {
}
}
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase {
GitProgressPhase::Validating => "validating repository",
GitProgressPhase::Authenticating => "requesting credentials",
GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects",
GitProgressPhase::Refreshing => "refreshing synchronized snapshot",
}
}
fn workflow_label(workflow: WorkflowAction) -> &'static str {
match workflow {
WorkflowAction::Initialize => "initialization",