mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-24 17:58:27 +02:00
Git branch flow improvements (#370)
This commit is contained in:
@@ -6,9 +6,10 @@ use crate::error::Result;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
use yaak_git::{
|
use yaak_git::{
|
||||||
GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, git_add, git_add_credential,
|
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
||||||
git_add_remote, git_checkout_branch, git_commit, git_create_branch, git_delete_branch,
|
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
|
||||||
git_fetch_all, git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes,
|
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
|
||||||
|
git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, git_rename_branch,
|
||||||
git_rm_remote, git_status, git_unstage,
|
git_rm_remote, git_status, git_unstage,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,22 +17,36 @@ use yaak_git::{
|
|||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> {
|
pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> {
|
||||||
Ok(git_checkout_branch(dir, branch, force)?)
|
Ok(git_checkout_branch(dir, branch, force).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_branch(dir: &Path, branch: &str) -> Result<()> {
|
pub async fn cmd_git_branch(dir: &Path, branch: &str, base: Option<&str>) -> Result<()> {
|
||||||
Ok(git_create_branch(dir, branch)?)
|
Ok(git_create_branch(dir, branch, base).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_delete_branch(dir: &Path, branch: &str) -> Result<()> {
|
pub async fn cmd_git_delete_branch(
|
||||||
Ok(git_delete_branch(dir, branch)?)
|
dir: &Path,
|
||||||
|
branch: &str,
|
||||||
|
force: Option<bool>,
|
||||||
|
) -> Result<BranchDeleteResult> {
|
||||||
|
Ok(git_delete_branch(dir, branch, force.unwrap_or(false)).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_merge_branch(dir: &Path, branch: &str, force: bool) -> Result<()> {
|
pub async fn cmd_git_delete_remote_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||||
Ok(git_merge_branch(dir, branch, force)?)
|
Ok(git_delete_remote_branch(dir, branch).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_merge_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||||
|
Ok(git_merge_branch(dir, branch).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
|
||||||
|
Ok(git_rename_branch(dir, old_name, new_name).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -49,6 +64,11 @@ pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
|||||||
Ok(git_init(dir)?)
|
Ok(git_init(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn cmd_git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
|
||||||
|
Ok(git_clone(url, dir).await?)
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
|
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||||
Ok(git_commit(dir, message).await?)
|
Ok(git_commit(dir, message).await?)
|
||||||
@@ -87,12 +107,11 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()>
|
|||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_add_credential(
|
pub async fn cmd_git_add_credential(
|
||||||
dir: &Path,
|
|
||||||
remote_url: &str,
|
remote_url: &str,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
Ok(git_add_credential(dir, remote_url, username, password).await?)
|
Ok(git_add_credential(remote_url, username, password).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ struct AppMetaData {
|
|||||||
app_data_dir: String,
|
app_data_dir: String,
|
||||||
app_log_dir: String,
|
app_log_dir: String,
|
||||||
vendored_plugin_dir: String,
|
vendored_plugin_dir: String,
|
||||||
|
default_project_dir: String,
|
||||||
feature_updater: bool,
|
feature_updater: bool,
|
||||||
feature_license: bool,
|
feature_license: bool,
|
||||||
}
|
}
|
||||||
@@ -111,6 +112,7 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
|
|||||||
let app_log_dir = app_handle.path().app_log_dir()?;
|
let app_log_dir = app_handle.path().app_log_dir()?;
|
||||||
let vendored_plugin_dir =
|
let vendored_plugin_dir =
|
||||||
app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?;
|
app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?;
|
||||||
|
let default_project_dir = app_handle.path().home_dir()?.join("YaakProjects");
|
||||||
Ok(AppMetaData {
|
Ok(AppMetaData {
|
||||||
is_dev: is_dev(),
|
is_dev: is_dev(),
|
||||||
version: app_handle.package_info().version.to_string(),
|
version: app_handle.package_info().version.to_string(),
|
||||||
@@ -118,6 +120,7 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
|
|||||||
app_data_dir: app_data_dir.to_string_lossy().to_string(),
|
app_data_dir: app_data_dir.to_string_lossy().to_string(),
|
||||||
app_log_dir: app_log_dir.to_string_lossy().to_string(),
|
app_log_dir: app_log_dir.to_string_lossy().to_string(),
|
||||||
vendored_plugin_dir: vendored_plugin_dir.to_string_lossy().to_string(),
|
vendored_plugin_dir: vendored_plugin_dir.to_string_lossy().to_string(),
|
||||||
|
default_project_dir: default_project_dir.to_string_lossy().to_string(),
|
||||||
feature_license: cfg!(feature = "license"),
|
feature_license: cfg!(feature = "license"),
|
||||||
feature_updater: cfg!(feature = "updater"),
|
feature_updater: cfg!(feature = "updater"),
|
||||||
})
|
})
|
||||||
@@ -1747,10 +1750,13 @@ pub fn run() {
|
|||||||
git_ext::cmd_git_checkout,
|
git_ext::cmd_git_checkout,
|
||||||
git_ext::cmd_git_branch,
|
git_ext::cmd_git_branch,
|
||||||
git_ext::cmd_git_delete_branch,
|
git_ext::cmd_git_delete_branch,
|
||||||
|
git_ext::cmd_git_delete_remote_branch,
|
||||||
git_ext::cmd_git_merge_branch,
|
git_ext::cmd_git_merge_branch,
|
||||||
|
git_ext::cmd_git_rename_branch,
|
||||||
git_ext::cmd_git_status,
|
git_ext::cmd_git_status,
|
||||||
git_ext::cmd_git_log,
|
git_ext::cmd_git_log,
|
||||||
git_ext::cmd_git_initialize,
|
git_ext::cmd_git_initialize,
|
||||||
|
git_ext::cmd_git_clone,
|
||||||
git_ext::cmd_git_commit,
|
git_ext::cmd_git_commit,
|
||||||
git_ext::cmd_git_fetch_all,
|
git_ext::cmd_git_fetch_all,
|
||||||
git_ext::cmd_git_push,
|
git_ext::cmd_git_push,
|
||||||
|
|||||||
4
crates/yaak-git/bindings/gen_git.ts
generated
4
crates/yaak-git/bindings/gen_git.ts
generated
@@ -1,6 +1,10 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { SyncModel } from "./gen_models";
|
import type { SyncModel } from "./gen_models";
|
||||||
|
|
||||||
|
export type BranchDeleteResult = { "type": "success", message: string, } | { "type": "not_fully_merged" };
|
||||||
|
|
||||||
|
export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||||
|
|
||||||
export type GitAuthor = { name: string | null, email: string | null, };
|
export type GitAuthor = { name: string | null, email: string | null, };
|
||||||
|
|
||||||
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation';
|
import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation';
|
||||||
import { queryClient } from '@yaakapp/app/lib/queryClient';
|
import { queryClient } from '@yaakapp/app/lib/queryClient';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
|
import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
|
||||||
|
|
||||||
export * from './bindings/gen_git';
|
export * from './bindings/gen_git';
|
||||||
|
|
||||||
@@ -59,7 +59,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
if (creds == null) throw new Error('Canceled');
|
if (creds == null) throw new Error('Canceled');
|
||||||
|
|
||||||
await invoke('cmd_git_add_credential', {
|
await invoke('cmd_git_add_credential', {
|
||||||
dir,
|
|
||||||
remoteUrl: result.url,
|
remoteUrl: result.url,
|
||||||
username: creds.username,
|
username: creds.username,
|
||||||
password: creds.password,
|
password: creds.password,
|
||||||
@@ -90,21 +89,31 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }),
|
mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }),
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}),
|
}),
|
||||||
branch: createFastMutation<void, string, { branch: string }>({
|
createBranch: createFastMutation<void, string, { branch: string; base?: string }>({
|
||||||
mutationKey: ['git', 'branch', dir],
|
mutationKey: ['git', 'branch', dir],
|
||||||
mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }),
|
mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }),
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}),
|
}),
|
||||||
mergeBranch: createFastMutation<void, string, { branch: string; force: boolean }>({
|
mergeBranch: createFastMutation<void, string, { branch: string }>({
|
||||||
mutationKey: ['git', 'merge', dir],
|
mutationKey: ['git', 'merge', dir],
|
||||||
mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }),
|
mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }),
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}),
|
}),
|
||||||
deleteBranch: createFastMutation<void, string, { branch: string }>({
|
deleteBranch: createFastMutation<BranchDeleteResult, string, { branch: string, force?: boolean }>({
|
||||||
mutationKey: ['git', 'delete-branch', dir],
|
mutationKey: ['git', 'delete-branch', dir],
|
||||||
mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }),
|
mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }),
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}),
|
}),
|
||||||
|
deleteRemoteBranch: createFastMutation<void, string, { branch: string }>({
|
||||||
|
mutationKey: ['git', 'delete-remote-branch', dir],
|
||||||
|
mutationFn: (args) => invoke('cmd_git_delete_remote_branch', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
renameBranch: createFastMutation<void, string, { oldName: string, newName: string }>({
|
||||||
|
mutationKey: ['git', 'rename-branch', dir],
|
||||||
|
mutationFn: (args) => invoke('cmd_git_rename_branch', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
checkout: createFastMutation<string, string, { branch: string; force: boolean }>({
|
checkout: createFastMutation<string, string, { branch: string; force: boolean }>({
|
||||||
mutationKey: ['git', 'checkout', dir],
|
mutationKey: ['git', 'checkout', dir],
|
||||||
mutationFn: (args) => invoke('cmd_git_checkout', { dir, ...args }),
|
mutationFn: (args) => invoke('cmd_git_checkout', { dir, ...args }),
|
||||||
@@ -144,7 +153,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
if (creds == null) throw new Error('Canceled');
|
if (creds == null) throw new Error('Canceled');
|
||||||
|
|
||||||
await invoke('cmd_git_add_credential', {
|
await invoke('cmd_git_add_credential', {
|
||||||
dir,
|
|
||||||
remoteUrl: result.url,
|
remoteUrl: result.url,
|
||||||
username: creds.username,
|
username: creds.username,
|
||||||
password: creds.password,
|
password: creds.password,
|
||||||
@@ -166,3 +174,28 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
async function getRemotes(dir: string) {
|
async function getRemotes(dir: string) {
|
||||||
return invoke<GitRemote[]>('cmd_git_remotes', { dir });
|
return invoke<GitRemote[]>('cmd_git_remotes', { dir });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a git repository, prompting for credentials if needed.
|
||||||
|
*/
|
||||||
|
export async function gitClone(
|
||||||
|
url: string,
|
||||||
|
dir: string,
|
||||||
|
promptCredentials: (args: { url: string; error: string | null }) => Promise<GitCredentials | null>,
|
||||||
|
): Promise<CloneResult> {
|
||||||
|
const result = await invoke<CloneResult>('cmd_git_clone', { url, dir });
|
||||||
|
if (result.type !== 'needs_credentials') return result;
|
||||||
|
|
||||||
|
// Prompt for credentials
|
||||||
|
const creds = await promptCredentials({ url: result.url, error: result.error });
|
||||||
|
if (creds == null) return {type: 'cancelled'};
|
||||||
|
|
||||||
|
// Store credentials and retry
|
||||||
|
await invoke('cmd_git_add_credential', {
|
||||||
|
remoteUrl: result.url,
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
return invoke<CloneResult>('cmd_git_clone', { url, dir });
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ use std::process::Stdio;
|
|||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use yaak_common::command::new_xplatform_command;
|
use yaak_common::command::new_xplatform_command;
|
||||||
|
|
||||||
|
/// Create a git command that runs in the specified directory
|
||||||
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
||||||
|
let mut cmd = new_binary_command_global().await?;
|
||||||
|
cmd.arg("-C").arg(dir);
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a git command without a specific directory (for global operations)
|
||||||
|
pub(crate) async fn new_binary_command_global() -> Result<Command> {
|
||||||
// 1. Probe that `git` exists and is runnable
|
// 1. Probe that `git` exists and is runnable
|
||||||
let mut probe = new_xplatform_command("git");
|
let mut probe = new_xplatform_command("git");
|
||||||
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
||||||
@@ -17,8 +25,6 @@ pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build the reusable git command
|
// 2. Build the reusable git command
|
||||||
let mut cmd = new_xplatform_command("git");
|
let cmd = new_xplatform_command("git");
|
||||||
cmd.arg("-C").arg(dir);
|
|
||||||
|
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,153 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
use crate::binary::new_binary_command;
|
||||||
use crate::error::Error::GenericError;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::merge::do_merge;
|
|
||||||
use crate::repository::open_repo;
|
|
||||||
use crate::util::{bytes_to_string, get_branch_by_name, get_current_branch};
|
|
||||||
use git2::BranchType;
|
|
||||||
use git2::build::CheckoutBuilder;
|
|
||||||
use log::info;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
if branch_name.starts_with("origin/") {
|
#[serde(rename_all = "snake_case", tag = "type")]
|
||||||
return git_checkout_remote_branch(dir, branch_name, force);
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
}
|
pub enum BranchDeleteResult {
|
||||||
|
Success { message: String },
|
||||||
|
NotFullyMerged,
|
||||||
|
}
|
||||||
|
|
||||||
let repo = open_repo(dir)?;
|
pub async fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
|
||||||
let branch = get_branch_by_name(&repo, branch_name)?;
|
let branch_name = branch_name.trim_start_matches("origin/");
|
||||||
let branch_ref = branch.into_reference();
|
|
||||||
let branch_tree = branch_ref.peel_to_tree()?;
|
|
||||||
|
|
||||||
let mut options = CheckoutBuilder::default();
|
let mut args = vec!["checkout"];
|
||||||
if force {
|
if force {
|
||||||
options.force();
|
args.push("--force");
|
||||||
}
|
}
|
||||||
|
args.push(branch_name);
|
||||||
|
|
||||||
repo.checkout_tree(branch_tree.as_object(), Some(&mut options))?;
|
let out = new_binary_command(dir)
|
||||||
repo.set_head(branch_ref.name().unwrap())?;
|
.await?
|
||||||
|
.args(&args)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| GenericError(format!("failed to run git checkout: {e}")))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let combined = format!("{}{}", stdout, stderr);
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(GenericError(format!("Failed to checkout: {}", combined.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(branch_name.to_string())
|
Ok(branch_name.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn git_checkout_remote_branch(
|
pub async fn git_create_branch(dir: &Path, name: &str, base: Option<&str>) -> Result<()> {
|
||||||
dir: &Path,
|
let mut cmd = new_binary_command(dir).await?;
|
||||||
branch_name: &str,
|
cmd.arg("branch").arg(name);
|
||||||
force: bool,
|
if let Some(base_branch) = base {
|
||||||
) -> Result<String> {
|
cmd.arg(base_branch);
|
||||||
let branch_name = branch_name.trim_start_matches("origin/");
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
|
|
||||||
let refname = format!("refs/remotes/origin/{}", branch_name);
|
|
||||||
let remote_ref = repo.find_reference(&refname)?;
|
|
||||||
let commit = remote_ref.peel_to_commit()?;
|
|
||||||
|
|
||||||
let mut new_branch = repo.branch(branch_name, &commit, false)?;
|
|
||||||
let upstream_name = format!("origin/{}", branch_name);
|
|
||||||
new_branch.set_upstream(Some(&upstream_name))?;
|
|
||||||
|
|
||||||
git_checkout_branch(dir, branch_name, force)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_create_branch(dir: &Path, name: &str) -> Result<()> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
let head = match repo.head() {
|
|
||||||
Ok(h) => h,
|
|
||||||
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
|
|
||||||
let msg = "Cannot create branch when there are no commits";
|
|
||||||
return Err(GenericError(msg.into()));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
let head = head.peel_to_commit()?;
|
|
||||||
|
|
||||||
repo.branch(name, &head, false)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_delete_branch(dir: &Path, name: &str) -> Result<()> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
let mut branch = get_branch_by_name(&repo, name)?;
|
|
||||||
|
|
||||||
if branch.is_head() {
|
|
||||||
info!("Deleting head branch");
|
|
||||||
let branches = repo.branches(Some(BranchType::Local))?;
|
|
||||||
let other_branch = branches.into_iter().filter_map(|b| b.ok()).find(|b| !b.0.is_head());
|
|
||||||
let other_branch = match other_branch {
|
|
||||||
None => return Err(GenericError("Cannot delete only branch".into())),
|
|
||||||
Some(b) => bytes_to_string(b.0.name_bytes()?)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
git_checkout_branch(dir, &other_branch, true)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
branch.delete()?;
|
let out =
|
||||||
|
cmd.output().await.map_err(|e| GenericError(format!("failed to run git branch: {e}")))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let combined = format!("{}{}", stdout, stderr);
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(GenericError(format!("Failed to create branch: {}", combined.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn git_merge_branch(dir: &Path, name: &str, _force: bool) -> Result<()> {
|
pub async fn git_delete_branch(dir: &Path, name: &str, force: bool) -> Result<BranchDeleteResult> {
|
||||||
let repo = open_repo(dir)?;
|
let mut cmd = new_binary_command(dir).await?;
|
||||||
let local_branch = get_current_branch(&repo)?.unwrap();
|
|
||||||
|
|
||||||
let commit_to_merge = get_branch_by_name(&repo, name)?.into_reference();
|
let out =
|
||||||
let commit_to_merge = repo.reference_to_annotated_commit(&commit_to_merge)?;
|
if force { cmd.args(["branch", "-D", name]) } else { cmd.args(["branch", "-d", name]) }
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| GenericError(format!("failed to run git branch -d: {e}")))?;
|
||||||
|
|
||||||
do_merge(&repo, &local_branch, &commit_to_merge)?;
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let combined = format!("{}{}", stdout, stderr);
|
||||||
|
|
||||||
|
if !out.status.success() && stderr.to_lowercase().contains("not fully merged") {
|
||||||
|
return Ok(BranchDeleteResult::NotFullyMerged);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(GenericError(format!("Failed to delete branch: {}", combined.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(BranchDeleteResult::Success { message: combined })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_merge_branch(dir: &Path, name: &str) -> Result<()> {
|
||||||
|
let out = new_binary_command(dir)
|
||||||
|
.await?
|
||||||
|
.args(["merge", name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| GenericError(format!("failed to run git merge: {e}")))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let combined = format!("{}{}", stdout, stderr);
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
// Check for merge conflicts
|
||||||
|
if combined.to_lowercase().contains("conflict") {
|
||||||
|
return Err(GenericError(
|
||||||
|
"Merge conflicts detected. Please resolve them manually.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(GenericError(format!("Failed to merge: {}", combined.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_delete_remote_branch(dir: &Path, name: &str) -> Result<()> {
|
||||||
|
// Remote branch names come in as "origin/branch-name", extract the branch name
|
||||||
|
let branch_name = name.trim_start_matches("origin/");
|
||||||
|
|
||||||
|
let out = new_binary_command(dir)
|
||||||
|
.await?
|
||||||
|
.args(["push", "origin", "--delete", branch_name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| GenericError(format!("failed to run git push --delete: {e}")))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let combined = format!("{}{}", stdout, stderr);
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(GenericError(format!("Failed to delete remote branch: {}", combined.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
|
||||||
|
let out = new_binary_command(dir)
|
||||||
|
.await?
|
||||||
|
.args(["branch", "-m", old_name, new_name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| GenericError(format!("failed to run git branch -m: {e}")))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let combined = format!("{}{}", stdout, stderr);
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(GenericError(format!("Failed to rename branch: {}", combined.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
53
crates/yaak-git/src/clone.rs
Normal file
53
crates/yaak-git/src/clone.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use crate::binary::new_binary_command;
|
||||||
|
use crate::error::Error::GenericError;
|
||||||
|
use crate::error::Result;
|
||||||
|
use log::info;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "type")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub enum CloneResult {
|
||||||
|
Success,
|
||||||
|
Cancelled,
|
||||||
|
NeedsCredentials { url: String, error: Option<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
|
||||||
|
let parent = dir.parent().ok_or_else(|| GenericError("Invalid clone directory".to_string()))?;
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| GenericError(format!("Failed to create directory: {e}")))?;
|
||||||
|
let mut cmd = new_binary_command(parent).await?;
|
||||||
|
cmd.args(["clone", url]).arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
||||||
|
|
||||||
|
let out =
|
||||||
|
cmd.output().await.map_err(|e| GenericError(format!("failed to run git clone: {e}")))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let combined = format!("{}{}", stdout, stderr);
|
||||||
|
let combined_lower = combined.to_lowercase();
|
||||||
|
|
||||||
|
info!("Cloned status={}: {combined}", out.status);
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
// Check for credentials error
|
||||||
|
if combined_lower.contains("could not read") {
|
||||||
|
return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: None });
|
||||||
|
}
|
||||||
|
if combined_lower.contains("unable to access")
|
||||||
|
|| combined_lower.contains("authentication failed")
|
||||||
|
{
|
||||||
|
return Ok(CloneResult::NeedsCredentials {
|
||||||
|
url: url.to_string(),
|
||||||
|
error: Some(combined.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(GenericError(format!("Failed to clone: {}", combined.trim())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CloneResult::Success)
|
||||||
|
}
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
use crate::binary::new_binary_command;
|
use crate::binary::new_binary_command_global;
|
||||||
use crate::error::Error::GenericError;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::path::Path;
|
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
pub async fn git_add_credential(
|
pub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> {
|
||||||
dir: &Path,
|
|
||||||
remote_url: &str,
|
|
||||||
username: &str,
|
|
||||||
password: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let url = Url::parse(remote_url)
|
let url = Url::parse(remote_url)
|
||||||
.map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?;
|
.map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?;
|
||||||
let protocol = url.scheme();
|
let protocol = url.scheme();
|
||||||
let host = url.host_str().unwrap();
|
let host = url.host_str().unwrap();
|
||||||
let path = Some(url.path());
|
let path = Some(url.path());
|
||||||
|
|
||||||
let mut child = new_binary_command(dir)
|
let mut child = new_binary_command_global()
|
||||||
.await?
|
.await?
|
||||||
.args(["credential", "approve"])
|
.args(["credential", "approve"])
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
mod add;
|
mod add;
|
||||||
mod binary;
|
mod binary;
|
||||||
mod branch;
|
mod branch;
|
||||||
|
mod clone;
|
||||||
mod commit;
|
mod commit;
|
||||||
mod credential;
|
mod credential;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
mod fetch;
|
mod fetch;
|
||||||
mod init;
|
mod init;
|
||||||
mod log;
|
mod log;
|
||||||
mod merge;
|
|
||||||
mod pull;
|
mod pull;
|
||||||
mod push;
|
mod push;
|
||||||
mod remotes;
|
mod remotes;
|
||||||
@@ -18,7 +19,11 @@ mod util;
|
|||||||
|
|
||||||
// Re-export all git functions for external use
|
// Re-export all git functions for external use
|
||||||
pub use add::git_add;
|
pub use add::git_add;
|
||||||
pub use branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch};
|
pub use branch::{
|
||||||
|
BranchDeleteResult, git_checkout_branch, git_create_branch, git_delete_branch,
|
||||||
|
git_delete_remote_branch, git_merge_branch, git_rename_branch,
|
||||||
|
};
|
||||||
|
pub use clone::{CloneResult, git_clone};
|
||||||
pub use commit::git_commit;
|
pub use commit::git_commit;
|
||||||
pub use credential::git_add_credential;
|
pub use credential::git_add_credential;
|
||||||
pub use fetch::git_fetch_all;
|
pub use fetch::git_fetch_all;
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
use crate::error::Error::MergeConflicts;
|
|
||||||
use crate::util::bytes_to_string;
|
|
||||||
use git2::{AnnotatedCommit, Branch, IndexEntry, Reference, Repository};
|
|
||||||
use log::{debug, info};
|
|
||||||
|
|
||||||
pub(crate) fn do_merge(
|
|
||||||
repo: &Repository,
|
|
||||||
local_branch: &Branch,
|
|
||||||
commit_to_merge: &AnnotatedCommit,
|
|
||||||
) -> crate::error::Result<()> {
|
|
||||||
debug!("Merging remote branches");
|
|
||||||
let analysis = repo.merge_analysis(&[&commit_to_merge])?;
|
|
||||||
|
|
||||||
if analysis.0.is_fast_forward() {
|
|
||||||
let refname = bytes_to_string(local_branch.get().name_bytes())?;
|
|
||||||
match repo.find_reference(&refname) {
|
|
||||||
Ok(mut r) => {
|
|
||||||
merge_fast_forward(repo, &mut r, &commit_to_merge)?;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// The branch doesn't exist, so set the reference to the commit directly. Usually
|
|
||||||
// this is because you are pulling into an empty repository.
|
|
||||||
repo.reference(
|
|
||||||
&refname,
|
|
||||||
commit_to_merge.id(),
|
|
||||||
true,
|
|
||||||
&format!("Setting {} to {}", refname, commit_to_merge.id()),
|
|
||||||
)?;
|
|
||||||
repo.set_head(&refname)?;
|
|
||||||
repo.checkout_head(Some(
|
|
||||||
git2::build::CheckoutBuilder::default()
|
|
||||||
.allow_conflicts(true)
|
|
||||||
.conflict_style_merge(true)
|
|
||||||
.force(),
|
|
||||||
))?;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else if analysis.0.is_normal() {
|
|
||||||
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
|
|
||||||
merge_normal(repo, &head_commit, commit_to_merge)?;
|
|
||||||
} else {
|
|
||||||
debug!("Skipping merge. Nothing to do")
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn merge_fast_forward(
|
|
||||||
repo: &Repository,
|
|
||||||
local_reference: &mut Reference,
|
|
||||||
remote_commit: &AnnotatedCommit,
|
|
||||||
) -> crate::error::Result<()> {
|
|
||||||
info!("Performing fast forward");
|
|
||||||
let name = match local_reference.name() {
|
|
||||||
Some(s) => s.to_string(),
|
|
||||||
None => String::from_utf8_lossy(local_reference.name_bytes()).to_string(),
|
|
||||||
};
|
|
||||||
let msg = format!("Fast-Forward: Setting {} to id: {}", name, remote_commit.id());
|
|
||||||
local_reference.set_target(remote_commit.id(), &msg)?;
|
|
||||||
repo.set_head(&name)?;
|
|
||||||
repo.checkout_head(Some(
|
|
||||||
git2::build::CheckoutBuilder::default()
|
|
||||||
// For some reason, the force is required to make the working directory actually get
|
|
||||||
// updated I suspect we should be adding some logic to handle dirty working directory
|
|
||||||
// states, but this is just an example so maybe not.
|
|
||||||
.force(),
|
|
||||||
))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn merge_normal(
|
|
||||||
repo: &Repository,
|
|
||||||
local: &AnnotatedCommit,
|
|
||||||
remote: &AnnotatedCommit,
|
|
||||||
) -> crate::error::Result<()> {
|
|
||||||
info!("Performing normal merge");
|
|
||||||
let local_tree = repo.find_commit(local.id())?.tree()?;
|
|
||||||
let remote_tree = repo.find_commit(remote.id())?.tree()?;
|
|
||||||
let ancestor = repo.find_commit(repo.merge_base(local.id(), remote.id())?)?.tree()?;
|
|
||||||
|
|
||||||
let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
|
|
||||||
|
|
||||||
if idx.has_conflicts() {
|
|
||||||
let conflicts = idx.conflicts()?;
|
|
||||||
for conflict in conflicts {
|
|
||||||
if let Ok(conflict) = conflict {
|
|
||||||
print_conflict(&conflict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Err(MergeConflicts);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
|
|
||||||
// now create the merge commit
|
|
||||||
let msg = format!("Merge: {} into {}", remote.id(), local.id());
|
|
||||||
let sig = repo.signature()?;
|
|
||||||
let local_commit = repo.find_commit(local.id())?;
|
|
||||||
let remote_commit = repo.find_commit(remote.id())?;
|
|
||||||
|
|
||||||
// Do our merge commit and set current branch head to that commit.
|
|
||||||
let _merge_commit = repo.commit(
|
|
||||||
Some("HEAD"),
|
|
||||||
&sig,
|
|
||||||
&sig,
|
|
||||||
&msg,
|
|
||||||
&result_tree,
|
|
||||||
&[&local_commit, &remote_commit],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Set working tree to match head.
|
|
||||||
repo.checkout_head(None)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_conflict(conflict: &git2::IndexConflict) {
|
|
||||||
let ancestor = conflict.ancestor.as_ref().map(path_from_index_entry);
|
|
||||||
let ours = conflict.our.as_ref().map(path_from_index_entry);
|
|
||||||
let theirs = conflict.their.as_ref().map(path_from_index_entry);
|
|
||||||
|
|
||||||
println!("Conflict detected:");
|
|
||||||
if let Some(path) = ancestor {
|
|
||||||
println!(" Common ancestor: {:?}", path);
|
|
||||||
}
|
|
||||||
if let Some(path) = ours {
|
|
||||||
println!(" Ours: {:?}", path);
|
|
||||||
}
|
|
||||||
if let Some(path) = theirs {
|
|
||||||
println!(" Theirs: {:?}", path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_from_index_entry(entry: &IndexEntry) -> String {
|
|
||||||
String::from_utf8_lossy(entry.path.as_slice()).into_owned()
|
|
||||||
}
|
|
||||||
@@ -47,10 +47,6 @@ pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
|
|||||||
Ok(branches)
|
Ok(branches)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_branch_by_name<'s>(repo: &'s Repository, name: &str) -> Result<Branch<'s>> {
|
|
||||||
Ok(repo.find_branch(name, BranchType::Local)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {
|
pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {
|
||||||
Ok(String::from_utf8(bytes.to_vec())?)
|
Ok(String::from_utf8(bytes.to_vec())?)
|
||||||
}
|
}
|
||||||
|
|||||||
161
src-web/components/CloneGitRepositoryDialog.tsx
Normal file
161
src-web/components/CloneGitRepositoryDialog.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { gitClone } from '@yaakapp-internal/git';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
|
||||||
|
import { appInfo } from '../lib/appInfo';
|
||||||
|
import { showErrorToast } from '../lib/toast';
|
||||||
|
import { Banner } from './core/Banner';
|
||||||
|
import { Button } from './core/Button';
|
||||||
|
import { Checkbox } from './core/Checkbox';
|
||||||
|
import { IconButton } from './core/IconButton';
|
||||||
|
import { PlainInput } from './core/PlainInput';
|
||||||
|
import { VStack } from './core/Stacks';
|
||||||
|
import { promptCredentials } from './git/credentials';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hide: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect path separator from an existing path (defaults to /)
|
||||||
|
function getPathSeparator(path: string): string {
|
||||||
|
return path.includes('\\') ? '\\' : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||||
|
const [url, setUrl] = useState<string>('');
|
||||||
|
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
|
||||||
|
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
|
||||||
|
const [hasSubdirectory, setHasSubdirectory] = useState(false);
|
||||||
|
const [subdirectory, setSubdirectory] = useState<string>('');
|
||||||
|
const [isCloning, setIsCloning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const repoName = extractRepoName(url);
|
||||||
|
const sep = getPathSeparator(baseDirectory);
|
||||||
|
const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
|
||||||
|
const directory = directoryOverride ?? computedDirectory;
|
||||||
|
const workspaceDirectory =
|
||||||
|
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
||||||
|
|
||||||
|
const handleSelectDirectory = async () => {
|
||||||
|
const dir = await open({
|
||||||
|
title: 'Select Directory',
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (dir != null) {
|
||||||
|
setBaseDirectory(dir);
|
||||||
|
setDirectoryOverride(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClone = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!url || !directory) return;
|
||||||
|
|
||||||
|
setIsCloning(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await gitClone(url, directory, promptCredentials);
|
||||||
|
|
||||||
|
if (result.type === 'needs_credentials') {
|
||||||
|
setError(
|
||||||
|
result.error ?? 'Authentication failed. Please check your credentials and try again.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the workspace from the cloned directory (or subdirectory)
|
||||||
|
await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
|
||||||
|
|
||||||
|
hide();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
showErrorToast({
|
||||||
|
id: 'git-clone-error',
|
||||||
|
title: 'Clone Failed',
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCloning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
|
||||||
|
{error && (
|
||||||
|
<Banner color="danger" className="w-full">
|
||||||
|
{error}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlainInput
|
||||||
|
required
|
||||||
|
label="Repository URL"
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
defaultValue={url}
|
||||||
|
onChange={setUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PlainInput
|
||||||
|
label="Directory"
|
||||||
|
placeholder={appInfo.defaultProjectDir}
|
||||||
|
defaultValue={directory}
|
||||||
|
onChange={setDirectoryOverride}
|
||||||
|
rightSlot={
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
className="mr-0.5 !h-auto my-0.5"
|
||||||
|
icon="folder"
|
||||||
|
title="Browse"
|
||||||
|
onClick={handleSelectDirectory}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={hasSubdirectory}
|
||||||
|
onChange={setHasSubdirectory}
|
||||||
|
title="Workspace is in a subdirectory"
|
||||||
|
help="Enable if the Yaak workspace files are not at the root of the repository"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasSubdirectory && (
|
||||||
|
<PlainInput
|
||||||
|
label="Subdirectory"
|
||||||
|
placeholder="path/to/workspace"
|
||||||
|
defaultValue={subdirectory}
|
||||||
|
onChange={setSubdirectory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
className="w-full mt-3"
|
||||||
|
disabled={!url || !directory || isCloning}
|
||||||
|
isLoading={isCloning}
|
||||||
|
>
|
||||||
|
{isCloning ? 'Cloning...' : 'Clone Repository'}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRepoName(url: string): string {
|
||||||
|
// Handle various Git URL formats:
|
||||||
|
// https://github.com/user/repo.git
|
||||||
|
// git@github.com:user/repo.git
|
||||||
|
// https://github.com/user/repo
|
||||||
|
const match = url.match(/\/([^/]+?)(\.git)?$/);
|
||||||
|
if (match?.[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
// Fallback for SSH-style URLs
|
||||||
|
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
|
||||||
|
if (sshMatch?.[1]) {
|
||||||
|
return sshMatch[1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { useWorkspaceActions } from '../hooks/useWorkspaceActions';
|
|||||||
import { showDialog } from '../lib/dialog';
|
import { showDialog } from '../lib/dialog';
|
||||||
import { jotaiStore } from '../lib/jotai';
|
import { jotaiStore } from '../lib/jotai';
|
||||||
import { revealInFinderText } from '../lib/reveal';
|
import { revealInFinderText } from '../lib/reveal';
|
||||||
|
import { CloneGitRepositoryDialog } from './CloneGitRepositoryDialog';
|
||||||
import type { ButtonProps } from './core/Button';
|
import type { ButtonProps } from './core/Button';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import type { DropdownItem } from './core/Dropdown';
|
import type { DropdownItem } from './core/Dropdown';
|
||||||
@@ -39,9 +40,19 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
const { mutate: deleteSendHistory } = useDeleteSendHistory();
|
const { mutate: deleteSendHistory } = useDeleteSendHistory();
|
||||||
const workspaceActions = useWorkspaceActions();
|
const workspaceActions = useWorkspaceActions();
|
||||||
|
|
||||||
const { workspaceItems, itemsAfter } = useMemo<{
|
const openCloneGitRepositoryDialog = useCallback(() => {
|
||||||
|
showDialog({
|
||||||
|
id: 'clone-git-repository',
|
||||||
|
size: 'md',
|
||||||
|
title: 'Clone Git Repository',
|
||||||
|
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{
|
||||||
workspaceItems: RadioDropdownItem[];
|
workspaceItems: RadioDropdownItem[];
|
||||||
itemsAfter: DropdownItem[];
|
itemsAfter: DropdownItem[];
|
||||||
|
itemsBefore: DropdownItem[];
|
||||||
}>(() => {
|
}>(() => {
|
||||||
const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
|
const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
|
||||||
key: w.id,
|
key: w.id,
|
||||||
@@ -50,6 +61,38 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const itemsBefore: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
label: 'New Workspace',
|
||||||
|
leftSlot: <Icon icon="plus" />,
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Create Empty',
|
||||||
|
leftSlot: <Icon icon="plus_circle" />,
|
||||||
|
onSelect: createWorkspace,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open Folder',
|
||||||
|
leftSlot: <Icon icon="folder_open" />,
|
||||||
|
onSelect: async () => {
|
||||||
|
const dir = await open({
|
||||||
|
title: 'Select Workspace Directory',
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dir == null) return;
|
||||||
|
openWorkspaceFromSyncDir.mutate(dir);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clone Git Repository',
|
||||||
|
leftSlot: <Icon icon="hard_drive_download" />,
|
||||||
|
onSelect: openCloneGitRepositoryDialog,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
const itemsAfter: DropdownItem[] = [
|
const itemsAfter: DropdownItem[] = [
|
||||||
...workspaceActions.map((a) => ({
|
...workspaceActions.map((a) => ({
|
||||||
label: a.label,
|
label: a.label,
|
||||||
@@ -80,34 +123,15 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
leftSlot: <Icon icon="history" />,
|
leftSlot: <Icon icon="history" />,
|
||||||
onSelect: deleteSendHistory,
|
onSelect: deleteSendHistory,
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'New Workspace',
|
|
||||||
leftSlot: <Icon icon="plus" />,
|
|
||||||
onSelect: createWorkspace,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Open Existing Workspace',
|
|
||||||
leftSlot: <Icon icon="folder_open" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
const dir = await open({
|
|
||||||
title: 'Select Workspace Directory',
|
|
||||||
directory: true,
|
|
||||||
multiple: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dir == null) return;
|
|
||||||
openWorkspaceFromSyncDir.mutate(dir);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return { workspaceItems, itemsAfter };
|
return { workspaceItems, itemsAfter, itemsBefore };
|
||||||
}, [
|
}, [
|
||||||
workspaces,
|
workspaces,
|
||||||
workspaceMeta,
|
workspaceMeta,
|
||||||
deleteSendHistory,
|
deleteSendHistory,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
|
openCloneGitRepositoryDialog,
|
||||||
workspace?.id,
|
workspace?.id,
|
||||||
workspace,
|
workspace,
|
||||||
workspaceActions.map,
|
workspaceActions.map,
|
||||||
@@ -144,6 +168,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
<RadioDropdown
|
<RadioDropdown
|
||||||
items={workspaceItems}
|
items={workspaceItems}
|
||||||
itemsAfter={itemsAfter}
|
itemsAfter={itemsAfter}
|
||||||
|
itemsBefore={itemsBefore}
|
||||||
onChange={handleSwitchWorkspace}
|
onChange={handleSwitchWorkspace}
|
||||||
value={workspace?.id ?? null}
|
value={workspace?.id ?? null}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export type DropdownItemDefault = {
|
|||||||
keepOpenOnSelect?: boolean;
|
keepOpenOnSelect?: boolean;
|
||||||
onSelect?: () => void | Promise<void>;
|
onSelect?: () => void | Promise<void>;
|
||||||
submenu?: DropdownItem[];
|
submenu?: DropdownItem[];
|
||||||
|
/** If true, submenu opens on click instead of hover */
|
||||||
|
submenuOpenOnClick?: boolean;
|
||||||
icon?: IconProps['icon'];
|
icon?: IconProps['icon'];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -272,6 +274,7 @@ interface MenuProps {
|
|||||||
defaultSelectedIndex: number | null;
|
defaultSelectedIndex: number | null;
|
||||||
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
|
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onCloseAll?: () => void;
|
||||||
showTriangle?: boolean;
|
showTriangle?: boolean;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -288,6 +291,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
items,
|
items,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
onClose,
|
onClose,
|
||||||
|
onCloseAll,
|
||||||
triggerShape,
|
triggerShape,
|
||||||
defaultSelectedIndex,
|
defaultSelectedIndex,
|
||||||
showTriangle,
|
showTriangle,
|
||||||
@@ -300,7 +304,16 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
defaultSelectedIndex ?? -1,
|
defaultSelectedIndex ?? -1,
|
||||||
[defaultSelectedIndex],
|
[defaultSelectedIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [filter, setFilter] = useState<string>('');
|
const [filter, setFilter] = useState<string>('');
|
||||||
|
|
||||||
|
// Clear filter when menu opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setFilter('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const [activeSubmenu, setActiveSubmenu] = useState<{
|
const [activeSubmenu, setActiveSubmenu] = useState<{
|
||||||
item: DropdownItemDefault;
|
item: DropdownItemDefault;
|
||||||
parent: HTMLButtonElement;
|
parent: HTMLButtonElement;
|
||||||
@@ -320,10 +333,18 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
onClose();
|
onClose();
|
||||||
setFilter('');
|
|
||||||
setActiveSubmenu(null);
|
setActiveSubmenu(null);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Close the entire menu hierarchy (used when selecting an item)
|
||||||
|
const handleCloseAll = useCallback(() => {
|
||||||
|
if (onCloseAll) {
|
||||||
|
onCloseAll();
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}, [onCloseAll, handleClose]);
|
||||||
|
|
||||||
// Handle type-ahead filtering (only for the deepest open menu)
|
// Handle type-ahead filtering (only for the deepest open menu)
|
||||||
const handleMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
const handleMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||||
// Skip if this menu has a submenu open - let the submenu handle typing
|
// Skip if this menu has a submenu open - let the submenu handle typing
|
||||||
@@ -393,6 +414,14 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
[items, setSelectedIndex],
|
[items, setSelectedIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ensure selection is on a valid item (not hidden/separator/content)
|
||||||
|
useEffect(() => {
|
||||||
|
const item = items[selectedIndex ?? -1];
|
||||||
|
if (item?.hidden || item?.type === 'separator' || item?.type === 'content') {
|
||||||
|
handleNext();
|
||||||
|
}
|
||||||
|
}, [selectedIndex, items, handleNext]);
|
||||||
|
|
||||||
useKey(
|
useKey(
|
||||||
'ArrowUp',
|
'ArrowUp',
|
||||||
(e) => {
|
(e) => {
|
||||||
@@ -433,7 +462,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
async (item: DropdownItem) => {
|
async (item: DropdownItem, parentEl?: HTMLButtonElement) => {
|
||||||
|
// Handle click-to-open submenu
|
||||||
|
if ('submenu' in item && item.submenu && item.submenuOpenOnClick && parentEl) {
|
||||||
|
setActiveSubmenu({ item, parent: parentEl });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!('onSelect' in item) || !item.onSelect) return;
|
if (!('onSelect' in item) || !item.onSelect) return;
|
||||||
setSelectedIndex(null);
|
setSelectedIndex(null);
|
||||||
|
|
||||||
@@ -446,9 +481,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.keepOpenOnSelect) handleClose();
|
if (!item.keepOpenOnSelect) handleCloseAll();
|
||||||
},
|
},
|
||||||
[handleClose, setSelectedIndex],
|
[handleCloseAll, setSelectedIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => {
|
useImperativeHandle(ref, () => {
|
||||||
@@ -476,17 +511,23 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
const parentRect = triggerShape;
|
const parentRect = triggerShape;
|
||||||
const docRect = document.documentElement.getBoundingClientRect();
|
const docRect = document.documentElement.getBoundingClientRect();
|
||||||
const spaceRight = docRect.width - parentRect.right;
|
const spaceRight = docRect.width - parentRect.right;
|
||||||
|
const spaceBelow = docRect.height - parentRect.top;
|
||||||
|
const spaceAbove = parentRect.bottom;
|
||||||
const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
|
const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
|
||||||
|
// Estimate submenu height (items * ~28px + padding), flip if not enough space below
|
||||||
|
const estimatedHeight = items.length * 28 + 20;
|
||||||
|
const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
upsideDown: false,
|
upsideDown: openUpward,
|
||||||
container: {
|
container: {
|
||||||
top: parentRect.top,
|
top: openUpward ? undefined : parentRect.top,
|
||||||
|
bottom: openUpward ? docRect.height - parentRect.bottom : undefined,
|
||||||
left: openLeft ? undefined : parentRect.right,
|
left: openLeft ? undefined : parentRect.right,
|
||||||
right: openLeft ? docRect.width - parentRect.left : undefined,
|
right: openLeft ? docRect.width - parentRect.left : undefined,
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
maxHeight: `${docRect.height - parentRect.top - 20}px`,
|
maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`,
|
||||||
},
|
},
|
||||||
triangle: {}, // No triangle for submenus
|
triangle: {}, // No triangle for submenus
|
||||||
};
|
};
|
||||||
@@ -586,7 +627,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
clearTimeout(submenuTimeoutRef.current);
|
clearTimeout(submenuTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.submenu) {
|
if (item.submenu && !item.submenuOpenOnClick) {
|
||||||
setActiveSubmenu({ item, parent });
|
setActiveSubmenu({ item, parent });
|
||||||
} else if (activeSubmenu) {
|
} else if (activeSubmenu) {
|
||||||
submenuTimeoutRef.current = window.setTimeout(() => {
|
submenuTimeoutRef.current = window.setTimeout(() => {
|
||||||
@@ -759,6 +800,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
items={activeSubmenu.item.submenu ?? []}
|
items={activeSubmenu.item.submenu ?? []}
|
||||||
defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null}
|
defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null}
|
||||||
onClose={() => setActiveSubmenu(null)}
|
onClose={() => setActiveSubmenu(null)}
|
||||||
|
onCloseAll={handleCloseAll}
|
||||||
triggerShape={submenuTriggerShape}
|
triggerShape={submenuTriggerShape}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -804,7 +846,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
interface MenuItemProps {
|
interface MenuItemProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
item: DropdownItemDefault;
|
item: DropdownItemDefault;
|
||||||
onSelect: (item: DropdownItemDefault) => Promise<void>;
|
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
||||||
onFocus: (item: DropdownItemDefault) => void;
|
onFocus: (item: DropdownItemDefault) => void;
|
||||||
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
@@ -824,7 +866,7 @@ function MenuItem({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const handleClick = useCallback(async () => {
|
const handleClick = useCallback(async () => {
|
||||||
if (item.waitForOnSelect) setIsLoading(true);
|
if (item.waitForOnSelect) setIsLoading(true);
|
||||||
await onSelect?.(item);
|
await onSelect?.(item, buttonRef.current ?? undefined);
|
||||||
if (item.waitForOnSelect) setIsLoading(false);
|
if (item.waitForOnSelect) setIsLoading(false);
|
||||||
}, [item, onSelect]);
|
}, [item, onSelect]);
|
||||||
|
|
||||||
@@ -854,7 +896,7 @@ function MenuItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rightSlot = item.submenu ? (
|
const rightSlot = item.submenu ? (
|
||||||
<Icon icon="chevron_right" />
|
<Icon icon="chevron_right" color='secondary' />
|
||||||
) : (
|
) : (
|
||||||
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />)
|
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import type { DropdownItem } from '../core/Dropdown';
|
|||||||
import { Dropdown } from '../core/Dropdown';
|
import { Dropdown } from '../core/Dropdown';
|
||||||
import { Icon } from '../core/Icon';
|
import { Icon } from '../core/Icon';
|
||||||
import { InlineCode } from '../core/InlineCode';
|
import { InlineCode } from '../core/InlineCode';
|
||||||
import { BranchSelectionDialog } from './BranchSelectionDialog';
|
|
||||||
import { gitCallbacks } from './callbacks';
|
import { gitCallbacks } from './callbacks';
|
||||||
import { GitCommitDialog } from './GitCommitDialog';
|
import { GitCommitDialog } from './GitCommitDialog';
|
||||||
import { GitRemotesDialog } from './GitRemotesDialog';
|
import { GitRemotesDialog } from './GitRemotesDialog';
|
||||||
@@ -39,7 +38,18 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||||
const [
|
const [
|
||||||
{ status, log },
|
{ status, log },
|
||||||
{ branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init },
|
{
|
||||||
|
createBranch,
|
||||||
|
deleteBranch,
|
||||||
|
deleteRemoteBranch,
|
||||||
|
renameBranch,
|
||||||
|
fetchAll,
|
||||||
|
mergeBranch,
|
||||||
|
push,
|
||||||
|
pull,
|
||||||
|
checkout,
|
||||||
|
init,
|
||||||
|
},
|
||||||
] = useGit(syncDir, gitCallbacks(syncDir));
|
] = useGit(syncDir, gitCallbacks(syncDir));
|
||||||
|
|
||||||
const localBranches = status.data?.localBranches ?? [];
|
const localBranches = status.data?.localBranches ?? [];
|
||||||
@@ -47,8 +57,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
const remoteOnlyBranches = remoteBranches.filter(
|
const remoteOnlyBranches = remoteBranches.filter(
|
||||||
(b) => !localBranches.includes(b.replace(/^origin\//, '')),
|
(b) => !localBranches.includes(b.replace(/^origin\//, '')),
|
||||||
);
|
);
|
||||||
const currentBranch = status.data?.headRefShorthand ?? 'UNKNOWN';
|
|
||||||
|
|
||||||
if (workspace == null) {
|
if (workspace == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -58,6 +66,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
|
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Still loading
|
||||||
|
if (status.data == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBranch = status.data.headRefShorthand;
|
||||||
|
|
||||||
const tryCheckout = (branch: string, force: boolean) => {
|
const tryCheckout = (branch: string, force: boolean) => {
|
||||||
checkout.mutate(
|
checkout.mutate(
|
||||||
{ branch, force },
|
{ branch, force },
|
||||||
@@ -104,7 +119,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
|
|
||||||
const items: DropdownItem[] = [
|
const items: DropdownItem[] = [
|
||||||
{
|
{
|
||||||
label: 'View History',
|
label: 'View History...',
|
||||||
hidden: (log.data ?? []).length === 0,
|
hidden: (log.data ?? []).length === 0,
|
||||||
leftSlot: <Icon icon="history" />,
|
leftSlot: <Icon icon="history" />,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
@@ -118,13 +133,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Manage Remotes',
|
label: 'Manage Remotes...',
|
||||||
leftSlot: <Icon icon="hard_drive_download" />,
|
leftSlot: <Icon icon="hard_drive_download" />,
|
||||||
onSelect: () => GitRemotesDialog.show(syncDir),
|
onSelect: () => GitRemotesDialog.show(syncDir),
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'New Branch',
|
label: 'New Branch...',
|
||||||
leftSlot: <Icon icon="git_branch_plus" />,
|
leftSlot: <Icon icon="git_branch_plus" />,
|
||||||
async onSelect() {
|
async onSelect() {
|
||||||
const name = await showPrompt({
|
const name = await showPrompt({
|
||||||
@@ -134,7 +149,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
});
|
});
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
await branch.mutateAsync(
|
await createBranch.mutateAsync(
|
||||||
{ branch: name },
|
{ branch: name },
|
||||||
{
|
{
|
||||||
disableToastError: true,
|
disableToastError: true,
|
||||||
@@ -150,95 +165,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
tryCheckout(name, false);
|
tryCheckout(name, false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Merge Branch',
|
|
||||||
leftSlot: <Icon icon="merge" />,
|
|
||||||
hidden: localBranches.length <= 1,
|
|
||||||
async onSelect() {
|
|
||||||
showDialog({
|
|
||||||
id: 'git-merge',
|
|
||||||
title: 'Merge Branch',
|
|
||||||
size: 'sm',
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Select a branch to merge into <InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<BranchSelectionDialog
|
|
||||||
selectText="Merge"
|
|
||||||
branches={localBranches.filter((b) => b !== currentBranch)}
|
|
||||||
onCancel={hide}
|
|
||||||
onSelect={async (branch) => {
|
|
||||||
await mergeBranch.mutateAsync(
|
|
||||||
{ branch, force: false },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSettled: hide,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: 'git-merged-branch',
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Merged <InlineCode>{branch}</InlineCode> into{' '}
|
|
||||||
<InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
sync({ force: true });
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: 'git-merged-branch-error',
|
|
||||||
title: 'Error merging branch',
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Delete Branch',
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
hidden: localBranches.length <= 1,
|
|
||||||
color: 'danger',
|
|
||||||
async onSelect() {
|
|
||||||
if (currentBranch == null) return;
|
|
||||||
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: 'git-delete-branch',
|
|
||||||
title: 'Delete Branch',
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete <InlineCode>{currentBranch}</InlineCode>?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await deleteBranch.mutateAsync(
|
|
||||||
{ branch: currentBranch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: 'git-delete-branch-error',
|
|
||||||
title: 'Error deleting branch',
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async onSuccess() {
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Push',
|
label: 'Push',
|
||||||
@@ -278,7 +204,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Commit',
|
label: 'Commit...',
|
||||||
leftSlot: <Icon icon="git_commit_vertical" />,
|
leftSlot: <Icon icon="git_commit_vertical" />,
|
||||||
onSelect() {
|
onSelect() {
|
||||||
showDialog({
|
showDialog({
|
||||||
@@ -298,16 +224,239 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
return {
|
return {
|
||||||
label: branch,
|
label: branch,
|
||||||
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
|
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
|
||||||
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
|
submenuOpenOnClick: true,
|
||||||
};
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Checkout',
|
||||||
|
hidden: isCurrent,
|
||||||
|
onSelect: () => tryCheckout(branch, false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Merge into <InlineCode>{currentBranch}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
hidden: isCurrent,
|
||||||
|
async onSelect() {
|
||||||
|
await mergeBranch.mutateAsync(
|
||||||
|
{ branch },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess() {
|
||||||
|
showToast({
|
||||||
|
id: 'git-merged-branch',
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Merged <InlineCode>{branch}</InlineCode> into{' '}
|
||||||
|
<InlineCode>{currentBranch}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
sync({ force: true });
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: 'git-merged-branch-error',
|
||||||
|
title: 'Error merging branch',
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New Branch...',
|
||||||
|
async onSelect() {
|
||||||
|
const name = await showPrompt({
|
||||||
|
id: 'git-new-branch-from',
|
||||||
|
title: 'New Branch',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Create a new branch from <InlineCode>{branch}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
label: 'Branch Name',
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
await createBranch.mutateAsync(
|
||||||
|
{ branch: name, base: branch },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onError: (err) => {
|
||||||
|
showErrorToast({
|
||||||
|
id: 'git-branch-error',
|
||||||
|
title: 'Error creating branch',
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tryCheckout(name, false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rename...',
|
||||||
|
async onSelect() {
|
||||||
|
const newName = await showPrompt({
|
||||||
|
id: 'git-rename-branch',
|
||||||
|
title: 'Rename Branch',
|
||||||
|
label: 'New Branch Name',
|
||||||
|
defaultValue: branch,
|
||||||
|
});
|
||||||
|
if (!newName || newName === branch) return;
|
||||||
|
|
||||||
|
await renameBranch.mutateAsync(
|
||||||
|
{ oldName: branch, newName },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess() {
|
||||||
|
showToast({
|
||||||
|
id: 'git-rename-branch-success',
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Renamed <InlineCode>{branch}</InlineCode> to{' '}
|
||||||
|
<InlineCode>{newName}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
color: 'success',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: 'git-rename-branch-error',
|
||||||
|
title: 'Error renaming branch',
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'separator', hidden: isCurrent },
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
color: 'danger',
|
||||||
|
hidden: isCurrent,
|
||||||
|
onSelect: async () => {
|
||||||
|
const confirmed = await showConfirmDelete({
|
||||||
|
id: 'git-delete-branch',
|
||||||
|
title: 'Delete Branch',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Permanently delete <InlineCode>{branch}</InlineCode>?
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteBranch.mutateAsync(
|
||||||
|
{ branch },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: 'git-delete-branch-error',
|
||||||
|
title: 'Error deleting branch',
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.type === 'not_fully_merged') {
|
||||||
|
const confirmed = await showConfirm({
|
||||||
|
id: 'force-branch-delete',
|
||||||
|
title: 'Branch not fully merged',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
|
||||||
|
</p>
|
||||||
|
<p>Do you want to delete it anyway?</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
await deleteBranch.mutateAsync(
|
||||||
|
{ branch, force: true },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: 'git-force-delete-branch-error',
|
||||||
|
title: 'Error force deleting branch',
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies DropdownItem;
|
||||||
}),
|
}),
|
||||||
...remoteOnlyBranches.map((branch) => {
|
...remoteOnlyBranches.map((branch) => {
|
||||||
const isCurrent = currentBranch === branch;
|
const isCurrent = currentBranch === branch;
|
||||||
return {
|
return {
|
||||||
label: branch,
|
label: branch,
|
||||||
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
|
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
|
||||||
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
|
submenuOpenOnClick: true,
|
||||||
};
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Checkout',
|
||||||
|
hidden: isCurrent,
|
||||||
|
onSelect: () => tryCheckout(branch, false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
color: 'danger',
|
||||||
|
async onSelect() {
|
||||||
|
const confirmed = await showConfirmDelete({
|
||||||
|
id: 'git-delete-remote-branch',
|
||||||
|
title: 'Delete Remote Branch',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await deleteRemoteBranch.mutateAsync(
|
||||||
|
{ branch },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess() {
|
||||||
|
showToast({
|
||||||
|
id: 'git-delete-remote-branch-success',
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Deleted remote branch <InlineCode>{branch}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
color: 'success',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: 'git-delete-remote-branch-error',
|
||||||
|
title: 'Error deleting remote branch',
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies DropdownItem;
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { GitCallbacks } from '@yaakapp-internal/git';
|
import type { GitCallbacks } from '@yaakapp-internal/git';
|
||||||
import { showPromptForm } from '../../lib/prompt-form';
|
import { promptCredentials } from './credentials';
|
||||||
import { Banner } from '../core/Banner';
|
|
||||||
import { InlineCode } from '../core/InlineCode';
|
|
||||||
import { addGitRemote } from './showAddRemoteDialog';
|
import { addGitRemote } from './showAddRemoteDialog';
|
||||||
|
|
||||||
export function gitCallbacks(dir: string): GitCallbacks {
|
export function gitCallbacks(dir: string): GitCallbacks {
|
||||||
@@ -9,40 +7,10 @@ export function gitCallbacks(dir: string): GitCallbacks {
|
|||||||
addRemote: async () => {
|
addRemote: async () => {
|
||||||
return addGitRemote(dir);
|
return addGitRemote(dir);
|
||||||
},
|
},
|
||||||
promptCredentials: async ({ url: remoteUrl, error }) => {
|
promptCredentials: async ({ url, error }) => {
|
||||||
const isGitHub = /github\.com/i.test(remoteUrl);
|
const creds = await promptCredentials({ url, error });
|
||||||
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
|
if (creds == null) throw new Error('Cancelled credentials prompt');
|
||||||
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
|
return creds;
|
||||||
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
|
|
||||||
const passDescription = isGitHub
|
|
||||||
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
|
|
||||||
: 'Enter your password or access token for this Git server.';
|
|
||||||
const r = await showPromptForm({
|
|
||||||
id: 'git-credentials',
|
|
||||||
title: 'Credentials Required',
|
|
||||||
description: error ? (
|
|
||||||
<Banner color="danger">{error}</Banner>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
inputs: [
|
|
||||||
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
name: 'password',
|
|
||||||
label: passLabel,
|
|
||||||
description: passDescription,
|
|
||||||
password: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (r == null) throw new Error('Cancelled credentials prompt');
|
|
||||||
|
|
||||||
const username = String(r.username || '');
|
|
||||||
const password = String(r.password || '');
|
|
||||||
return { username, password };
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
50
src-web/components/git/credentials.tsx
Normal file
50
src-web/components/git/credentials.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { showPromptForm } from '../../lib/prompt-form';
|
||||||
|
import { Banner } from '../core/Banner';
|
||||||
|
import { InlineCode } from '../core/InlineCode';
|
||||||
|
|
||||||
|
export interface GitCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptCredentials({
|
||||||
|
url: remoteUrl,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
error: string | null;
|
||||||
|
}): Promise<GitCredentials | null> {
|
||||||
|
const isGitHub = /github\.com/i.test(remoteUrl);
|
||||||
|
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
|
||||||
|
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
|
||||||
|
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
|
||||||
|
const passDescription = isGitHub
|
||||||
|
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
|
||||||
|
: 'Enter your password or access token for this Git server.';
|
||||||
|
const r = await showPromptForm({
|
||||||
|
id: 'git-credentials',
|
||||||
|
title: 'Credentials Required',
|
||||||
|
description: error ? (
|
||||||
|
<Banner color="danger">{error}</Banner>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
inputs: [
|
||||||
|
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'password',
|
||||||
|
label: passLabel,
|
||||||
|
description: passDescription,
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (r == null) return null;
|
||||||
|
|
||||||
|
const username = String(r.username || '');
|
||||||
|
const password = String(r.password || '');
|
||||||
|
return { username, password };
|
||||||
|
}
|
||||||
@@ -23,14 +23,17 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
|
|||||||
variables: TVariables,
|
variables: TVariables,
|
||||||
args?: CallbackMutationOptions<TData, TError, TVariables>,
|
args?: CallbackMutationOptions<TData, TError, TVariables>,
|
||||||
) => {
|
) => {
|
||||||
const { mutationKey, mutationFn, onSuccess, onError, onSettled, disableToastError } = {
|
const { mutationKey, mutationFn, disableToastError } = {
|
||||||
...defaultArgs,
|
...defaultArgs,
|
||||||
...args,
|
...args,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const data = await mutationFn(variables);
|
const data = await mutationFn(variables);
|
||||||
onSuccess?.(data);
|
// Run both default and custom onSuccess callbacks
|
||||||
onSettled?.();
|
defaultArgs.onSuccess?.(data);
|
||||||
|
args?.onSuccess?.(data);
|
||||||
|
defaultArgs.onSettled?.();
|
||||||
|
args?.onSettled?.();
|
||||||
return data;
|
return data;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const stringKey = mutationKey.join('.');
|
const stringKey = mutationKey.join('.');
|
||||||
@@ -44,8 +47,11 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
|
|||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
onError?.(e);
|
// Run both default and custom onError callbacks
|
||||||
onSettled?.();
|
defaultArgs.onError?.(e);
|
||||||
|
args?.onError?.(e);
|
||||||
|
defaultArgs.onSettled?.();
|
||||||
|
args?.onSettled?.();
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface AppInfo {
|
|||||||
appDataDir: string;
|
appDataDir: string;
|
||||||
appLogDir: string;
|
appLogDir: string;
|
||||||
vendoredPluginDir: string;
|
vendoredPluginDir: string;
|
||||||
|
defaultProjectDir: string;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
featureLicense: boolean;
|
featureLicense: boolean;
|
||||||
featureUpdater: boolean;
|
featureUpdater: boolean;
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type TauriCmd =
|
|||||||
| 'cmd_get_sse_events'
|
| 'cmd_get_sse_events'
|
||||||
| 'cmd_get_themes'
|
| 'cmd_get_themes'
|
||||||
| 'cmd_get_workspace_meta'
|
| 'cmd_get_workspace_meta'
|
||||||
|
| 'cmd_git_add_credential'
|
||||||
|
| 'cmd_git_clone'
|
||||||
| 'cmd_grpc_go'
|
| 'cmd_grpc_go'
|
||||||
| 'cmd_grpc_reflect'
|
| 'cmd_grpc_reflect'
|
||||||
| 'cmd_grpc_request_actions'
|
| 'cmd_grpc_request_actions'
|
||||||
|
|||||||
Reference in New Issue
Block a user