Compare commits

...

6 Commits

Author SHA1 Message Date
Gregory Schier
bd7e840a57 Fix x64 macOS build bundling wrong architecture binaries
Set YAAK_TARGET_ARCH before npm run bootstrap so vendor scripts
download the correct x64 binaries instead of arm64 ones.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:00:59 -08:00
Gregory Schier
8969748c3c Add option to disable encryption when key is forgotten (#371) 2026-01-26 15:40:02 -08:00
Gregory Schier
4e15ac10a6 Add folder CRUD operations to MCP server (#369)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:08:24 -08:00
Gregory Schier
47a3d44888 Git branch flow improvements (#370) 2026-01-26 14:45:51 -08:00
Gregory Schier
eb10910d20 Update HttpMethodTag.tsx 2026-01-22 06:03:04 -08:00
Gregory Schier
6ba83d424d Fix request method dropdown for GraphQL not showing HTTP method 2026-01-22 06:02:49 -08:00
32 changed files with 1216 additions and 561 deletions

View File

@@ -89,6 +89,8 @@ jobs:
- run: npm ci
- run: npm run bootstrap
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
- run: npm run lint
- name: Run JS Tests
run: npm test

View File

@@ -100,6 +100,15 @@ pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
Ok(())
}
#[command]
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<()> {
window.crypto().disable_encryption(workspace_id)?;
Ok(())
}
#[command]
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
default_headers()

View File

@@ -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<String> {
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<bool>,
) -> Result<BranchDeleteResult> {
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<CloneResult> {
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<PathBuf>) -> 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]

View File

@@ -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<AppMetaData> {
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<AppMetaData> {
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"),
})
@@ -1719,6 +1722,7 @@ pub fn run() {
// Migrated commands
crate::commands::cmd_decrypt_template,
crate::commands::cmd_default_headers,
crate::commands::cmd_disable_encryption,
crate::commands::cmd_enable_encryption,
crate::commands::cmd_get_themes,
crate::commands::cmd_reveal_workspace_key,
@@ -1747,10 +1751,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,

View File

@@ -11,3 +11,7 @@ export function revealWorkspaceKey(workspaceId: string) {
export function setWorkspaceKey(args: { workspaceId: string; key: string }) {
return invoke<void>('cmd_set_workspace_key', args);
}
export function disableEncryption(workspaceId: string) {
return invoke<void>('cmd_disable_encryption', { workspaceId });
}

View File

@@ -115,6 +115,35 @@ impl EncryptionManager {
self.set_workspace_key(workspace_id, &wkey)
}
pub fn disable_encryption(&self, workspace_id: &str) -> Result<()> {
info!("Disabling encryption for {workspace_id}");
self.query_manager.with_tx::<(), Error>(|tx| {
let workspace = tx.get_workspace(workspace_id)?;
let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;
// Clear encryption challenge on workspace
tx.upsert_workspace(
&Workspace { encryption_key_challenge: None, ..workspace },
&UpdateSource::Background,
)?;
// Clear encryption key on workspace meta
tx.upsert_workspace_meta(
&WorkspaceMeta { encryption_key: None, ..workspace_meta },
&UpdateSource::Background,
)?;
Ok(())
})?;
// Remove from cache
let mut cache = self.cached_workspace_keys.lock().unwrap();
cache.remove(workspace_id);
Ok(())
}
fn get_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceKey> {
{
let cache = self.cached_workspace_keys.lock().unwrap();

View File

@@ -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, };

View File

@@ -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<void, string, { branch: string }>({
createBranch: createFastMutation<void, string, { branch: string; base?: string }>({
mutationKey: ['git', 'branch', dir],
mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }),
onSuccess,
}),
mergeBranch: createFastMutation<void, string, { branch: string; force: boolean }>({
mergeBranch: createFastMutation<void, string, { branch: string }>({
mutationKey: ['git', 'merge', dir],
mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }),
onSuccess,
}),
deleteBranch: createFastMutation<void, string, { branch: string }>({
deleteBranch: createFastMutation<BranchDeleteResult, string, { branch: string, force?: boolean }>({
mutationKey: ['git', 'delete-branch', dir],
mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }),
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 }>({
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<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 });
}

View File

@@ -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<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
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<Command> {
}
// 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)
}

View File

@@ -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<String> {
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<String> {
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<String> {
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<BranchDeleteResult> {
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(())
}

View 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)
}

View File

@@ -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())

View File

@@ -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;

View File

@@ -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()
}

View File

@@ -47,10 +47,6 @@ pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
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> {
Ok(String::from_utf8(bytes.to_vec())?)
}

View File

@@ -25,7 +25,7 @@ import type {
TemplateRenderRequest,
WorkspaceInfo,
} from '../bindings/gen_events.ts';
import type { HttpRequest } from '../bindings/gen_models.ts';
import type { Folder, HttpRequest } from '../bindings/gen_models.ts';
import type { JsonValue } from '../bindings/serde_json/JsonValue';
export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>;
@@ -82,6 +82,15 @@ export interface Context {
};
folder: {
list(args?: ListFoldersRequest): Promise<ListFoldersResponse['folders']>;
getById(args: { id: string }): Promise<Folder | null>;
create(
args: Omit<Partial<Folder>, 'id' | 'model' | 'createdAt' | 'updatedAt'> &
Pick<Folder, 'workspaceId' | 'name'>,
): Promise<Folder>;
update(
args: Omit<Partial<Folder>, 'model' | 'createdAt' | 'updatedAt'> & Pick<Folder, 'id'>,
): Promise<Folder>;
delete(args: { id: string }): Promise<Folder>;
};
httpResponse: {
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse['httpResponses']>;

View File

@@ -11,6 +11,7 @@ import type {
DeleteKeyValueResponse,
DeleteModelResponse,
FindHttpResponsesResponse,
Folder,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
@@ -782,6 +783,44 @@ export class PluginInstance {
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders;
},
getById: async (args: { id: string }) => {
const payload = { type: 'list_folders_request' } as const;
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders.find((f) => f.id === args.id) ?? null;
},
create: async (args) => {
const payload = {
type: 'upsert_model_request',
model: {
name: '',
...args,
id: '',
model: 'folder',
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
update: async (args) => {
const payload = {
type: 'upsert_model_request',
model: {
model: 'folder',
...args,
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
delete: async (args: { id: string }) => {
const payload = {
type: 'delete_model_request',
model: 'folder',
id: args.id,
} as InternalEventPayload;
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
return response.model as Folder;
},
},
cookies: {
getValue: async (args: GetCookieValueRequest) => {

View File

@@ -2,6 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod';
import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js';
import {
authenticationSchema,
authenticationTypeSchema,
headersSchema,
workspaceIdSchema,
} from './schemas.js';
export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
server.registerTool(
@@ -10,10 +16,7 @@ export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
title: 'List Folders',
description: 'List all folders in a workspace',
inputSchema: {
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
workspaceId: workspaceIdSchema,
},
},
async ({ workspaceId }) => {
@@ -30,4 +33,116 @@ export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
};
},
);
server.registerTool(
'get_folder',
{
title: 'Get Folder',
description: 'Get details of a specific folder by ID',
inputSchema: {
id: z.string().describe('The folder ID'),
workspaceId: workspaceIdSchema,
},
},
async ({ id, workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
const folder = await workspaceCtx.yaak.folder.getById({ id });
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(folder, null, 2),
},
],
};
},
);
server.registerTool(
'create_folder',
{
title: 'Create Folder',
description: 'Create a new folder in a workspace',
inputSchema: {
workspaceId: workspaceIdSchema,
name: z.string().describe('Folder name'),
folderId: z.string().optional().describe('Parent folder ID (for nested folders)'),
description: z.string().optional().describe('Folder description'),
sortPriority: z.number().optional().describe('Sort priority for ordering'),
headers: headersSchema.describe('Default headers to apply to requests in this folder'),
authenticationType: authenticationTypeSchema,
authentication: authenticationSchema,
},
},
async ({ workspaceId: ogWorkspaceId, ...args }) => {
const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);
const workspaceId = await workspaceCtx.yaak.window.workspaceId();
if (!workspaceId) {
throw new Error('No workspace is open');
}
const folder = await workspaceCtx.yaak.folder.create({
workspaceId: workspaceId,
...args,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
};
},
);
server.registerTool(
'update_folder',
{
title: 'Update Folder',
description: 'Update an existing folder',
inputSchema: {
id: z.string().describe('Folder ID to update'),
workspaceId: workspaceIdSchema,
name: z.string().optional().describe('Folder name'),
folderId: z.string().optional().describe('Parent folder ID (for nested folders)'),
description: z.string().optional().describe('Folder description'),
sortPriority: z.number().optional().describe('Sort priority for ordering'),
headers: headersSchema.describe('Default headers to apply to requests in this folder'),
authenticationType: authenticationTypeSchema,
authentication: authenticationSchema,
},
},
async ({ id, workspaceId, ...updates }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
// Fetch existing folder to merge with updates
const existing = await workspaceCtx.yaak.folder.getById({ id });
if (!existing) {
throw new Error(`Folder with ID ${id} not found`);
}
// Merge existing fields with updates
const folder = await workspaceCtx.yaak.folder.update({
...existing,
...updates,
id,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
};
},
);
server.registerTool(
'delete_folder',
{
title: 'Delete Folder',
description: 'Delete a folder by ID',
inputSchema: {
id: z.string().describe('Folder ID to delete'),
},
},
async ({ id }) => {
const folder = await ctx.yaak.folder.delete({ id });
return {
content: [{ type: 'text' as const, text: `Deleted: ${folder.name} (${folder.id})` }],
};
},
);
}

View File

@@ -2,6 +2,15 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod';
import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js';
import {
authenticationSchema,
authenticationTypeSchema,
bodySchema,
bodyTypeSchema,
headersSchema,
urlParametersSchema,
workspaceIdSchema,
} from './schemas.js';
export function registerHttpRequestTools(server: McpServer, ctx: McpServerContext) {
server.registerTool(
@@ -10,10 +19,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
title: 'List HTTP Requests',
description: 'List all HTTP requests in a workspace',
inputSchema: {
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
workspaceId: workspaceIdSchema,
},
},
async ({ workspaceId }) => {
@@ -38,10 +44,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
description: 'Get details of a specific HTTP request by ID',
inputSchema: {
id: z.string().describe('The HTTP request ID'),
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
workspaceId: workspaceIdSchema,
},
},
async ({ id, workspaceId }) => {
@@ -67,10 +70,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
inputSchema: {
id: z.string().describe('The HTTP request ID to send'),
environmentId: z.string().optional().describe('Optional environment ID to use'),
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
workspaceId: workspaceIdSchema,
},
},
async ({ id, workspaceId }) => {
@@ -99,10 +99,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
title: 'Create HTTP Request',
description: 'Create a new HTTP request',
inputSchema: {
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
workspaceId: workspaceIdSchema,
name: z
.string()
.optional()
@@ -111,62 +108,12 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
method: z.string().optional().describe('HTTP method (defaults to GET)'),
folderId: z.string().optional().describe('Parent folder ID'),
description: z.string().optional().describe('Request description'),
headers: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('Request headers'),
urlParameters: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters'),
bodyType: z
.string()
.optional()
.describe(
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
),
body: z
.record(z.string(), z.any())
.optional()
.describe(
'Body content object. Structure varies by bodyType:\n' +
'- "binary": { filePath: "/path/to/file" }\n' +
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
'- text-based (application/json, etc.): { text: "raw body content" }',
),
authenticationType: z
.string()
.optional()
.describe(
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent folder/workspace.',
),
authentication: z
.record(z.string(), z.any())
.optional()
.describe(
'Authentication configuration object. Structure varies by authenticationType:\n' +
'- "basic": { username: "user", password: "pass" }\n' +
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
),
headers: headersSchema.describe('Request headers'),
urlParameters: urlParametersSchema,
bodyType: bodyTypeSchema,
body: bodySchema,
authenticationType: authenticationTypeSchema,
authentication: authenticationSchema,
},
},
async ({ workspaceId: ogWorkspaceId, ...args }) => {
@@ -194,68 +141,18 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
description: 'Update an existing HTTP request',
inputSchema: {
id: z.string().describe('HTTP request ID to update'),
workspaceId: z.string().describe('Workspace ID'),
workspaceId: workspaceIdSchema,
name: z.string().optional().describe('Request name'),
url: z.string().optional().describe('Request URL'),
method: z.string().optional().describe('HTTP method'),
folderId: z.string().optional().describe('Parent folder ID'),
description: z.string().optional().describe('Request description'),
headers: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('Request headers'),
urlParameters: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters'),
bodyType: z
.string()
.optional()
.describe(
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
),
body: z
.record(z.string(), z.any())
.optional()
.describe(
'Body content object. Structure varies by bodyType:\n' +
'- "binary": { filePath: "/path/to/file" }\n' +
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
'- text-based (application/json, etc.): { text: "raw body content" }',
),
authenticationType: z
.string()
.optional()
.describe(
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent folder/workspace.',
),
authentication: z
.record(z.string(), z.any())
.optional()
.describe(
'Authentication configuration object. Structure varies by authenticationType:\n' +
'- "basic": { username: "user", password: "pass" }\n' +
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
),
headers: headersSchema.describe('Request headers'),
urlParameters: urlParametersSchema,
bodyType: bodyTypeSchema,
body: bodySchema,
authenticationType: authenticationTypeSchema,
authentication: authenticationSchema,
},
},
async ({ id, workspaceId, ...updates }) => {

View File

@@ -0,0 +1,67 @@
import * as z from 'zod';
export const workspaceIdSchema = z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)');
export const headersSchema = z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional();
export const urlParametersSchema = z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters');
export const bodyTypeSchema = z
.string()
.optional()
.describe(
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
);
export const bodySchema = z
.record(z.string(), z.any())
.optional()
.describe(
'Body content object. Structure varies by bodyType:\n' +
'- "binary": { filePath: "/path/to/file" }\n' +
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
'- text-based (application/json, etc.): { text: "raw body content" }',
);
export const authenticationTypeSchema = z
.string()
.optional()
.describe(
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent.',
);
export const authenticationSchema = z
.record(z.string(), z.any())
.optional()
.describe(
'Authentication configuration object. Structure varies by authenticationType:\n' +
'- "basic": { username: "user", password: "pass" }\n' +
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
);

View 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 '';
}

View File

@@ -71,7 +71,7 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
onChange={handleChange}
>
<Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}>
<HttpMethodTag request={request} />
<HttpMethodTag request={request} noAlias />
</Button>
</RadioDropdown>
);

View File

@@ -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 }) => <CloneGitRepositoryDialog hide={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 ? <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[] = [
...workspaceActions.map((a) => ({
label: a.label,
@@ -80,34 +123,15 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="history" />,
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,
workspaceMeta,
deleteSendHistory,
createWorkspace,
openCloneGitRepositoryDialog,
workspace?.id,
workspace,
workspaceActions.map,
@@ -144,6 +168,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
<RadioDropdown
items={workspaceItems}
itemsAfter={itemsAfter}
itemsBefore={itemsBefore}
onChange={handleSwitchWorkspace}
value={workspace?.id ?? null}
>

View File

@@ -1,4 +1,9 @@
import { enableEncryption, revealWorkspaceKey, setWorkspaceKey } from '@yaakapp-internal/crypto';
import {
disableEncryption,
enableEncryption,
revealWorkspaceKey,
setWorkspaceKey,
} from '@yaakapp-internal/crypto';
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
@@ -6,6 +11,7 @@ import { useEffect, useState } from 'react';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { showConfirm } from '../lib/confirm';
import { CopyIconButton } from './CopyIconButton';
import { Banner } from './core/Banner';
import type { ButtonProps } from './core/Button';
@@ -69,6 +75,9 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
onDone?.();
onEnabledEncryption?.();
}}
onDisabled={() => {
onDone?.();
}}
/>
);
}
@@ -109,6 +118,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
return (
<div className="mb-auto flex flex-col-reverse">
<Button
className="mt-3"
color={expanded ? 'info' : 'secondary'}
size={size}
onClick={async () => {
@@ -149,13 +159,39 @@ const setWorkspaceKeyMut = createFastMutation({
function EnterWorkspaceKey({
workspaceMeta,
onEnabled,
onDisabled,
error,
}: {
workspaceMeta: WorkspaceMeta;
onEnabled?: () => void;
onDisabled?: () => void;
error?: string | null;
}) {
const [key, setKey] = useState<string>('');
const handleForgotKey = async () => {
const confirmed = await showConfirm({
id: 'disable-encryption',
title: 'Disable Encryption',
color: 'danger',
confirmText: 'Disable Encryption',
description: (
<>
This will disable encryption for this workspace. Any previously encrypted values will fail
to decrypt and will need to be re-entered manually.
<br />
<br />
This action cannot be undone.
</>
),
});
if (confirmed) {
await disableEncryption(workspaceMeta.workspaceId);
onDisabled?.();
}
};
return (
<VStack space={4} className="w-full">
{error ? (
@@ -192,6 +228,13 @@ function EnterWorkspaceKey({
Submit
</Button>
</HStack>
<button
type="button"
onClick={handleForgotKey}
className="text-text-subtlest text-sm hover:text-text-subtle"
>
Forgot your key?
</button>
</VStack>
);
}

View File

@@ -66,6 +66,8 @@ export type DropdownItemDefault = {
keepOpenOnSelect?: boolean;
onSelect?: () => void | Promise<void>;
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<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
onClose: () => void;
onCloseAll?: () => void;
showTriangle?: boolean;
fullWidth?: boolean;
isOpen: boolean;
@@ -288,6 +291,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
items,
fullWidth,
onClose,
onCloseAll,
triggerShape,
defaultSelectedIndex,
showTriangle,
@@ -300,7 +304,16 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
defaultSelectedIndex ?? -1,
[defaultSelectedIndex],
);
const [filter, setFilter] = useState<string>('');
// 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<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
const handleClose = useCallback(() => {
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<HTMLDivElement>) => {
// 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],
);
// 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(
'ArrowUp',
(e) => {
@@ -433,7 +462,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
);
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;
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, () => {
@@ -476,17 +511,23 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
const parentRect = triggerShape;
const docRect = document.documentElement.getBoundingClientRect();
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
// 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 {
upsideDown: false,
upsideDown: openUpward,
container: {
top: parentRect.top,
top: openUpward ? undefined : parentRect.top,
bottom: openUpward ? docRect.height - parentRect.bottom : undefined,
left: openLeft ? undefined : parentRect.right,
right: openLeft ? docRect.width - parentRect.left : undefined,
},
menu: {
maxHeight: `${docRect.height - parentRect.top - 20}px`,
maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`,
},
triangle: {}, // No triangle for submenus
};
@@ -586,7 +627,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
clearTimeout(submenuTimeoutRef.current);
}
if (item.submenu) {
if (item.submenu && !item.submenuOpenOnClick) {
setActiveSubmenu({ item, parent });
} else if (activeSubmenu) {
submenuTimeoutRef.current = window.setTimeout(() => {
@@ -759,6 +800,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
items={activeSubmenu.item.submenu ?? []}
defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null}
onClose={() => setActiveSubmenu(null)}
onCloseAll={handleCloseAll}
triggerShape={submenuTriggerShape}
/>
</div>
@@ -804,7 +846,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
interface MenuItemProps {
className?: string;
item: DropdownItemDefault;
onSelect: (item: DropdownItemDefault) => Promise<void>;
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
onFocus: (item: DropdownItemDefault) => void;
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
focused: boolean;
@@ -824,7 +866,7 @@ function MenuItem({
const [isLoading, setIsLoading] = useState(false);
const handleClick = useCallback(async () => {
if (item.waitForOnSelect) setIsLoading(true);
await onSelect?.(item);
await onSelect?.(item, buttonRef.current ?? undefined);
if (item.waitForOnSelect) setIsLoading(false);
}, [item, onSelect]);
@@ -854,7 +896,7 @@ function MenuItem({
};
const rightSlot = item.submenu ? (
<Icon icon="chevron_right" />
<Icon icon="chevron_right" color='secondary' />
) : (
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />)
);

View File

@@ -8,6 +8,7 @@ interface Props {
request: HttpRequest | GrpcRequest | WebsocketRequest;
className?: string;
short?: boolean;
noAlias?: boolean;
}
const methodNames: Record<string, string> = {
@@ -24,9 +25,9 @@ const methodNames: Record<string, string> = {
websocket: 'WS',
};
export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short }: Props) {
export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short, noAlias }: Props) {
const method =
request.model === 'http_request' && request.bodyType === 'graphql'
request.model === 'http_request' && (request.bodyType === 'graphql' && !noAlias)
? 'graphql'
: request.model === 'grpc_request'
? 'grpc'

View File

@@ -17,7 +17,6 @@ import type { DropdownItem } from '../core/Dropdown';
import { Dropdown } from '../core/Dropdown';
import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode';
import { BranchSelectionDialog } from './BranchSelectionDialog';
import { gitCallbacks } from './callbacks';
import { GitCommitDialog } from './GitCommitDialog';
import { GitRemotesDialog } from './GitRemotesDialog';
@@ -39,7 +38,18 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom);
const [
{ status, log },
{ branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init },
{
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
fetchAll,
mergeBranch,
push,
pull,
checkout,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir));
const localBranches = status.data?.localBranches ?? [];
@@ -47,8 +57,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const remoteOnlyBranches = remoteBranches.filter(
(b) => !localBranches.includes(b.replace(/^origin\//, '')),
);
const currentBranch = status.data?.headRefShorthand ?? 'UNKNOWN';
if (workspace == null) {
return null;
}
@@ -58,6 +66,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
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) => {
checkout.mutate(
{ branch, force },
@@ -104,7 +119,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const items: DropdownItem[] = [
{
label: 'View History',
label: 'View History...',
hidden: (log.data ?? []).length === 0,
leftSlot: <Icon icon="history" />,
onSelect: async () => {
@@ -118,13 +133,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
},
{
label: 'Manage Remotes',
label: 'Manage Remotes...',
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: () => GitRemotesDialog.show(syncDir),
},
{ type: 'separator' },
{
label: 'New Branch',
label: 'New Branch...',
leftSlot: <Icon icon="git_branch_plus" />,
async onSelect() {
const name = await showPrompt({
@@ -134,7 +149,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
});
if (!name) return;
await branch.mutateAsync(
await createBranch.mutateAsync(
{ branch: name },
{
disableToastError: true,
@@ -150,95 +165,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
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' },
{
label: 'Push',
@@ -278,7 +204,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
},
},
{
label: 'Commit',
label: 'Commit...',
leftSlot: <Icon icon="git_commit_vertical" />,
onSelect() {
showDialog({
@@ -298,16 +224,239 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
return {
label: branch,
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) => {
const isCurrent = currentBranch === branch;
return {
label: branch,
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;
}),
];

View File

@@ -1,7 +1,5 @@
import type { GitCallbacks } from '@yaakapp-internal/git';
import { showPromptForm } from '../../lib/prompt-form';
import { Banner } from '../core/Banner';
import { InlineCode } from '../core/InlineCode';
import { promptCredentials } from './credentials';
import { addGitRemote } from './showAddRemoteDialog';
export function gitCallbacks(dir: string): GitCallbacks {
@@ -9,40 +7,10 @@ export function gitCallbacks(dir: string): GitCallbacks {
addRemote: async () => {
return addGitRemote(dir);
},
promptCredentials: async ({ url: remoteUrl, error }) => {
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) throw new Error('Cancelled credentials prompt');
const username = String(r.username || '');
const password = String(r.password || '');
return { username, password };
promptCredentials: async ({ url, error }) => {
const creds = await promptCredentials({ url, error });
if (creds == null) throw new Error('Cancelled credentials prompt');
return creds;
},
};
}

View 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 };
}

View File

@@ -23,14 +23,17 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
variables: TVariables,
args?: CallbackMutationOptions<TData, TError, TVariables>,
) => {
const { mutationKey, mutationFn, onSuccess, onError, onSettled, disableToastError } = {
const { mutationKey, mutationFn, disableToastError } = {
...defaultArgs,
...args,
};
try {
const data = await mutationFn(variables);
onSuccess?.(data);
onSettled?.();
// Run both default and custom onSuccess callbacks
defaultArgs.onSuccess?.(data);
args?.onSuccess?.(data);
defaultArgs.onSettled?.();
args?.onSettled?.();
return data;
} catch (err: unknown) {
const stringKey = mutationKey.join('.');
@@ -44,8 +47,11 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
timeout: 5000,
});
}
onError?.(e);
onSettled?.();
// Run both default and custom onError callbacks
defaultArgs.onError?.(e);
args?.onError?.(e);
defaultArgs.onSettled?.();
args?.onSettled?.();
throw e;
}
};

View File

@@ -8,6 +8,7 @@ export interface AppInfo {
appDataDir: string;
appLogDir: string;
vendoredPluginDir: string;
defaultProjectDir: string;
identifier: string;
featureLicense: boolean;
featureUpdater: boolean;

View File

@@ -25,6 +25,8 @@ type TauriCmd =
| 'cmd_get_sse_events'
| 'cmd_get_themes'
| 'cmd_get_workspace_meta'
| 'cmd_git_add_credential'
| 'cmd_git_clone'
| 'cmd_grpc_go'
| 'cmd_grpc_reflect'
| 'cmd_grpc_request_actions'