Files
Mermix/src-tauri/src/commands.rs
Aleksey Shakhmatov c35960761b 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.
2026-05-22 21:32:48 +03:00

241 lines
6.9 KiB
Rust

use std::path::{Path, PathBuf};
use serde::Serialize;
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, RemoteStatus};
use crate::project::{self, ProjectConfig};
use crate::state::AppState;
/// Everything the frontend needs after opening or creating a project.
#[derive(Serialize)]
pub struct OpenedProject {
pub record: ProjectRecord,
pub config: ProjectConfig,
pub diagrams: Vec<DiagramInfo>,
pub branch: String,
}
fn now() -> String {
chrono::Utc::now().to_rfc3339()
}
fn record_from_config(config: &ProjectConfig, path: &Path) -> ProjectRecord {
ProjectRecord {
id: config.project.id.clone(),
name: config.project.name.clone(),
path: path.to_string_lossy().to_string(),
created_at: config.project.created_at.clone(),
last_opened_at: Some(now()),
}
}
// ---------------------------------------------------------------------------
// Project registry & lifecycle
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<ProjectRecord>> {
db::list_projects(&state.db).await
}
#[tauri::command]
pub async fn create_project(
state: State<'_, AppState>,
parent_dir: String,
name: String,
description: String,
) -> Result<OpenedProject> {
let dir = project::project_path_for(Path::new(&parent_dir), &name);
let config = project::create_project(&dir, &name, &description)?;
let record = record_from_config(&config, &dir);
db::upsert_project(&state.db, &record).await?;
let diagrams = diagram::list_diagrams(&dir)?;
let branch = git_ops::current_branch(&dir).unwrap_or_else(|_| "main".into());
Ok(OpenedProject {
record,
config,
diagrams,
branch,
})
}
#[tauri::command]
pub async fn open_project(state: State<'_, AppState>, path: String) -> Result<OpenedProject> {
let dir = PathBuf::from(&path);
if !project::is_project(&dir) {
return Err(AppError::NotAProject(path));
}
let config = project::read_config(&dir)?;
let record = record_from_config(&config, &dir);
db::upsert_project(&state.db, &record).await?;
db::touch_project(&state.db, &record.path, &now()).await?;
let diagrams = diagram::list_diagrams(&dir)?;
let branch = git_ops::current_branch(&dir).unwrap_or_else(|_| "main".into());
Ok(OpenedProject {
record,
config,
diagrams,
branch,
})
}
/// Remove a project from the registry. Files on disk are left untouched.
#[tauri::command]
pub async fn forget_project(state: State<'_, AppState>, id: String) -> Result<()> {
db::remove_project(&state.db, &id).await
}
// ---------------------------------------------------------------------------
// Diagrams
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn list_diagrams(path: String) -> Result<Vec<DiagramInfo>> {
diagram::list_diagrams(Path::new(&path))
}
#[tauri::command]
pub fn read_diagram(path: String, rel: String) -> Result<String> {
diagram::read_diagram(Path::new(&path), &rel)
}
#[tauri::command]
pub fn save_diagram(path: String, rel: String, content: String) -> Result<()> {
diagram::save_diagram(Path::new(&path), &rel, &content)
}
#[tauri::command]
pub fn create_diagram(path: String, name: String) -> Result<DiagramInfo> {
diagram::create_diagram(Path::new(&path), &name)
}
#[tauri::command]
pub fn rename_diagram(path: String, rel: String, new_name: String) -> Result<DiagramInfo> {
diagram::rename_diagram(Path::new(&path), &rel, &new_name)
}
#[tauri::command]
pub fn delete_diagram(path: String, rel: String) -> Result<()> {
diagram::delete_diagram(Path::new(&path), &rel)
}
// ---------------------------------------------------------------------------
// Git
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn git_status(path: String) -> Result<Vec<FileStatus>> {
git_ops::status(Path::new(&path))
}
#[tauri::command]
pub fn git_commit(path: String, message: String) -> Result<String> {
let msg = message.trim();
if msg.is_empty() {
return Err(AppError::other("commit message cannot be empty"));
}
git_ops::commit_all(Path::new(&path), msg)
}
#[tauri::command]
pub fn git_history(path: String, limit: Option<usize>) -> Result<Vec<CommitInfo>> {
git_ops::history(Path::new(&path), limit.unwrap_or(100))
}
#[tauri::command]
pub fn git_branches(path: String) -> Result<Vec<BranchInfo>> {
git_ops::branches(Path::new(&path))
}
#[tauri::command]
pub fn git_current_branch(path: String) -> Result<String> {
git_ops::current_branch(Path::new(&path))
}
#[tauri::command]
pub fn git_create_branch(path: String, name: String) -> Result<()> {
let name = name.trim();
if name.is_empty() {
return Err(AppError::other("branch name cannot be empty"));
}
git_ops::create_branch(Path::new(&path), name)
}
#[tauri::command]
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
// ---------------------------------------------------------------------------
/// Write UTF-8 text to an arbitrary path chosen by the user (e.g. SVG export).
#[tauri::command]
pub fn write_text_file(path: String, contents: String) -> Result<()> {
std::fs::write(path, contents)?;
Ok(())
}
/// Write raw bytes to a path chosen by the user (e.g. PNG export).
#[tauri::command]
pub fn write_binary_file(path: String, contents: Vec<u8>) -> Result<()> {
std::fs::write(path, contents)?;
Ok(())
}
#[tauri::command]
pub async fn get_setting(state: State<'_, AppState>, key: String) -> Result<Option<String>> {
db::get_setting(&state.db, &key).await
}
#[tauri::command]
pub async fn set_setting(
state: State<'_, AppState>,
key: String,
value: String,
) -> Result<()> {
db::set_setting(&state.db, &key, &value).await
}