Present iPhone Git actions (#51)

This commit is contained in:
hugo committed 2026-08-14 17:24:41 +02:00
1 parent fcf649e56d
commit 65eb1da71c
8 files changed
+1054 -90

No files matched your search

+355 -25
View File
@@ -12,8 +12,11 @@ use std::{
use crate::{
config::{Config, ConfigError, GitRemote},
git::{
GitChangeKind, GitCommitActivity, GitDivergence, GitError, GitOperationControl,
GitProgressPhase, GitRepository, PullOutcome,
FetchOutcome, GitChangeKind, GitCommitActivity, GitDivergence, GitError,
GitOperationControl, GitProgressPhase, GitRepository, PullOutcome, PushOutcome,
},
mobile_authentication::{
MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError,
},
repository::{Repository, RepositoryError},
secret_store::{
@@ -46,6 +49,35 @@ pub enum MobileHomeChangeStatus {
Deleted,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileHomeActionKind {
Commit,
Fetch,
Pull,
Push,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeAction {
kind: MobileHomeActionKind,
title: &'static str,
system_image: &'static str,
}
impl MobileHomeAction {
pub fn kind(&self) -> MobileHomeActionKind {
self.kind
}
pub fn title(&self) -> &str {
self.title
}
pub fn system_image(&self) -> &str {
self.system_image
}
}
impl From<GitChangeKind> for MobileHomeChangeStatus {
fn from(kind: GitChangeKind) -> Self {
match kind {
@@ -62,6 +94,7 @@ pub struct MobileHomeSummaryRow {
title: String,
detail: String,
system_image: String,
actions: Vec<MobileHomeAction>,
}
impl MobileHomeSummaryRow {
@@ -80,6 +113,10 @@ impl MobileHomeSummaryRow {
pub fn system_image(&self) -> &str {
&self.system_image
}
pub fn actions(&self) -> &[MobileHomeAction] {
&self.actions
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -126,6 +163,7 @@ pub struct MobileHomeCommit {
system_image: String,
timestamp: i64,
changes: Vec<MobileHomeChange>,
actions: Vec<MobileHomeAction>,
}
impl MobileHomeCommit {
@@ -152,6 +190,10 @@ impl MobileHomeCommit {
pub fn changes(&self) -> &[MobileHomeChange] {
&self.changes
}
pub fn actions(&self) -> &[MobileHomeAction] {
&self.actions
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -227,6 +269,7 @@ pub enum MobileHomePhase {
Authenticating,
Receiving,
Integrating,
Sending,
Finishing,
}
@@ -236,7 +279,8 @@ impl From<GitProgressPhase> for MobileHomePhase {
GitProgressPhase::Validating => Self::Validating,
GitProgressPhase::Authenticating => Self::Authenticating,
GitProgressPhase::Receiving => Self::Receiving,
GitProgressPhase::Integrating | GitProgressPhase::Sending => Self::Integrating,
GitProgressPhase::Integrating => Self::Integrating,
GitProgressPhase::Sending => Self::Sending,
GitProgressPhase::Refreshing => Self::Finishing,
}
}
@@ -244,12 +288,17 @@ impl From<GitProgressPhase> for MobileHomePhase {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileHomeProgress {
action: Option<MobileHomeActionKind>,
phase: MobileHomePhase,
title: String,
detail: String,
}
impl MobileHomeProgress {
pub fn action(&self) -> Option<MobileHomeActionKind> {
self.action
}
pub fn phase(&self) -> MobileHomePhase {
self.phase
}
@@ -264,32 +313,34 @@ impl MobileHomeProgress {
}
pub struct MobileHomeOperation {
authentication: Arc<MobileAuthentication>,
control: GitOperationControl,
phase: Arc<Mutex<MobileHomePhase>>,
action: Mutex<Option<MobileHomeActionKind>>,
}
impl Default for MobileHomeOperation {
fn default() -> Self {
impl MobileHomeOperation {
pub fn new(authentication: Arc<MobileAuthentication>) -> Self {
let phase = Arc::new(Mutex::new(MobileHomePhase::Validating));
let observed = Arc::clone(&phase);
Self {
authentication,
control: GitOperationControl::new(move |phase| {
if let Ok(mut current) = observed.lock() {
*current = phase.into();
}
}),
phase,
action: Mutex::new(None),
}
}
}
impl MobileHomeOperation {
pub fn cancel(&self) {
self.control.cancel();
}
pub fn progress(&self) -> MobileHomeProgress {
progress_copy(
self.action.lock().ok().and_then(|action| *action),
self.phase
.lock()
.map_or(MobileHomePhase::Validating, |phase| *phase),
@@ -320,15 +371,53 @@ impl MobileHomeOperation {
None,
);
}
storage.refresh(now, &self.control)
self.run(MobileHomeActionKind::Fetch, || {
storage.fetch(now, &self.control)
})
}
pub fn refresh(&self) -> Result<MobileHomePage, MobileHomeError> {
MobileHomeStorage::load()?.refresh(unix_seconds()?, &self.control)
pub fn commit(&self, message: &str) -> Result<MobileHomePage, MobileHomeError> {
if message.trim().is_empty() || message.contains('\0') {
return Err(MobileHomeError::invalid_commit_message());
}
self.run(MobileHomeActionKind::Commit, || {
MobileHomeStorage::load()?.commit(message, &self.control)
})
}
pub fn fetch(&self) -> Result<MobileHomePage, MobileHomeError> {
self.run(MobileHomeActionKind::Fetch, || {
MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control)
})
}
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
self.run(MobileHomeActionKind::Pull, || {
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
})
}
pub fn push(&self) -> Result<MobileHomePage, MobileHomeError> {
self.run(MobileHomeActionKind::Push, || {
MobileHomeStorage::load()?.push(unix_seconds()?, &self.control)
})
}
fn run(
&self,
action: MobileHomeActionKind,
operation: impl FnOnce() -> Result<MobileHomePage, MobileHomeError>,
) -> Result<MobileHomePage, MobileHomeError> {
if let Ok(mut current) = self.action.lock() {
*current = Some(action);
}
let reservation = self
.authentication
.reserve_repository_operation(repository_operation(action))
.map_err(MobileHomeError::from_reservation)?;
let result = operation();
drop(reservation);
result
}
}
@@ -356,27 +445,50 @@ impl MobileHomeStorage {
})
}
fn refresh(
fn commit(
&self,
message: &str,
control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> {
control
.report(GitProgressPhase::Validating)
.map_err(MobileHomeError::from_git)?;
let commit = self
.git
.commit_all(message)
.map_err(MobileHomeError::from_git)?;
control
.report(GitProgressPhase::Refreshing)
.map_err(MobileHomeError::from_git)?;
self.page(
MobileHomeFreshness::Cached,
self.config.mobile_home_refreshed_at(),
Some(commit_notice(&commit)),
)
.map_err(MobileHomeError::after_commit)
}
fn fetch(
&self,
now: i64,
control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> {
let store = self.credentials()?;
let refreshed = self.git.fetch_with_transport_controlled(
let fetched = self.git.fetch_with_transport_controlled(
&self.remote,
&store,
&crate::git::EmbeddedFetchTransport,
control,
);
let locked = store.lock();
refreshed.map_err(MobileHomeError::from_git)?;
let outcome = fetched.map_err(MobileHomeError::from_git)?;
if let Err(error) = locked {
return Err(MobileHomeError::partial_secret(error));
}
control
.report(GitProgressPhase::Refreshing)
.map_err(MobileHomeError::from_git)?;
self.current_page(now, None)
self.current_page(now, Some(fetch_notice(&outcome)))
}
fn pull(
@@ -404,6 +516,31 @@ impl MobileHomeStorage {
.map_err(MobileHomeError::after_pull)
}
fn push(
&self,
now: i64,
control: &GitOperationControl,
) -> Result<MobileHomePage, MobileHomeError> {
let store = self.credentials()?;
let pushed = self.git.push_with_transport_controlled(
&self.remote,
None,
&store,
&crate::git::ReqwestGitTransport,
control,
);
let locked = store.lock();
let outcome = pushed.map_err(MobileHomeError::from_git)?;
if let Err(error) = locked {
return Err(MobileHomeError::partial_secret(error));
}
control
.report(GitProgressPhase::Refreshing)
.map_err(MobileHomeError::from_git)?;
self.current_page(now, Some(push_notice(&outcome)))
.map_err(MobileHomeError::after_push)
}
fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
let store = NativeSecretStore::system(
SecretCachePolicy::Disabled,
@@ -463,6 +600,7 @@ pub enum MobileHomeErrorKind {
Authentication,
Conflict,
DirtyLocalChanges,
NoChanges,
Offline,
Interrupted,
SecureStorage,
@@ -557,6 +695,17 @@ impl MobileHomeError {
title: "Local Changes Need Attention",
detail: "Commit or discard local changes before pulling remote activity.".to_owned(),
},
GitError::NoChanges => Self {
kind: MobileHomeErrorKind::NoChanges,
title: "Nothing to Commit",
detail: "The password-store working tree has no changes to commit.".to_owned(),
},
GitError::NonFastForward => Self {
kind: MobileHomeErrorKind::Conflict,
title: "Pull Before Pushing",
detail: "The remote branch contains commits that are not local. Pull and resolve any conflicts before retrying Push."
.to_owned(),
},
GitError::NetworkUnavailable => Self {
kind: MobileHomeErrorKind::Offline,
title: "Server Is Offline",
@@ -589,6 +738,43 @@ impl MobileHomeError {
}
}
fn invalid_commit_message() -> Self {
Self {
kind: MobileHomeErrorKind::NoChanges,
title: "Commit Message Required",
detail: "Enter a non-empty commit message and try again.".to_owned(),
}
}
fn from_reservation(error: MobileRepositoryOperationError) -> Self {
match error {
MobileRepositoryOperationError::DirtyEditor => Self {
kind: MobileHomeErrorKind::DirtyLocalChanges,
title: "Unsaved Entry Edits",
detail: "Save or discard the open entry editor before pulling or pushing."
.to_owned(),
},
MobileRepositoryOperationError::OpenEditor => Self {
kind: MobileHomeErrorKind::DirtyLocalChanges,
title: "Close the Entry Editor",
detail: "Close the open entry editor before pulling so its source cannot change underneath it."
.to_owned(),
},
MobileRepositoryOperationError::Busy => Self {
kind: MobileHomeErrorKind::Interrupted,
title: "Password-Store Action in Progress",
detail: "Wait for the current password-store action to finish, then retry."
.to_owned(),
},
MobileRepositoryOperationError::Unavailable => Self {
kind: MobileHomeErrorKind::Repository,
title: "Password-Store State Is Unavailable",
detail: "The shared mobile repository state could not be locked. Restart IronStorage and retry."
.to_owned(),
},
}
}
fn from_secret(error: SecretStoreError) -> Self {
let detail = match error {
SecretStoreError::Denied => "Access to the application token was denied.",
@@ -631,6 +817,24 @@ impl MobileHomeError {
.to_owned(),
}
}
fn after_commit(_error: Self) -> Self {
Self {
kind: MobileHomeErrorKind::PartialProgress,
title: "Commit Completed, Status Unavailable",
detail: "The commit was created, but Home could not rebuild the current Git status. Reload Home to retry."
.to_owned(),
}
}
fn after_push(_error: Self) -> Self {
Self {
kind: MobileHomeErrorKind::PartialProgress,
title: "Push Completed, Status Unavailable",
detail: "The remote branch was updated, but Home could not rebuild the current Git status. Reload Home to retry."
.to_owned(),
}
}
}
impl fmt::Display for MobileHomeError {
@@ -674,6 +878,11 @@ fn page_from_divergence(
title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
detail: "Tracked HTTPS branch".to_owned(),
system_image: "point.3.connected.trianglepath.dotted".to_owned(),
actions: vec![
mobile_action(MobileHomeActionKind::Fetch),
mobile_action(MobileHomeActionKind::Pull),
mobile_action(MobileHomeActionKind::Push),
],
},
MobileHomeSummaryRow {
id: "divergence".to_owned(),
@@ -684,6 +893,13 @@ fn page_from_divergence(
} else {
"arrow.triangle.2.circlepath".to_owned()
},
actions: [
(behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)),
(ahead > 0).then(|| mobile_action(MobileHomeActionKind::Push)),
]
.into_iter()
.flatten()
.collect(),
},
MobileHomeSummaryRow {
id: "working-tree".to_owned(),
@@ -702,6 +918,11 @@ fn page_from_divergence(
} else {
"exclamationmark.triangle".to_owned()
},
actions: if local_count > 0 {
vec![mobile_action(MobileHomeActionKind::Commit)]
} else {
Vec::new()
},
},
];
MobileHomePage {
@@ -765,6 +986,11 @@ fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeComm
},
timestamp: commit.timestamp(),
changes,
actions: vec![mobile_action(if incoming {
MobileHomeActionKind::Pull
} else {
MobileHomeActionKind::Push
})],
}
}
@@ -881,23 +1107,113 @@ fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
}
}
fn progress_copy(phase: MobileHomePhase) -> MobileHomeProgress {
let (title, detail) = match phase {
MobileHomePhase::Validating => ("Checking Home", "Validating local and remote Git state."),
MobileHomePhase::Authenticating => (
fn commit_notice(commit: &str) -> MobileHomeNotice {
MobileHomeNotice {
title: "Changes Committed".to_owned(),
detail: format!("Created commit {}.", &commit[..commit.len().min(12)]),
system_image: "checkmark.circle".to_owned(),
}
}
fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice {
MobileHomeNotice {
title: if outcome.received_pack() {
"Remote Status Updated"
} else {
"Remote Already Current"
}
.to_owned(),
detail: if outcome.received_pack() {
format!(
"Received new objects from {} without changing the local password store.",
outcome.remote()
)
} else {
format!("{} has no new objects for this clone.", outcome.remote())
},
system_image: "arrow.clockwise.circle".to_owned(),
}
}
fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice {
let unchanged = outcome.old() == Some(outcome.new_id());
MobileHomeNotice {
title: if unchanged {
"Remote Already Current"
} else {
"Changes Pushed"
}
.to_owned(),
detail: if unchanged {
format!(
"{}/{} already points to the local commit.",
outcome.remote(),
outcome.branch()
)
} else {
format!(
"Updated {}/{} with local commits.",
outcome.remote(),
outcome.branch()
)
},
system_image: "arrow.up.circle".to_owned(),
}
}
fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction {
let (title, system_image) = match kind {
MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"),
MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"),
MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"),
MobileHomeActionKind::Push => ("Push", "arrow.up.circle"),
};
MobileHomeAction {
kind,
title,
system_image,
}
}
fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation {
match action {
MobileHomeActionKind::Commit => MobileRepositoryOperation::Commit,
MobileHomeActionKind::Fetch => MobileRepositoryOperation::Fetch,
MobileHomeActionKind::Pull => MobileRepositoryOperation::Pull,
MobileHomeActionKind::Push => MobileRepositoryOperation::Push,
}
}
fn progress_copy(
action: Option<MobileHomeActionKind>,
phase: MobileHomePhase,
) -> MobileHomeProgress {
let (title, detail) = match (action, phase) {
(Some(MobileHomeActionKind::Commit), MobileHomePhase::Validating) => (
"Committing Changes",
"Staging current password-store changes and creating the commit in Rust.",
),
(_, MobileHomePhase::Validating) => {
("Checking Home", "Validating local and remote Git state.")
}
(_, MobileHomePhase::Authenticating) => (
"Authenticating",
"Reading the application token from protected storage.",
),
MobileHomePhase::Receiving => ("Refreshing Remote", "Receiving remote Git objects."),
MobileHomePhase::Integrating => (
(_, MobileHomePhase::Receiving) => ("Refreshing Remote", "Receiving remote Git objects."),
(_, MobileHomePhase::Integrating) => (
"Updating Password Store",
"Integrating fetched commits into the local clone.",
),
MobileHomePhase::Finishing => {
(_, MobileHomePhase::Sending) => {
("Pushing Changes", "Sending local Git objects over HTTPS.")
}
(_, MobileHomePhase::Finishing) => {
("Updating Home", "Building current activity and divergence.")
}
};
MobileHomeProgress {
action,
phase,
title: title.to_owned(),
detail: detail.to_owned(),
@@ -935,8 +1251,8 @@ mod tests {
use crate::git::{GitChangeKind, GitError};
use super::{
MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind, display_text, is_stale,
mobile_change,
MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind,
MobileHomePhase, display_text, is_stale, mobile_action, mobile_change, progress_copy,
};
#[test]
@@ -974,6 +1290,20 @@ mod tests {
assert_eq!(offline.kind(), MobileHomeErrorKind::Offline);
let dirty = super::MobileHomeError::from_git(GitError::DirtyWorktree);
assert_eq!(dirty.kind(), MobileHomeErrorKind::DirtyLocalChanges);
let push_conflict = super::MobileHomeError::from_git(GitError::NonFastForward);
assert_eq!(push_conflict.kind(), MobileHomeErrorKind::Conflict);
let no_changes = super::MobileHomeError::from_git(GitError::NoChanges);
assert_eq!(no_changes.kind(), MobileHomeErrorKind::NoChanges);
assert!(!offline.to_string().contains("token-value"));
}
#[test]
fn actions_and_progress_are_typed_before_swift_presentation() {
let push = mobile_action(MobileHomeActionKind::Push);
assert_eq!(push.title(), "Push");
assert_eq!(push.system_image(), "arrow.up.circle");
let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending);
assert_eq!(progress.action(), Some(MobileHomeActionKind::Push));
assert_eq!(progress.title(), "Pushing Changes");
}
}