557 lines
26 KiB
Rust
557 lines
26 KiB
Rust
//! In-app Git host logic on `DesktopApp` — Git-panel snapshot
|
|
//! refresh, background-job draining, and panel action dispatch.
|
|
//!
|
|
//! Split out of `main.rs` to keep that file under the repo's
|
|
//! 800-line-per-file cap; `main.rs` keeps the `DesktopApp` struct,
|
|
//! its general `impl`, and `fn main`.
|
|
|
|
use crate::{git_jobs, persistence, DesktopApp};
|
|
|
|
impl DesktopApp {
|
|
/// Request a refresh of the Git-panel snapshot.
|
|
///
|
|
/// The git queries (`status` / `log` / branch) run on a worker
|
|
/// thread — `poll_git_status_job` applies the result on a later
|
|
/// frame — so even a large repository never freezes the UI. When
|
|
/// no repository is bound there is nothing to query, so the panel
|
|
/// is cleared inline. The panel's `open` / input / `pulling`
|
|
/// fields are left untouched — the caller owns those.
|
|
pub(crate) fn refresh_git_panel(&mut self) {
|
|
match self.git_session.repo().cloned() {
|
|
Some(repo) => {
|
|
// A fresh request supersedes any in-flight query.
|
|
self.git_status_job = Some(git_jobs::GitStatusJob::spawn(repo));
|
|
}
|
|
None => {
|
|
// Discard any in-flight status query first — its
|
|
// result is for the previous repository and must not
|
|
// land back onto the now-unbound panel.
|
|
self.git_status_job = None;
|
|
let panel = &mut self.host.editor_state_mut().editor_ui.git_panel;
|
|
panel.in_repo = false;
|
|
panel.branch = None;
|
|
panel.branches.clear();
|
|
panel.dirty_count = 0;
|
|
panel.conflicted_count = 0;
|
|
panel.merging = false;
|
|
panel.conflicted_files.clear();
|
|
panel.changed_files.clear();
|
|
panel.remotes.clear();
|
|
panel.recent_commits.clear();
|
|
// No repo → no ready view → no header popovers.
|
|
panel.branch_picker_open = false;
|
|
panel.overflow_open = false;
|
|
panel.overflow_view = op_editor_core::GitOverflowView::Menu;
|
|
panel.close_tracked_picker();
|
|
// Cleared synchronously — there is nothing to wait for.
|
|
panel.loading = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Drain a finished background Git status query into the panel.
|
|
/// Returns `true` when the snapshot actually changed, so a
|
|
/// periodic refresh only repaints on a real change.
|
|
pub(crate) fn poll_git_status_job(&mut self) -> bool {
|
|
let Some(job) = self.git_status_job.as_mut() else {
|
|
return false;
|
|
};
|
|
let Some(snap) = job.poll() else {
|
|
return false;
|
|
};
|
|
self.git_status_job = None;
|
|
// Guard against a stale job: if the document is no longer in
|
|
// a repository, the snapshot is for a since-unbound repo —
|
|
// drop it rather than repopulating the panel.
|
|
if !self.git_session.is_bound() {
|
|
return false;
|
|
}
|
|
// Resolve the stored-credential kind for the remote host before the
|
|
// `panel` borrow (it reads `git_session`'s auth store).
|
|
let stored_auth = match snap.remote_host.as_deref() {
|
|
None => String::new(),
|
|
Some(host) => match self
|
|
.git_session
|
|
.auth_stores()
|
|
.and_then(|(auth, _)| auth.get(host).ok().flatten())
|
|
{
|
|
Some(op_git::Credential::Ssh { .. }) => "ssh".to_string(),
|
|
Some(op_git::Credential::Https { .. }) => "token".to_string(),
|
|
None => "none".to_string(),
|
|
},
|
|
};
|
|
let panel = &mut self.host.editor_state_mut().editor_ui.git_panel;
|
|
let changed = panel.in_repo != snap.in_repo
|
|
|| panel.branch != snap.branch
|
|
|| panel.branches != snap.branches
|
|
|| panel.dirty_count != snap.dirty_count
|
|
|| panel.ahead != snap.ahead
|
|
|| panel.conflicted_count != snap.conflicted_count
|
|
|| panel.merging != snap.merging
|
|
|| panel.conflicted_files != snap.conflicted_files
|
|
|| panel.changed_files != snap.changed_files
|
|
|| panel.remotes != snap.remotes
|
|
|| panel.recent_commits != snap.recent_commits;
|
|
// Collapse the inline detail card when the log changes — its
|
|
// index would otherwise point at a since-shifted commit.
|
|
let commits_changed = panel.recent_commits != snap.recent_commits;
|
|
if commits_changed {
|
|
panel.expanded_commit = None;
|
|
panel.expanded_commit_diff = None;
|
|
}
|
|
panel.in_repo = snap.in_repo;
|
|
panel.branch = snap.branch;
|
|
panel.branches = snap.branches;
|
|
panel.dirty_count = snap.dirty_count;
|
|
panel.ahead = snap.ahead;
|
|
panel.behind = snap.behind;
|
|
panel.remote_host = snap.remote_host;
|
|
panel.stored_auth = stored_auth;
|
|
panel.conflicted_count = snap.conflicted_count;
|
|
panel.merging = snap.merging;
|
|
panel.conflicted_files = snap.conflicted_files;
|
|
panel.changed_files = snap.changed_files;
|
|
panel.remotes = snap.remotes;
|
|
panel.recent_commits = snap.recent_commits;
|
|
// The header popovers only exist in the ready view; if the
|
|
// refresh left that state (no repo / merging), clear the popover
|
|
// flags so they can't go stale and dead-end input (the press-time
|
|
// modal guard keys off these flags). A dirty working tree still
|
|
// shows the ready view (TS parity), so it must NOT close them —
|
|
// otherwise a periodic status poll snaps an open branch-picker /
|
|
// overflow menu shut.
|
|
if !panel.header_popovers_allowed() {
|
|
panel.branch_picker_open = false;
|
|
panel.overflow_open = false;
|
|
panel.overflow_view = op_editor_core::GitOverflowView::Menu;
|
|
panel.close_tracked_picker();
|
|
}
|
|
// The fresh snapshot has landed — leave the loading state.
|
|
let was_loading = panel.loading;
|
|
panel.loading = false;
|
|
changed || was_loading
|
|
}
|
|
|
|
/// Drain a finished background Git diff into the panel's diff
|
|
/// view. Returns `true` when the view actually changed, so a
|
|
/// periodic refresh only repaints on a real change.
|
|
pub(crate) fn poll_git_diff_job(&mut self) -> bool {
|
|
let Some(job) = self.git_diff_job.as_mut() else {
|
|
return false;
|
|
};
|
|
let Some(result) = job.poll() else {
|
|
return false;
|
|
};
|
|
self.git_diff_job = None;
|
|
let panel = &mut self.host.editor_state_mut().editor_ui.git_panel;
|
|
// The user may have closed the panel / diff view (or it was
|
|
// cleared by a repo switch) while the worker ran — drop a
|
|
// stale result rather than re-opening the view.
|
|
if !panel.open || panel.diff.is_none() {
|
|
return false;
|
|
}
|
|
panel.diff = Some(op_editor_core::GitDiffView {
|
|
title: result.title,
|
|
lines: result.lines,
|
|
scroll: 0,
|
|
h_scroll: 0,
|
|
stage_path: result.stage_path,
|
|
});
|
|
true
|
|
}
|
|
|
|
/// Run a queued Git-panel action — `pending_action`, set by a
|
|
/// panel click or by Enter in the commit input — then refresh
|
|
/// the panel snapshot. A no-op when nothing is queued.
|
|
pub(crate) fn drain_git_action(&mut self) {
|
|
use op_editor_core::GitPanelAction;
|
|
let Some(action) = self
|
|
.host
|
|
.editor_state_mut()
|
|
.editor_ui
|
|
.git_panel
|
|
.pending_action
|
|
.take()
|
|
else {
|
|
return;
|
|
};
|
|
match action {
|
|
GitPanelAction::InitRepo => {
|
|
self.init_repo_for_doc();
|
|
}
|
|
GitPanelAction::OpenRepo => {
|
|
self.open_existing_repo();
|
|
}
|
|
GitPanelAction::CloneRepo => {
|
|
self.open_clone_form();
|
|
}
|
|
GitPanelAction::PickCloneDest => {
|
|
self.pick_clone_dest();
|
|
}
|
|
GitPanelAction::SubmitClone => {
|
|
self.submit_clone();
|
|
}
|
|
// Refresh is handled by the shared snapshot below.
|
|
GitPanelAction::Refresh => {}
|
|
GitPanelAction::Pull => {
|
|
// Run the network-bound pull on a worker thread so the
|
|
// UI never freezes. A pull rewrites the tracked
|
|
// document on disk and the editor is reloaded when it
|
|
// lands (`poll_git_pull_job`) — confirm first so
|
|
// unsaved in-memory edits are not silently lost. A
|
|
// second Pull while one is in flight is ignored.
|
|
if self.git_pull_job.is_none()
|
|
&& self.collaboration_allows_git_worktree_rewrite()
|
|
&& self.confirm_document_reload()
|
|
{
|
|
if let Some(repo) = self.git_session.authed_repo() {
|
|
self.git_pull_job = Some(git_jobs::GitPullJob::spawn(repo));
|
|
// Snapshot the document so the post-pull reload
|
|
// can detect edits made *during* the async
|
|
// pull — those the confirm above did not cover.
|
|
let state = self.host.editor_state();
|
|
self.git_pull_doc_baseline = Some((
|
|
self.host.document_epoch(),
|
|
state.document_generation(),
|
|
state.document_revision(),
|
|
));
|
|
self.host.editor_state_mut().editor_ui.git_panel.pulling = true;
|
|
}
|
|
}
|
|
}
|
|
GitPanelAction::Push => {
|
|
// Network-bound push on a worker thread; a second
|
|
// Push while one is in flight is ignored. Push does
|
|
// not touch the working tree, so no reload / confirm.
|
|
if self.git_push_job.is_none() {
|
|
if let Some(repo) = self.git_session.authed_repo() {
|
|
self.git_push_job = Some(git_jobs::GitPushJob::spawn(repo));
|
|
self.host.editor_state_mut().editor_ui.git_panel.pushing = true;
|
|
}
|
|
}
|
|
}
|
|
GitPanelAction::Commit => {
|
|
let message = self
|
|
.host
|
|
.editor_state()
|
|
.editor_ui
|
|
.git_panel
|
|
.commit_input
|
|
.text()
|
|
.trim()
|
|
.to_string();
|
|
// Commit exactly the staged index — the set the user
|
|
// assembled with the per-file checkboxes and per-hunk
|
|
// Stage buttons. Nothing is auto-saved or force-staged
|
|
// here, so a partial (hunk-level) staging is never
|
|
// silently widened into a whole-file commit. Unsaved
|
|
// editor edits stay in the editor; the user saves +
|
|
// stages them explicitly before they can be committed.
|
|
if !message.is_empty() {
|
|
match self.git_session.commit_staged(&message) {
|
|
Ok(()) => {
|
|
let panel = &mut self.host.editor_state_mut().editor_ui.git_panel;
|
|
panel.commit_input.set_text("");
|
|
panel.defocus_commit_input(0);
|
|
}
|
|
Err(err) => {
|
|
self.show_git_op_error_dialog("commit", &err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
GitPanelAction::CommitMilestone => {
|
|
let message = self
|
|
.host
|
|
.editor_state()
|
|
.editor_ui
|
|
.git_panel
|
|
.commit_input
|
|
.text()
|
|
.trim()
|
|
.to_string();
|
|
// Ready-view "Save milestone": snapshot the live design
|
|
// as a commit in one step — write the editor's current
|
|
// state to the tracked .op, stage that file, then commit
|
|
// (the TS `commitMilestone` flow). stage_tracked is
|
|
// explicitly designed to refresh the index blob after a
|
|
// save, so a milestone captures exactly what's on screen.
|
|
// No committer identity yet → show the signature form and
|
|
// defer the commit (the message stays put; `save_author_identity`
|
|
// re-fires this action). TS `authorIdentity === null` path.
|
|
let collaboration_allows_milestone = message.is_empty()
|
|
|| self.host.gate_collaboration_action(
|
|
op_editor_core::CollabGateAction::SaveShared,
|
|
op_editor_core::CollabEditSource::User,
|
|
);
|
|
let needs_author = collaboration_allows_milestone
|
|
&& !message.is_empty()
|
|
&& !self
|
|
.git_session
|
|
.repo()
|
|
.map(|r| r.has_committer_identity())
|
|
.unwrap_or(true);
|
|
if needs_author {
|
|
let panel = &mut self.host.editor_state_mut().editor_ui.git_panel;
|
|
panel.author_prompt = true;
|
|
panel.author_name_focused = true;
|
|
panel.author_email_focused = false;
|
|
// Hand keyboard focus to the form, off the (now hidden)
|
|
// commit box, so typing lands in the name/email fields.
|
|
panel.defocus_commit_input(0);
|
|
panel.commit_no_changes = false;
|
|
} else if collaboration_allows_milestone
|
|
&& !message.is_empty()
|
|
&& self.finish_background_saves()
|
|
{
|
|
// A milestone performs its own synchronous save before staging.
|
|
// Drain any UI-requested save first so an older background
|
|
// snapshot cannot rename over the milestone afterward.
|
|
match self.git_session.tracked_file().map(|p| p.to_path_buf()) {
|
|
Some(path) => {
|
|
match op_host_services::doc_io::save_to_path(
|
|
self.host.editor_state(),
|
|
&path,
|
|
) {
|
|
Ok(()) => {
|
|
self.mark_document_saved();
|
|
// Stage, then guard against an empty
|
|
// milestone: if the saved file matches the
|
|
// last commit there is nothing to commit,
|
|
// so skip rather than create an empty one.
|
|
let committed = match self.git_session.stage_tracked() {
|
|
Ok(()) if self.git_session.tracked_has_staged_changes() => {
|
|
self.git_session.commit_staged(&message).map(|()| true)
|
|
}
|
|
Ok(()) => Ok(false),
|
|
Err(e) => Err(e),
|
|
};
|
|
match committed {
|
|
Ok(true) => {
|
|
let panel = &mut self
|
|
.host
|
|
.editor_state_mut()
|
|
.editor_ui
|
|
.git_panel;
|
|
panel.commit_input.set_text("");
|
|
panel.defocus_commit_input(0);
|
|
panel.commit_no_changes = false;
|
|
}
|
|
// Nothing changed — keep the message and
|
|
// flag a "no changes" hint under the box.
|
|
Ok(false) => {
|
|
self.host
|
|
.editor_state_mut()
|
|
.editor_ui
|
|
.git_panel
|
|
.commit_no_changes = true;
|
|
}
|
|
Err(err) => self.show_git_op_error_dialog("commit", &err),
|
|
}
|
|
}
|
|
// `show_error_dialog_public` takes an
|
|
// already-rendered `&str` detail; render
|
|
// through `Display` so this stays valid
|
|
// whichever error type `doc_io` reports.
|
|
Err(detail) => persistence::show_error_dialog_public(
|
|
&self.host,
|
|
op_host_services::doc_io::ErrorKind::Save,
|
|
Some(&path),
|
|
&detail.to_string(),
|
|
),
|
|
}
|
|
}
|
|
None => persistence::show_error_dialog_public(
|
|
&self.host,
|
|
op_host_services::doc_io::ErrorKind::Save,
|
|
None,
|
|
"the open document is not tracked under this repository — \
|
|
save it into the repository folder first",
|
|
),
|
|
}
|
|
}
|
|
}
|
|
GitPanelAction::SwitchBranch(name) => {
|
|
self.run_reloading_git_op("switch", |repo| repo.switch_branch(&name));
|
|
}
|
|
GitPanelAction::CreateBranch(name) => {
|
|
self.run_reloading_git_op("branch", |repo| repo.create_and_switch_branch(&name));
|
|
}
|
|
GitPanelAction::MergeBranch(name) => {
|
|
self.run_branch_merge(&name);
|
|
}
|
|
GitPanelAction::SetupSshAuth => {
|
|
self.setup_ssh_auth();
|
|
}
|
|
GitPanelAction::ApplyMergeResolution => {
|
|
self.apply_merge_resolution();
|
|
}
|
|
GitPanelAction::StageHunk(path, hunk_index) => {
|
|
self.stage_diff_hunk(&path, hunk_index);
|
|
}
|
|
GitPanelAction::SetHttpsAuth(credential) => {
|
|
self.store_https_credential(&credential);
|
|
}
|
|
GitPanelAction::SetRemote(url) => {
|
|
let url = url.trim().to_string();
|
|
if !url.is_empty() {
|
|
if let Some(repo) = self.git_session.repo() {
|
|
if let Err(err) = repo.set_remote("origin", &url) {
|
|
eprintln!("openpencil-desktop: git set-remote failed: {err}");
|
|
}
|
|
}
|
|
let panel = &mut self.host.editor_state_mut().editor_ui.git_panel;
|
|
panel.remote_input.set_text("");
|
|
panel.remote_focused = false;
|
|
}
|
|
}
|
|
GitPanelAction::AbortMerge => {
|
|
self.run_reloading_git_op("merge --abort", |repo| repo.abort_merge());
|
|
}
|
|
GitPanelAction::CompleteMerge => {
|
|
self.run_reloading_git_op("complete merge", |repo| repo.complete_merge());
|
|
}
|
|
GitPanelAction::ToggleStageFile(path) => {
|
|
// Flip the file's index state — stage it if currently
|
|
// unstaged, unstage it otherwise. The shared snapshot
|
|
// refresh below re-reads the real index.
|
|
let staged = self
|
|
.host
|
|
.editor_state()
|
|
.editor_ui
|
|
.git_panel
|
|
.changed_files
|
|
.iter()
|
|
.find(|f| f.path == path)
|
|
.map(|f| f.staged)
|
|
.unwrap_or(false);
|
|
if let Some(repo) = self.git_session.repo() {
|
|
let p = std::path::Path::new(&path);
|
|
let result = if staged {
|
|
repo.unstage(&[p])
|
|
} else {
|
|
repo.stage(&[p])
|
|
};
|
|
if let Err(err) = result {
|
|
eprintln!("openpencil-desktop: git stage toggle failed: {err}");
|
|
}
|
|
}
|
|
}
|
|
GitPanelAction::ShowDiff(target) => {
|
|
// Run `git diff` / `git show` on a worker thread — a
|
|
// diff can be large. The panel enters diff mode at
|
|
// once showing a placeholder; `poll_git_diff_job`
|
|
// swaps in the real lines on a later frame.
|
|
if let Some(repo) = self.git_session.repo().cloned() {
|
|
let locale = self.host.editor_state().editor_ui.locale;
|
|
self.host.editor_state_mut().editor_ui.git_panel.diff =
|
|
Some(op_editor_core::GitDiffView {
|
|
title: op_i18n::translate(locale, "git.panel.diffLoading").to_string(),
|
|
lines: vec![
|
|
op_i18n::translate(locale, "git.panel.diffComputing").to_string()
|
|
],
|
|
scroll: 0,
|
|
h_scroll: 0,
|
|
stage_path: None,
|
|
});
|
|
self.git_diff_job = Some(git_jobs::GitDiffJob::spawn(repo, target, locale));
|
|
}
|
|
}
|
|
GitPanelAction::RestoreCommit(rev) => {
|
|
// Roll the tracked document back to that commit, then
|
|
// reload so the editor reflects it (TS `restoreCommit`).
|
|
if let Some(path) = self.git_session.tracked_file().map(|p| p.to_path_buf()) {
|
|
self.run_reloading_git_op("restore", move |repo| repo.restore(&path, &rev));
|
|
}
|
|
}
|
|
GitPanelAction::CopyHash(rev) => {
|
|
// Pure clipboard write — no git op, no reload.
|
|
crate::clipboard::set_text(&rev);
|
|
}
|
|
GitPanelAction::LoadCommitDiff(index) => self.load_expanded_commit_diff(index),
|
|
GitPanelAction::EnterTrackedPicker => self.enter_tracked_picker(),
|
|
GitPanelAction::BindTrackedFile(path, open) => {
|
|
if !open || self.collaboration_allows_git_worktree_rewrite() {
|
|
self.bind_tracked_file(path, open);
|
|
}
|
|
}
|
|
GitPanelAction::ClearAuthor => self.clear_commit_author(),
|
|
GitPanelAction::CloseRepo => self.close_repo(),
|
|
GitPanelAction::EnterSshKeys => self.enter_ssh_keys(),
|
|
GitPanelAction::ImportSshKey => self.import_ssh_key(),
|
|
GitPanelAction::SaveAuthor => self.save_author_identity(),
|
|
GitPanelAction::FetchRemote => {
|
|
// `git fetch` on origin (with stored credentials) — the tail
|
|
// refresh re-reads ahead/behind afterward.
|
|
if let Some(repo) = self.git_session.authed_repo() {
|
|
if let Err(e) = repo.fetch() {
|
|
eprintln!("openpencil-desktop: git fetch failed: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Every action ends with a fresh snapshot + a repaint.
|
|
self.refresh_git_panel();
|
|
self.host.mark_editor_state_dirty();
|
|
self.request_redraw(true);
|
|
}
|
|
|
|
/// Run a working-tree-rewriting git op (branch switch, merge
|
|
/// abort / complete) then reload the document from disk so the
|
|
/// editor reflects the new state. Confirms first — the reload
|
|
/// would otherwise silently discard unsaved in-memory edits.
|
|
fn run_reloading_git_op(
|
|
&mut self,
|
|
label: &str,
|
|
op: impl FnOnce(&op_git::GitRepo) -> Result<(), op_git::GitError>,
|
|
) {
|
|
if !self.collaboration_allows_git_worktree_rewrite() || !self.confirm_document_reload() {
|
|
return;
|
|
}
|
|
let ok = match self.git_session.repo() {
|
|
Some(repo) => match op(repo) {
|
|
Ok(()) => true,
|
|
Err(err) => {
|
|
eprintln!("openpencil-desktop: git {label} failed: {err}");
|
|
false
|
|
}
|
|
},
|
|
None => false,
|
|
};
|
|
if ok {
|
|
self.reload_tracked_document();
|
|
}
|
|
}
|
|
|
|
/// Git pull is the only current background job that can rewrite the
|
|
/// tracked document. Start/Join/Retry calls this before changing the
|
|
/// collaboration phase: a ready result is drained while the document is
|
|
/// still standalone; an in-flight result returns `false`, keeping the
|
|
/// transition fail-closed until a later UI turn.
|
|
pub(crate) fn settle_git_before_collaboration_transition(&mut self) -> bool {
|
|
if self.git_pull_job.is_none() {
|
|
return true;
|
|
}
|
|
let _ = self.poll_git_pull_job();
|
|
self.git_pull_job.is_none()
|
|
}
|
|
|
|
/// A worktree rewrite is a whole-document replacement from the editor's
|
|
/// perspective. Keep this typed seam shared by synchronous Git actions,
|
|
/// the async pull launch, and the final reload sink.
|
|
pub(crate) fn collaboration_allows_git_worktree_rewrite(&mut self) -> bool {
|
|
self.host.gate_collaboration_action(
|
|
op_editor_core::CollabGateAction::ReplaceDocument,
|
|
op_editor_core::CollabEditSource::ExternalSync,
|
|
)
|
|
}
|
|
}
|
|
|
|
mod auth_error;
|
|
#[cfg(test)]
|
|
mod collab_tests;
|
|
mod patch;
|
|
mod repo_ops;
|
|
|
|
use patch::{build_hunk_patch, build_merge_resolve, merge_conflict_detail};
|