diff --git a/crates-tauri/yaak-app/src/git_ext.rs b/crates-tauri/yaak-app/src/git_ext.rs index 2cc79088..dbbb4999 100644 --- a/crates-tauri/yaak-app/src/git_ext.rs +++ b/crates-tauri/yaak-app/src/git_ext.rs @@ -6,9 +6,10 @@ use crate::error::Result; use std::path::{Path, PathBuf}; use tauri::command; use yaak_git::{ - GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, git_add, git_add_credential, - git_add_remote, git_checkout_branch, git_commit, git_create_branch, git_delete_branch, - git_fetch_all, git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, + BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, + PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone, + 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, }; @@ -16,22 +17,36 @@ use yaak_git::{ #[command] pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result { - Ok(git_checkout_branch(dir, branch, force)?) + Ok(git_checkout_branch(dir, branch, force).await?) } #[command] -pub async fn cmd_git_branch(dir: &Path, branch: &str) -> Result<()> { - Ok(git_create_branch(dir, branch)?) +pub async fn cmd_git_branch(dir: &Path, branch: &str, base: Option<&str>) -> Result<()> { + Ok(git_create_branch(dir, branch, base).await?) } #[command] -pub async fn cmd_git_delete_branch(dir: &Path, branch: &str) -> Result<()> { - Ok(git_delete_branch(dir, branch)?) +pub async fn cmd_git_delete_branch( + dir: &Path, + branch: &str, + force: Option, +) -> Result { + Ok(git_delete_branch(dir, branch, force.unwrap_or(false)).await?) } #[command] -pub async fn cmd_git_merge_branch(dir: &Path, branch: &str, force: bool) -> Result<()> { - Ok(git_merge_branch(dir, branch, force)?) +pub async fn cmd_git_delete_remote_branch(dir: &Path, branch: &str) -> Result<()> { + 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] @@ -49,6 +64,11 @@ pub async fn cmd_git_initialize(dir: &Path) -> Result<()> { Ok(git_init(dir)?) } +#[command] +pub async fn cmd_git_clone(url: &str, dir: &Path) -> Result { + Ok(git_clone(url, dir).await?) +} + #[command] pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> { Ok(git_commit(dir, message).await?) @@ -87,12 +107,11 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec) -> Result<()> #[command] pub async fn cmd_git_add_credential( - dir: &Path, remote_url: &str, username: &str, password: &str, ) -> Result<()> { - Ok(git_add_credential(dir, remote_url, username, password).await?) + Ok(git_add_credential(remote_url, username, password).await?) } #[command] diff --git a/crates-tauri/yaak-app/src/lib.rs b/crates-tauri/yaak-app/src/lib.rs index a00da2f7..e91fd888 100644 --- a/crates-tauri/yaak-app/src/lib.rs +++ b/crates-tauri/yaak-app/src/lib.rs @@ -101,6 +101,7 @@ struct AppMetaData { app_data_dir: String, app_log_dir: String, vendored_plugin_dir: String, + default_project_dir: String, feature_updater: bool, feature_license: bool, } @@ -111,6 +112,7 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult { let app_log_dir = app_handle.path().app_log_dir()?; let vendored_plugin_dir = app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?; + let default_project_dir = app_handle.path().home_dir()?.join("YaakProjects"); Ok(AppMetaData { is_dev: is_dev(), version: app_handle.package_info().version.to_string(), @@ -118,6 +120,7 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult { app_data_dir: app_data_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(), + default_project_dir: default_project_dir.to_string_lossy().to_string(), feature_license: cfg!(feature = "license"), feature_updater: cfg!(feature = "updater"), }) @@ -1747,10 +1750,13 @@ pub fn run() { git_ext::cmd_git_checkout, git_ext::cmd_git_branch, git_ext::cmd_git_delete_branch, + git_ext::cmd_git_delete_remote_branch, git_ext::cmd_git_merge_branch, + git_ext::cmd_git_rename_branch, git_ext::cmd_git_status, git_ext::cmd_git_log, git_ext::cmd_git_initialize, + git_ext::cmd_git_clone, git_ext::cmd_git_commit, git_ext::cmd_git_fetch_all, git_ext::cmd_git_push, diff --git a/crates/yaak-git/bindings/gen_git.ts b/crates/yaak-git/bindings/gen_git.ts index e1d7ef8a..5f6bff76 100644 --- a/crates/yaak-git/bindings/gen_git.ts +++ b/crates/yaak-git/bindings/gen_git.ts @@ -1,6 +1,10 @@ // 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"; +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 GitCommit = { author: GitAuthor, when: string, message: string | null, }; diff --git a/crates/yaak-git/index.ts b/crates/yaak-git/index.ts index dd415c4b..32c7a3da 100644 --- a/crates/yaak-git/index.ts +++ b/crates/yaak-git/index.ts @@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation'; import { queryClient } from '@yaakapp/app/lib/queryClient'; 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'; @@ -59,7 +59,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => { if (creds == null) throw new Error('Canceled'); await invoke('cmd_git_add_credential', { - dir, remoteUrl: result.url, username: creds.username, password: creds.password, @@ -90,21 +89,31 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => { mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }), onSuccess, }), - branch: createFastMutation({ + createBranch: createFastMutation({ mutationKey: ['git', 'branch', dir], mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }), onSuccess, }), - mergeBranch: createFastMutation({ + mergeBranch: createFastMutation({ mutationKey: ['git', 'merge', dir], mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }), onSuccess, }), - deleteBranch: createFastMutation({ + deleteBranch: createFastMutation({ mutationKey: ['git', 'delete-branch', dir], mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }), onSuccess, }), + deleteRemoteBranch: createFastMutation({ + mutationKey: ['git', 'delete-remote-branch', dir], + mutationFn: (args) => invoke('cmd_git_delete_remote_branch', { dir, ...args }), + onSuccess, + }), + renameBranch: createFastMutation({ + mutationKey: ['git', 'rename-branch', dir], + mutationFn: (args) => invoke('cmd_git_rename_branch', { dir, ...args }), + onSuccess, + }), checkout: createFastMutation({ mutationKey: ['git', 'checkout', dir], 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'); await invoke('cmd_git_add_credential', { - dir, remoteUrl: result.url, username: creds.username, password: creds.password, @@ -166,3 +174,28 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => { async function getRemotes(dir: string) { return invoke('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, +): Promise { + const result = await invoke('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('cmd_git_clone', { url, dir }); +} diff --git a/crates/yaak-git/src/binary.rs b/crates/yaak-git/src/binary.rs index 8ef0cf81..270e6dbe 100644 --- a/crates/yaak-git/src/binary.rs +++ b/crates/yaak-git/src/binary.rs @@ -5,7 +5,15 @@ use std::process::Stdio; use tokio::process::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 { + 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 { // 1. Probe that `git` exists and is runnable let mut probe = new_xplatform_command("git"); 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 { } // 2. Build the reusable git command - let mut cmd = new_xplatform_command("git"); - cmd.arg("-C").arg(dir); - + let cmd = new_xplatform_command("git"); Ok(cmd) } diff --git a/crates/yaak-git/src/branch.rs b/crates/yaak-git/src/branch.rs index 52b0f11a..a365951d 100644 --- a/crates/yaak-git/src/branch.rs +++ b/crates/yaak-git/src/branch.rs @@ -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::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; -pub fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result { - if branch_name.starts_with("origin/") { - return git_checkout_remote_branch(dir, branch_name, force); - } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "type")] +#[ts(export, export_to = "gen_git.ts")] +pub enum BranchDeleteResult { + Success { message: String }, + NotFullyMerged, +} - let repo = open_repo(dir)?; - let branch = get_branch_by_name(&repo, branch_name)?; - let branch_ref = branch.into_reference(); - let branch_tree = branch_ref.peel_to_tree()?; +pub async fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result { + let branch_name = branch_name.trim_start_matches("origin/"); - let mut options = CheckoutBuilder::default(); + let mut args = vec!["checkout"]; if force { - options.force(); + args.push("--force"); } + args.push(branch_name); - repo.checkout_tree(branch_tree.as_object(), Some(&mut options))?; - repo.set_head(branch_ref.name().unwrap())?; + let out = new_binary_command(dir) + .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()) } -pub(crate) fn git_checkout_remote_branch( - dir: &Path, - branch_name: &str, - force: bool, -) -> Result { - 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)?; +pub async fn git_create_branch(dir: &Path, name: &str, base: Option<&str>) -> Result<()> { + let mut cmd = new_binary_command(dir).await?; + cmd.arg("branch").arg(name); + if let Some(base_branch) = base { + cmd.arg(base_branch); } - 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(()) } -pub fn git_merge_branch(dir: &Path, name: &str, _force: bool) -> Result<()> { - let repo = open_repo(dir)?; - let local_branch = get_current_branch(&repo)?.unwrap(); +pub async fn git_delete_branch(dir: &Path, name: &str, force: bool) -> Result { + let mut cmd = new_binary_command(dir).await?; - let commit_to_merge = get_branch_by_name(&repo, name)?.into_reference(); - let commit_to_merge = repo.reference_to_annotated_commit(&commit_to_merge)?; + let out = + 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(()) } diff --git a/crates/yaak-git/src/clone.rs b/crates/yaak-git/src/clone.rs new file mode 100644 index 00000000..c3d83317 --- /dev/null +++ b/crates/yaak-git/src/clone.rs @@ -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 }, +} + +pub async fn git_clone(url: &str, dir: &Path) -> Result { + 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) +} diff --git a/crates/yaak-git/src/credential.rs b/crates/yaak-git/src/credential.rs index ab53f86c..41c7442e 100644 --- a/crates/yaak-git/src/credential.rs +++ b/crates/yaak-git/src/credential.rs @@ -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::Result; -use std::path::Path; use std::process::Stdio; use tokio::io::AsyncWriteExt; use url::Url; -pub async fn git_add_credential( - dir: &Path, - remote_url: &str, - username: &str, - password: &str, -) -> Result<()> { +pub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> { let url = Url::parse(remote_url) .map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?; let protocol = url.scheme(); let host = url.host_str().unwrap(); let path = Some(url.path()); - let mut child = new_binary_command(dir) + let mut child = new_binary_command_global() .await? .args(["credential", "approve"]) .stdin(Stdio::piped()) diff --git a/crates/yaak-git/src/lib.rs b/crates/yaak-git/src/lib.rs index fc21d9ea..f399c24b 100644 --- a/crates/yaak-git/src/lib.rs +++ b/crates/yaak-git/src/lib.rs @@ -1,13 +1,14 @@ mod add; mod binary; mod branch; +mod clone; mod commit; mod credential; pub mod error; mod fetch; mod init; mod log; -mod merge; + mod pull; mod push; mod remotes; @@ -18,7 +19,11 @@ mod util; // Re-export all git functions for external use 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 credential::git_add_credential; pub use fetch::git_fetch_all; diff --git a/crates/yaak-git/src/merge.rs b/crates/yaak-git/src/merge.rs deleted file mode 100644 index 2c621141..00000000 --- a/crates/yaak-git/src/merge.rs +++ /dev/null @@ -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() -} diff --git a/crates/yaak-git/src/util.rs b/crates/yaak-git/src/util.rs index 0bee2fb5..6aa5c068 100644 --- a/crates/yaak-git/src/util.rs +++ b/crates/yaak-git/src/util.rs @@ -47,10 +47,6 @@ pub(crate) fn remote_branch_names(repo: &Repository) -> Result> { Ok(branches) } -pub(crate) fn get_branch_by_name<'s>(repo: &'s Repository, name: &str) -> Result> { - Ok(repo.find_branch(name, BranchType::Local)?) -} - pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result { Ok(String::from_utf8(bytes.to_vec())?) } diff --git a/src-web/components/CloneGitRepositoryDialog.tsx b/src-web/components/CloneGitRepositoryDialog.tsx new file mode 100644 index 00000000..9804dd9d --- /dev/null +++ b/src-web/components/CloneGitRepositoryDialog.tsx @@ -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(''); + const [baseDirectory, setBaseDirectory] = useState(appInfo.defaultProjectDir); + const [directoryOverride, setDirectoryOverride] = useState(null); + const [hasSubdirectory, setHasSubdirectory] = useState(false); + const [subdirectory, setSubdirectory] = useState(''); + const [isCloning, setIsCloning] = useState(false); + const [error, setError] = useState(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 ( + + {error && ( + + {error} + + )} + + + + + } + /> + + + + {hasSubdirectory && ( + + )} + + + + ); +} + +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 ''; +} diff --git a/src-web/components/WorkspaceActionsDropdown.tsx b/src-web/components/WorkspaceActionsDropdown.tsx index 4a1799aa..ef613dfc 100644 --- a/src-web/components/WorkspaceActionsDropdown.tsx +++ b/src-web/components/WorkspaceActionsDropdown.tsx @@ -18,6 +18,7 @@ import { useWorkspaceActions } from '../hooks/useWorkspaceActions'; import { showDialog } from '../lib/dialog'; import { jotaiStore } from '../lib/jotai'; import { revealInFinderText } from '../lib/reveal'; +import { CloneGitRepositoryDialog } from './CloneGitRepositoryDialog'; import type { ButtonProps } from './core/Button'; import { Button } from './core/Button'; import type { DropdownItem } from './core/Dropdown'; @@ -39,9 +40,19 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({ const { mutate: deleteSendHistory } = useDeleteSendHistory(); const workspaceActions = useWorkspaceActions(); - const { workspaceItems, itemsAfter } = useMemo<{ + const openCloneGitRepositoryDialog = useCallback(() => { + showDialog({ + id: 'clone-git-repository', + size: 'md', + title: 'Clone Git Repository', + render: ({ hide }) => , + }); + }, []); + + const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{ workspaceItems: RadioDropdownItem[]; itemsAfter: DropdownItem[]; + itemsBefore: DropdownItem[]; }>(() => { const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({ key: w.id, @@ -50,6 +61,38 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({ leftSlot: w.id === workspace?.id ? : , })); + const itemsBefore: DropdownItem[] = [ + { + label: 'New Workspace', + leftSlot: , + submenu: [ + { + label: 'Create Empty', + leftSlot: , + onSelect: createWorkspace, + }, + { + label: 'Open Folder', + leftSlot: , + 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: , + onSelect: openCloneGitRepositoryDialog, + }, + ], + }, + ]; const itemsAfter: DropdownItem[] = [ ...workspaceActions.map((a) => ({ label: a.label, @@ -80,34 +123,15 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({ leftSlot: , onSelect: deleteSendHistory, }, - { type: 'separator' }, - { - label: 'New Workspace', - leftSlot: , - onSelect: createWorkspace, - }, - { - label: 'Open Existing Workspace', - leftSlot: , - 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, workspaceMeta, deleteSendHistory, createWorkspace, + openCloneGitRepositoryDialog, workspace?.id, workspace, workspaceActions.map, @@ -144,6 +168,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({ diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 0a50fe1e..6de614fb 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -66,6 +66,8 @@ export type DropdownItemDefault = { keepOpenOnSelect?: boolean; onSelect?: () => void | Promise; submenu?: DropdownItem[]; + /** If true, submenu opens on click instead of hover */ + submenuOpenOnClick?: boolean; icon?: IconProps['icon']; }; @@ -272,6 +274,7 @@ interface MenuProps { defaultSelectedIndex: number | null; triggerShape: Pick | null; onClose: () => void; + onCloseAll?: () => void; showTriangle?: boolean; fullWidth?: boolean; isOpen: boolean; @@ -288,6 +291,7 @@ const Menu = forwardRef(''); + + // Clear filter when menu opens + useEffect(() => { + if (isOpen) { + setFilter(''); + } + }, [isOpen]); + const [activeSubmenu, setActiveSubmenu] = useState<{ item: DropdownItemDefault; parent: HTMLButtonElement; @@ -320,10 +333,18 @@ const Menu = forwardRef { onClose(); - setFilter(''); setActiveSubmenu(null); }, [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) const handleMenuKeyDown = (e: ReactKeyboardEvent) => { // Skip if this menu has a submenu open - let the submenu handle typing @@ -393,6 +414,14 @@ const Menu = forwardRef