feat(git): sync with a remote — fetch, pull and push
Add remote sync to the Git panel: set/edit an origin URL, then fetch, pull (fast-forward) and push, with live ahead/behind counts against the remote-tracking branch. Backend: - git_ops: remote_status (git2 ahead/behind + upstream), set_remote, and fetch/pull/push that shell out to the system git CLI so they reuse the user's existing credentials (SSH agent, keychain / credential helpers) instead of reimplementing libgit2 credential callbacks. Pull is --ff-only to avoid leaving a conflicted tree from the GUI; push uses --set-upstream. - New commands git_remote_status/set_remote/fetch/pull/push, registered. - Test: push to a bare local remote, then assert upstream + ahead/behind. Frontend: - RemoteStatus type, api wrappers, store state (remote, syncing) with refreshRemote wired into refreshGit; pull reloads diagrams + open buffer. - GitPanel remote section: URL, ahead/behind badges, Fetch/Pull/Push, and a set-remote dialog. cargo test (7 passing incl. roundtrip), svelte-check and build all green.
This commit is contained in:
@@ -20,6 +20,11 @@ TypeScript** frontend using **CodeMirror 6** and **Mermaid 11**.
|
||||
- **Git version control built in** — view the working-tree status, write commit
|
||||
messages, browse history, create branches and switch between them. Each branch
|
||||
keeps its own set of diagrams, just like normal Git.
|
||||
- **Remote sync** — point a project at a remote (`origin`), then **fetch**,
|
||||
**pull** (fast-forward) and **push** from the Git panel, with live ahead/behind
|
||||
counts. Network operations shell out to your system `git`, so they reuse your
|
||||
existing credentials (SSH agent, keychain / credential helpers) — no separate
|
||||
login.
|
||||
- **Optimize panel (Diagram Doctor)** — analyse a tangled flowchart and declutter
|
||||
it in one click: a readability score and metrics (hubs, density, cross-group
|
||||
edges), plus source rewrites for the **ELK layered layout**, extra
|
||||
@@ -132,6 +137,10 @@ cd src-tauri && cargo test
|
||||
for you. Switching branches reloads the diagram list from that branch.
|
||||
- **History** shows the most recent commits with short SHA, message, author and
|
||||
relative time.
|
||||
- **Remote sync** sets/uses an `origin` remote. Fetch updates remote-tracking
|
||||
refs; pull is fast-forward only (it never leaves a half-merged tree from the
|
||||
GUI — reconcile divergent history yourself); push uses `--set-upstream`. These
|
||||
invoke the system `git` binary, which must be installed and on `PATH`.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use tauri::State;
|
||||
use crate::db::{self, ProjectRecord};
|
||||
use crate::diagram::{self, DiagramInfo};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::git_ops::{self, BranchInfo, CommitInfo, FileStatus};
|
||||
use crate::git_ops::{self, BranchInfo, CommitInfo, FileStatus, RemoteStatus};
|
||||
use crate::project::{self, ProjectConfig};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -176,6 +176,37 @@ pub fn git_checkout_branch(path: String, name: String) -> Result<()> {
|
||||
git_ops::checkout_branch(Path::new(&path), &name)
|
||||
}
|
||||
|
||||
// ---- Remote sync ----------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_remote_status(path: String) -> Result<RemoteStatus> {
|
||||
git_ops::remote_status(Path::new(&path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_set_remote(path: String, url: String) -> Result<()> {
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return Err(AppError::other("remote URL cannot be empty"));
|
||||
}
|
||||
git_ops::set_remote(Path::new(&path), url)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_fetch(path: String) -> Result<String> {
|
||||
git_ops::fetch(Path::new(&path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(path: String) -> Result<String> {
|
||||
git_ops::pull(Path::new(&path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(path: String) -> Result<String> {
|
||||
git_ops::push(Path::new(&path))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export helpers & settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use git2::{
|
||||
BranchType, Commit, IndexAddOption, Repository, RepositoryInitOptions, Signature, StatusOptions,
|
||||
@@ -30,6 +31,21 @@ pub struct FileStatus {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Snapshot of the relationship between the current branch and its remote.
|
||||
///
|
||||
/// `ahead`/`behind` are measured against the remote-tracking ref, so they
|
||||
/// reflect the last fetch — they update after `fetch`/`pull`/`push`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RemoteStatus {
|
||||
pub has_remote: bool,
|
||||
pub remote: Option<String>,
|
||||
pub url: Option<String>,
|
||||
/// Upstream ref shorthand, e.g. `origin/main`, if the branch tracks one.
|
||||
pub upstream: Option<String>,
|
||||
pub ahead: usize,
|
||||
pub behind: usize,
|
||||
}
|
||||
|
||||
/// Initialize a fresh repository with `main` as the default branch.
|
||||
pub fn init_repo(dir: &Path) -> Result<Repository> {
|
||||
let mut opts = RepositoryInitOptions::new();
|
||||
@@ -188,3 +204,138 @@ pub fn status(dir: &Path) -> Result<Vec<FileStatus>> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remotes & sync
|
||||
//
|
||||
// Network operations (fetch/pull/push) shell out to the system `git` CLI so
|
||||
// they transparently reuse the user's existing credentials — SSH agent keys,
|
||||
// macOS keychain / credential helpers, and host-key trust — rather than
|
||||
// reimplementing all of that through libgit2's credential callbacks. Read-only
|
||||
// introspection (remote URL, ahead/behind) stays on git2 since it needs no
|
||||
// network and no auth.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pick the remote to sync with: `origin` if present, otherwise the first one.
|
||||
fn pick_remote(repo: &Repository) -> Result<Option<String>> {
|
||||
let remotes = repo.remotes()?;
|
||||
if remotes.iter().flatten().any(|r| r == "origin") {
|
||||
return Ok(Some("origin".to_string()));
|
||||
}
|
||||
Ok(remotes.get(0).map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
/// Describe how the current branch relates to its remote-tracking branch.
|
||||
pub fn remote_status(dir: &Path) -> Result<RemoteStatus> {
|
||||
let repo = Repository::open(dir)?;
|
||||
|
||||
let Some(name) = pick_remote(&repo)? else {
|
||||
return Ok(RemoteStatus {
|
||||
has_remote: false,
|
||||
remote: None,
|
||||
url: None,
|
||||
upstream: None,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
};
|
||||
let url = repo
|
||||
.find_remote(&name)
|
||||
.ok()
|
||||
.and_then(|r| r.url().map(|s| s.to_string()));
|
||||
|
||||
let mut upstream = None;
|
||||
let (mut ahead, mut behind) = (0usize, 0usize);
|
||||
if let Ok(head) = repo.head() {
|
||||
if let (Some(local_oid), Some(shorthand)) = (head.target(), head.shorthand()) {
|
||||
if let Ok(branch) = repo.find_branch(shorthand, BranchType::Local) {
|
||||
if let Ok(up) = branch.upstream() {
|
||||
upstream = up.name().ok().flatten().map(|s| s.to_string());
|
||||
if let Some(up_oid) = up.get().target() {
|
||||
if let Ok((a, b)) = repo.graph_ahead_behind(local_oid, up_oid) {
|
||||
ahead = a;
|
||||
behind = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RemoteStatus {
|
||||
has_remote: true,
|
||||
remote: Some(name),
|
||||
url,
|
||||
upstream,
|
||||
ahead,
|
||||
behind,
|
||||
})
|
||||
}
|
||||
|
||||
/// Add (or update) the `origin` remote URL.
|
||||
pub fn set_remote(dir: &Path, url: &str) -> Result<()> {
|
||||
let repo = Repository::open(dir)?;
|
||||
if repo.find_remote("origin").is_ok() {
|
||||
repo.remote_set_url("origin", url)?;
|
||||
} else {
|
||||
repo.remote("origin", url)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the remote to operate on, erroring clearly if none is configured.
|
||||
fn remote_or_err(dir: &Path) -> Result<String> {
|
||||
let repo = Repository::open(dir)?;
|
||||
pick_remote(&repo)?.ok_or_else(|| AppError::other("No remote configured. Add one first."))
|
||||
}
|
||||
|
||||
/// Run a `git` subcommand in `dir`, returning trimmed output or a clear error.
|
||||
fn run_git(dir: &Path, args: &[&str]) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
AppError::other("Git is not installed or not on PATH; it's required to sync.")
|
||||
} else {
|
||||
AppError::Io(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if output.status.success() {
|
||||
// git writes most progress to stderr, so combine both streams.
|
||||
Ok(format!("{stdout}{stderr}").trim().to_string())
|
||||
} else {
|
||||
let msg = if !stderr.trim().is_empty() {
|
||||
stderr.trim()
|
||||
} else {
|
||||
stdout.trim()
|
||||
};
|
||||
Err(AppError::other(msg.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch refs from the remote (updates remote-tracking branches; no merge).
|
||||
pub fn fetch(dir: &Path) -> Result<String> {
|
||||
let remote = remote_or_err(dir)?;
|
||||
run_git(dir, &["fetch", &remote])
|
||||
}
|
||||
|
||||
/// Pull the current branch, fast-forward only — never leaves a half-merged or
|
||||
/// conflicted working tree from the GUI. Divergent history errors out so the
|
||||
/// user can reconcile deliberately.
|
||||
pub fn pull(dir: &Path) -> Result<String> {
|
||||
let remote = remote_or_err(dir)?;
|
||||
let branch = current_branch(dir)?;
|
||||
run_git(dir, &["pull", "--ff-only", &remote, &branch])
|
||||
}
|
||||
|
||||
/// Push the current branch, setting it as the upstream.
|
||||
pub fn push(dir: &Path) -> Result<String> {
|
||||
let remote = remote_or_err(dir)?;
|
||||
let branch = current_branch(dir)?;
|
||||
run_git(dir, &["push", "--set-upstream", &remote, &branch])
|
||||
}
|
||||
|
||||
@@ -63,6 +63,11 @@ pub fn run() {
|
||||
commands::git_current_branch,
|
||||
commands::git_create_branch,
|
||||
commands::git_checkout_branch,
|
||||
commands::git_remote_status,
|
||||
commands::git_set_remote,
|
||||
commands::git_fetch,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::write_text_file,
|
||||
commands::write_binary_file,
|
||||
commands::get_setting,
|
||||
|
||||
@@ -152,3 +152,51 @@ fn git_commit_and_branching() {
|
||||
assert_eq!(git_ops::current_branch(&dir).unwrap(), "main");
|
||||
assert_eq!(git_ops::history(&dir, 10).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
fn git_available() -> bool {
|
||||
std::process::Command::new("git")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_push_and_status_roundtrip() {
|
||||
// Network ops shell out to git; skip cleanly where the CLI is unavailable.
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = TempDir::new();
|
||||
let dir = tmp.project();
|
||||
project::create_project(&dir, "Proj", "").unwrap();
|
||||
|
||||
// No remote yet.
|
||||
let st = git_ops::remote_status(&dir).unwrap();
|
||||
assert!(!st.has_remote);
|
||||
|
||||
// Point origin at a fresh bare repo acting as the remote.
|
||||
let remote_path = tmp.0.join("remote.git");
|
||||
git2::Repository::init_bare(&remote_path).unwrap();
|
||||
git_ops::set_remote(&dir, remote_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let st = git_ops::remote_status(&dir).unwrap();
|
||||
assert!(st.has_remote);
|
||||
assert_eq!(st.remote.as_deref(), Some("origin"));
|
||||
assert_eq!(st.upstream, None, "no upstream before the first push");
|
||||
|
||||
// Push main; afterwards the branch tracks origin and is in sync.
|
||||
git_ops::push(&dir).unwrap();
|
||||
let st = git_ops::remote_status(&dir).unwrap();
|
||||
assert!(st.upstream.is_some(), "push --set-upstream sets tracking");
|
||||
assert_eq!((st.ahead, st.behind), (0, 0));
|
||||
|
||||
// A local commit puts us one ahead; a fetch is a clean no-op.
|
||||
diagram::create_diagram(&dir, "Second").unwrap();
|
||||
git_ops::commit_all(&dir, "second").unwrap();
|
||||
let st = git_ops::remote_status(&dir).unwrap();
|
||||
assert_eq!(st.ahead, 1);
|
||||
assert_eq!(st.behind, 0);
|
||||
git_ops::fetch(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
FileStatus,
|
||||
OpenedProject,
|
||||
ProjectRecord,
|
||||
RemoteStatus,
|
||||
} from './types';
|
||||
|
||||
// Thin, typed wrappers over the Rust command surface. Argument keys use
|
||||
@@ -60,6 +61,18 @@ export const api = {
|
||||
gitCheckoutBranch: (path: string, name: string) =>
|
||||
invoke<void>('git_checkout_branch', { path, name }),
|
||||
|
||||
// ---- Remote sync ----------------------------------------------------
|
||||
gitRemoteStatus: (path: string) => invoke<RemoteStatus>('git_remote_status', { path }),
|
||||
|
||||
gitSetRemote: (path: string, url: string) =>
|
||||
invoke<void>('git_set_remote', { path, url }),
|
||||
|
||||
gitFetch: (path: string) => invoke<string>('git_fetch', { path }),
|
||||
|
||||
gitPull: (path: string) => invoke<string>('git_pull', { path }),
|
||||
|
||||
gitPush: (path: string) => invoke<string>('git_push', { path }),
|
||||
|
||||
// ---- Export & settings ---------------------------------------------
|
||||
writeTextFile: (path: string, contents: string) =>
|
||||
invoke<void>('write_text_file', { path, contents }),
|
||||
|
||||
@@ -4,9 +4,25 @@
|
||||
|
||||
let message = $state('');
|
||||
let newBranch = $state<string | null>(null);
|
||||
let remoteUrl = $state<string | null>(null);
|
||||
|
||||
const hasChanges = $derived(store.changes.length > 0 || store.dirty);
|
||||
|
||||
async function saveRemote() {
|
||||
const url = (remoteUrl ?? '').trim();
|
||||
if (!url) return;
|
||||
await store.setRemote(url);
|
||||
remoteUrl = null;
|
||||
}
|
||||
|
||||
function shortUrl(url: string): string {
|
||||
// Strip protocol/host noise to a recognisable "owner/repo".
|
||||
return url
|
||||
.replace(/^git@[^:]+:/, '')
|
||||
.replace(/^[a-z]+:\/\/[^/]+\//, '')
|
||||
.replace(/\.git$/, '');
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const msg = message.trim();
|
||||
if (!msg) return;
|
||||
@@ -56,6 +72,44 @@
|
||||
<button class="ghost icon" title="New branch" onclick={() => (newBranch = '')}>+</button>
|
||||
</div>
|
||||
|
||||
<div class="remote-row">
|
||||
{#if store.remote?.has_remote}
|
||||
<div class="remote-info">
|
||||
<span class="remote-icon" title="Remote">☁</span>
|
||||
<span class="remote-name" title={store.remote.url ?? ''}>
|
||||
{store.remote.url ? shortUrl(store.remote.url) : store.remote.remote}
|
||||
</span>
|
||||
{#if store.remote.ahead}
|
||||
<span class="badge-count ahead" title="{store.remote.ahead} commit(s) to push">
|
||||
↑{store.remote.ahead}
|
||||
</span>
|
||||
{/if}
|
||||
{#if store.remote.behind}
|
||||
<span class="badge-count behind" title="{store.remote.behind} commit(s) to pull">
|
||||
↓{store.remote.behind}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
class="ghost icon mini"
|
||||
title="Edit remote URL"
|
||||
onclick={() => (remoteUrl = store.remote?.url ?? '')}>✎</button
|
||||
>
|
||||
</div>
|
||||
<div class="sync-btns">
|
||||
<button onclick={() => store.fetch()} disabled={store.syncing}>Fetch</button>
|
||||
<button onclick={() => store.pull()} disabled={store.syncing}>
|
||||
Pull{store.remote.behind ? ` ↓${store.remote.behind}` : ''}
|
||||
</button>
|
||||
<button class="primary" onclick={() => store.push()} disabled={store.syncing}>
|
||||
Push{store.remote.ahead ? ` ↑${store.remote.ahead}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
{#if store.syncing}<div class="syncing faint">Syncing…</div>{/if}
|
||||
{:else if store.remote}
|
||||
<button class="add-remote" onclick={() => (remoteUrl = '')}>☁ Add remote…</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="commit-box">
|
||||
<div class="changes">
|
||||
{#if hasChanges}
|
||||
@@ -121,6 +175,29 @@
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if remoteUrl !== null}
|
||||
<Modal title="Remote repository" onClose={() => (remoteUrl = null)}>
|
||||
<label class="fld">
|
||||
<span>Remote URL (origin)</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
bind:value={remoteUrl}
|
||||
placeholder="git@github.com:user/repo.git"
|
||||
onkeydown={(e) => e.key === 'Enter' && saveRemote()}
|
||||
/>
|
||||
</label>
|
||||
<p class="hint faint">
|
||||
Uses your existing Git credentials (SSH keys / keychain). Push and pull
|
||||
authenticate just like the <code>git</code> command line.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button onclick={() => (remoteUrl = null)}>Cancel</button>
|
||||
<button class="primary" onclick={saveRemote} disabled={!remoteUrl.trim()}>Save</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.git {
|
||||
width: 280px;
|
||||
@@ -141,6 +218,79 @@
|
||||
.branch-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
.remote-row {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.remote-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.remote-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
.remote-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.badge-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.badge-count.ahead {
|
||||
color: var(--green);
|
||||
background: rgba(63, 185, 80, 0.14);
|
||||
}
|
||||
.badge-count.behind {
|
||||
color: var(--amber);
|
||||
background: rgba(210, 153, 34, 0.14);
|
||||
}
|
||||
.sync-btns {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.sync-btns button {
|
||||
flex: 1;
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.syncing {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.add-remote {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.add-remote:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.hint code {
|
||||
font-family: var(--mono);
|
||||
color: var(--accent);
|
||||
}
|
||||
.commit-box {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
FileStatus,
|
||||
OpenedProject,
|
||||
ProjectRecord,
|
||||
RemoteStatus,
|
||||
} from './types';
|
||||
|
||||
export type Toast = { id: number; msg: string; kind: 'info' | 'error' | 'success' };
|
||||
@@ -31,6 +32,9 @@ class Store {
|
||||
branches = $state<BranchInfo[]>([]);
|
||||
history = $state<CommitInfo[]>([]);
|
||||
changes = $state<FileStatus[]>([]);
|
||||
remote = $state<RemoteStatus | null>(null);
|
||||
/** True while a fetch/pull/push is in flight. */
|
||||
syncing = $state(false);
|
||||
|
||||
// UI
|
||||
theme = $state('default');
|
||||
@@ -134,6 +138,7 @@ class Store {
|
||||
this.history = [];
|
||||
this.branches = [];
|
||||
this.changes = [];
|
||||
this.remote = null;
|
||||
this.focusNode = null;
|
||||
this.view = 'start';
|
||||
}
|
||||
@@ -249,11 +254,12 @@ class Store {
|
||||
async refreshGit() {
|
||||
if (!this.path) return;
|
||||
try {
|
||||
[this.branch, this.branches, this.history, this.changes] = await Promise.all([
|
||||
[this.branch, this.branches, this.history, this.changes, this.remote] = await Promise.all([
|
||||
api.gitCurrentBranch(this.path),
|
||||
api.gitBranches(this.path),
|
||||
api.gitHistory(this.path, 100),
|
||||
api.gitStatus(this.path),
|
||||
api.gitRemoteStatus(this.path),
|
||||
]);
|
||||
} catch (e) {
|
||||
this.fail(e);
|
||||
@@ -304,6 +310,66 @@ class Store {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Remote sync ----------------------------------------------------
|
||||
|
||||
async setRemote(url: string) {
|
||||
if (!this.path) return;
|
||||
try {
|
||||
await api.gitSetRemote(this.path, url);
|
||||
this.remote = await api.gitRemoteStatus(this.path);
|
||||
this.notify('Remote saved', 'success');
|
||||
} catch (e) {
|
||||
this.fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
fetch() {
|
||||
return this.sync('fetch');
|
||||
}
|
||||
pull() {
|
||||
return this.sync('pull');
|
||||
}
|
||||
push() {
|
||||
return this.sync('push');
|
||||
}
|
||||
|
||||
/** Shared fetch/pull/push flow: run, then refresh local + remote state. */
|
||||
private async sync(op: 'fetch' | 'pull' | 'push') {
|
||||
if (!this.path || this.syncing) return;
|
||||
// Don't lose buffered edits before a push or a (fast-forward) pull.
|
||||
if (op !== 'fetch' && this.dirty) await this.save(true);
|
||||
|
||||
this.syncing = true;
|
||||
try {
|
||||
if (op === 'fetch') await api.gitFetch(this.path);
|
||||
else if (op === 'pull') await api.gitPull(this.path);
|
||||
else await api.gitPush(this.path);
|
||||
|
||||
// A pull can change files on disk; reload the diagram list and the open
|
||||
// diagram's content so the editor reflects what was merged in.
|
||||
if (op === 'pull') {
|
||||
this.diagrams = await api.listDiagrams(this.path);
|
||||
if (this.activeId && this.diagrams.some((d) => d.id === this.activeId)) {
|
||||
const content = await api.readDiagram(this.path, this.activeId);
|
||||
this.content = content;
|
||||
this.savedContent = content;
|
||||
this.revision++;
|
||||
} else if (this.diagrams.length > 0) {
|
||||
await this.selectDiagram(this.diagrams[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshGit();
|
||||
const where = this.remote?.remote ?? 'remote';
|
||||
const verb = op === 'fetch' ? 'Fetched from' : op === 'pull' ? 'Pulled from' : 'Pushed to';
|
||||
this.notify(`${verb} ${where}`, 'success');
|
||||
} catch (e) {
|
||||
this.fail(e);
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async setTheme(theme: string) {
|
||||
this.theme = theme;
|
||||
try {
|
||||
|
||||
@@ -56,4 +56,13 @@ export interface FileStatus {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface RemoteStatus {
|
||||
has_remote: boolean;
|
||||
remote: string | null;
|
||||
url: string | null;
|
||||
upstream: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
export type MermaidTheme = 'default' | 'neutral' | 'dark' | 'forest' | 'base';
|
||||
|
||||
Reference in New Issue
Block a user