Implement embedded Git synchronization TUI
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user