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
|
- **Git version control built in** — view the working-tree status, write commit
|
||||||
messages, browse history, create branches and switch between them. Each branch
|
messages, browse history, create branches and switch between them. Each branch
|
||||||
keeps its own set of diagrams, just like normal Git.
|
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
|
- **Optimize panel (Diagram Doctor)** — analyse a tangled flowchart and declutter
|
||||||
it in one click: a readability score and metrics (hubs, density, cross-group
|
it in one click: a readability score and metrics (hubs, density, cross-group
|
||||||
edges), plus source rewrites for the **ELK layered layout**, extra
|
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.
|
for you. Switching branches reloads the diagram list from that branch.
|
||||||
- **History** shows the most recent commits with short SHA, message, author and
|
- **History** shows the most recent commits with short SHA, message, author and
|
||||||
relative time.
|
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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use tauri::State;
|
|||||||
use crate::db::{self, ProjectRecord};
|
use crate::db::{self, ProjectRecord};
|
||||||
use crate::diagram::{self, DiagramInfo};
|
use crate::diagram::{self, DiagramInfo};
|
||||||
use crate::error::{AppError, Result};
|
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::project::{self, ProjectConfig};
|
||||||
use crate::state::AppState;
|
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)
|
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
|
// Export helpers & settings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
use git2::{
|
use git2::{
|
||||||
BranchType, Commit, IndexAddOption, Repository, RepositoryInitOptions, Signature, StatusOptions,
|
BranchType, Commit, IndexAddOption, Repository, RepositoryInitOptions, Signature, StatusOptions,
|
||||||
@@ -30,6 +31,21 @@ pub struct FileStatus {
|
|||||||
pub status: String,
|
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.
|
/// Initialize a fresh repository with `main` as the default branch.
|
||||||
pub fn init_repo(dir: &Path) -> Result<Repository> {
|
pub fn init_repo(dir: &Path) -> Result<Repository> {
|
||||||
let mut opts = RepositoryInitOptions::new();
|
let mut opts = RepositoryInitOptions::new();
|
||||||
@@ -188,3 +204,138 @@ pub fn status(dir: &Path) -> Result<Vec<FileStatus>> {
|
|||||||
}
|
}
|
||||||
Ok(out)
|
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_current_branch,
|
||||||
commands::git_create_branch,
|
commands::git_create_branch,
|
||||||
commands::git_checkout_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_text_file,
|
||||||
commands::write_binary_file,
|
commands::write_binary_file,
|
||||||
commands::get_setting,
|
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::current_branch(&dir).unwrap(), "main");
|
||||||
assert_eq!(git_ops::history(&dir, 10).unwrap().len(), 2);
|
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,
|
FileStatus,
|
||||||
OpenedProject,
|
OpenedProject,
|
||||||
ProjectRecord,
|
ProjectRecord,
|
||||||
|
RemoteStatus,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Thin, typed wrappers over the Rust command surface. Argument keys use
|
// Thin, typed wrappers over the Rust command surface. Argument keys use
|
||||||
@@ -60,6 +61,18 @@ export const api = {
|
|||||||
gitCheckoutBranch: (path: string, name: string) =>
|
gitCheckoutBranch: (path: string, name: string) =>
|
||||||
invoke<void>('git_checkout_branch', { path, name }),
|
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 ---------------------------------------------
|
// ---- Export & settings ---------------------------------------------
|
||||||
writeTextFile: (path: string, contents: string) =>
|
writeTextFile: (path: string, contents: string) =>
|
||||||
invoke<void>('write_text_file', { path, contents }),
|
invoke<void>('write_text_file', { path, contents }),
|
||||||
|
|||||||
@@ -4,9 +4,25 @@
|
|||||||
|
|
||||||
let message = $state('');
|
let message = $state('');
|
||||||
let newBranch = $state<string | null>(null);
|
let newBranch = $state<string | null>(null);
|
||||||
|
let remoteUrl = $state<string | null>(null);
|
||||||
|
|
||||||
const hasChanges = $derived(store.changes.length > 0 || store.dirty);
|
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() {
|
async function commit() {
|
||||||
const msg = message.trim();
|
const msg = message.trim();
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
@@ -56,6 +72,44 @@
|
|||||||
<button class="ghost icon" title="New branch" onclick={() => (newBranch = '')}>+</button>
|
<button class="ghost icon" title="New branch" onclick={() => (newBranch = '')}>+</button>
|
||||||
</div>
|
</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="commit-box">
|
||||||
<div class="changes">
|
<div class="changes">
|
||||||
{#if hasChanges}
|
{#if hasChanges}
|
||||||
@@ -121,6 +175,29 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/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>
|
<style>
|
||||||
.git {
|
.git {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
@@ -141,6 +218,79 @@
|
|||||||
.branch-icon {
|
.branch-icon {
|
||||||
color: var(--accent);
|
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 {
|
.commit-box {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
FileStatus,
|
FileStatus,
|
||||||
OpenedProject,
|
OpenedProject,
|
||||||
ProjectRecord,
|
ProjectRecord,
|
||||||
|
RemoteStatus,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export type Toast = { id: number; msg: string; kind: 'info' | 'error' | 'success' };
|
export type Toast = { id: number; msg: string; kind: 'info' | 'error' | 'success' };
|
||||||
@@ -31,6 +32,9 @@ class Store {
|
|||||||
branches = $state<BranchInfo[]>([]);
|
branches = $state<BranchInfo[]>([]);
|
||||||
history = $state<CommitInfo[]>([]);
|
history = $state<CommitInfo[]>([]);
|
||||||
changes = $state<FileStatus[]>([]);
|
changes = $state<FileStatus[]>([]);
|
||||||
|
remote = $state<RemoteStatus | null>(null);
|
||||||
|
/** True while a fetch/pull/push is in flight. */
|
||||||
|
syncing = $state(false);
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
theme = $state('default');
|
theme = $state('default');
|
||||||
@@ -134,6 +138,7 @@ class Store {
|
|||||||
this.history = [];
|
this.history = [];
|
||||||
this.branches = [];
|
this.branches = [];
|
||||||
this.changes = [];
|
this.changes = [];
|
||||||
|
this.remote = null;
|
||||||
this.focusNode = null;
|
this.focusNode = null;
|
||||||
this.view = 'start';
|
this.view = 'start';
|
||||||
}
|
}
|
||||||
@@ -249,11 +254,12 @@ class Store {
|
|||||||
async refreshGit() {
|
async refreshGit() {
|
||||||
if (!this.path) return;
|
if (!this.path) return;
|
||||||
try {
|
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.gitCurrentBranch(this.path),
|
||||||
api.gitBranches(this.path),
|
api.gitBranches(this.path),
|
||||||
api.gitHistory(this.path, 100),
|
api.gitHistory(this.path, 100),
|
||||||
api.gitStatus(this.path),
|
api.gitStatus(this.path),
|
||||||
|
api.gitRemoteStatus(this.path),
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.fail(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) {
|
async setTheme(theme: string) {
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -56,4 +56,13 @@ export interface FileStatus {
|
|||||||
status: string;
|
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';
|
export type MermaidTheme = 'default' | 'neutral' | 'dark' | 'forest' | 'base';
|
||||||
|
|||||||
Reference in New Issue
Block a user