mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 09:08:32 +02:00
Move a bunch of git ops to use the git binary (#302)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
pub mod window;
|
pub mod window;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod api_client;
|
pub mod api_client;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { SyncModel } from "./gen_models.js";
|
import type { SyncModel } from "./gen_models";
|
||||||
|
|
||||||
export type GitAuthor = { name: string | null, email: string | null, };
|
export type GitAuthor = { name: string | null, email: string | null, };
|
||||||
|
|
||||||
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
||||||
|
|
||||||
|
export type GitRemote = { name: string, url: string | null, };
|
||||||
|
|
||||||
export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change";
|
export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change";
|
||||||
|
|
||||||
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
|
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
|
||||||
|
|
||||||
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, };
|
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, };
|
||||||
|
|
||||||
export type PullResult = { receivedBytes: number, receivedObjects: number, };
|
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||||
|
|
||||||
export type PushResult = "success" | "nothing_to_push";
|
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||||
|
|
||||||
export type PushType = "branch" | "tag";
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const COMMANDS: &[&str] = &[
|
const COMMANDS: &[&str] = &[
|
||||||
"add",
|
"add",
|
||||||
|
"add_credential",
|
||||||
|
"add_remote",
|
||||||
"branch",
|
"branch",
|
||||||
"checkout",
|
"checkout",
|
||||||
"commit",
|
"commit",
|
||||||
@@ -10,6 +12,8 @@ const COMMANDS: &[&str] = &[
|
|||||||
"merge_branch",
|
"merge_branch",
|
||||||
"pull",
|
"pull",
|
||||||
"push",
|
"push",
|
||||||
|
"remotes",
|
||||||
|
"rm_remote",
|
||||||
"status",
|
"status",
|
||||||
"unstage",
|
"unstage",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,96 +1,168 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { GitCommit, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
|
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';
|
||||||
|
|
||||||
export * from './bindings/gen_git';
|
export * from './bindings/gen_git';
|
||||||
|
|
||||||
export function useGit(dir: string) {
|
export interface GitCredentials {
|
||||||
const queryClient = useQueryClient();
|
username: string;
|
||||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitCallbacks {
|
||||||
|
addRemote: () => Promise<GitRemote | null>;
|
||||||
|
promptCredentials: (
|
||||||
|
result: Extract<PushResult, { type: 'needs_credentials' }>,
|
||||||
|
) => Promise<GitCredentials | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
|
||||||
|
|
||||||
|
export function useGit(dir: string, callbacks: GitCallbacks) {
|
||||||
|
const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
log: useQuery<void, string, GitCommit[]>({
|
remotes: useQuery<GitRemote[], string>({
|
||||||
|
queryKey: ['git', 'remotes', dir],
|
||||||
|
queryFn: () => getRemotes(dir),
|
||||||
|
}),
|
||||||
|
log: useQuery<GitCommit[], string>({
|
||||||
queryKey: ['git', 'log', dir],
|
queryKey: ['git', 'log', dir],
|
||||||
queryFn: () => invoke('plugin:yaak-git|log', { dir }),
|
queryFn: () => invoke('plugin:yaak-git|log', { dir }),
|
||||||
}),
|
}),
|
||||||
status: useQuery<void, string, GitStatusSummary>({
|
status: useQuery<GitStatusSummary, string>({
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
queryKey: ['git', 'status', dir],
|
queryKey: ['git', 'status', dir],
|
||||||
queryFn: () => invoke('plugin:yaak-git|status', { dir }),
|
queryFn: () => invoke('plugin:yaak-git|status', { dir }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
mutations,
|
||||||
add: useMutation<void, string, { relaPaths: string[] }>({
|
|
||||||
mutationKey: ['git', 'add', dir],
|
|
||||||
mutationFn: (args) => invoke('plugin:yaak-git|add', { dir, ...args }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
branch: useMutation<void, string, { branch: string }>({
|
|
||||||
mutationKey: ['git', 'branch', dir],
|
|
||||||
mutationFn: (args) => invoke('plugin:yaak-git|branch', { dir, ...args }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
mergeBranch: useMutation<void, string, { branch: string; force: boolean }>({
|
|
||||||
mutationKey: ['git', 'merge', dir],
|
|
||||||
mutationFn: (args) => invoke('plugin:yaak-git|merge_branch', { dir, ...args }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
deleteBranch: useMutation<void, string, { branch: string }>({
|
|
||||||
mutationKey: ['git', 'delete-branch', dir],
|
|
||||||
mutationFn: (args) => invoke('plugin:yaak-git|delete_branch', { dir, ...args }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
checkout: useMutation<string, string, { branch: string; force: boolean }>({
|
|
||||||
mutationKey: ['git', 'checkout', dir],
|
|
||||||
mutationFn: (args) => invoke('plugin:yaak-git|checkout', { dir, ...args }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
commit: useMutation<void, string, { message: string }>({
|
|
||||||
mutationKey: ['git', 'commit', dir],
|
|
||||||
mutationFn: (args) => invoke('plugin:yaak-git|commit', { dir, ...args }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
commitAndPush: useMutation<PushResult, string, { message: string }>({
|
|
||||||
mutationKey: ['git', 'commit_push', dir],
|
|
||||||
mutationFn: async (args) => {
|
|
||||||
await invoke('plugin:yaak-git|commit', { dir, ...args });
|
|
||||||
return invoke('plugin:yaak-git|push', { dir });
|
|
||||||
},
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
fetchAll: useMutation<string, string, void>({
|
|
||||||
mutationKey: ['git', 'checkout', dir],
|
|
||||||
mutationFn: () => invoke('plugin:yaak-git|fetch_all', { dir }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
push: useMutation<PushResult, string, void>({
|
|
||||||
mutationKey: ['git', 'push', dir],
|
|
||||||
mutationFn: () => invoke('plugin:yaak-git|push', { dir }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
pull: useMutation<PullResult, string, void>({
|
|
||||||
mutationKey: ['git', 'pull', dir],
|
|
||||||
mutationFn: () => invoke('plugin:yaak-git|pull', { dir }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
unstage: useMutation<void, string, { relaPaths: string[] }>({
|
|
||||||
mutationKey: ['git', 'unstage', dir],
|
|
||||||
mutationFn: (args) => invoke('plugin:yaak-git|unstage', { dir, ...args }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
init: useGitInit(),
|
|
||||||
},
|
|
||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGitInit() {
|
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||||
const queryClient = useQueryClient();
|
const push = async () => {
|
||||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
|
const remotes = await getRemotes(dir);
|
||||||
|
if (remotes.length === 0) {
|
||||||
|
const remote = await callbacks.addRemote();
|
||||||
|
if (remote == null) throw new Error('No remote found');
|
||||||
|
}
|
||||||
|
|
||||||
return useMutation<void, string, { dir: string }>({
|
const result = await invoke<PushResult>('plugin:yaak-git|push', { dir });
|
||||||
mutationKey: ['git', 'init'],
|
if (result.type !== 'needs_credentials') return result;
|
||||||
mutationFn: (args) => invoke('plugin:yaak-git|initialize', { ...args }),
|
|
||||||
onSuccess,
|
// Needs credentials, prompt for them
|
||||||
});
|
const creds = await callbacks.promptCredentials(result);
|
||||||
|
if (creds == null) throw new Error('Canceled');
|
||||||
|
|
||||||
|
await invoke('plugin:yaak-git|add_credential', {
|
||||||
|
dir,
|
||||||
|
remoteUrl: result.url,
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push again
|
||||||
|
return invoke<PushResult>('plugin:yaak-git|push', { dir });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: createFastMutation<void, string, void>({
|
||||||
|
mutationKey: ['git', 'init'],
|
||||||
|
mutationFn: () => invoke('plugin:yaak-git|initialize', { dir }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
add: createFastMutation<void, string, { relaPaths: string[] }>({
|
||||||
|
mutationKey: ['git', 'add', dir],
|
||||||
|
mutationFn: (args) => invoke('plugin:yaak-git|add', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
addRemote: createFastMutation<GitRemote, string, GitRemote>({
|
||||||
|
mutationKey: ['git', 'add-remote'],
|
||||||
|
mutationFn: (args) => invoke('plugin:yaak-git|add_remote', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
rmRemote: createFastMutation<void, string, { name: string }>({
|
||||||
|
mutationKey: ['git', 'rm-remote', dir],
|
||||||
|
mutationFn: (args) => invoke('plugin:yaak-git|rm_remote', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
branch: createFastMutation<void, string, { branch: string }>({
|
||||||
|
mutationKey: ['git', 'branch', dir],
|
||||||
|
mutationFn: (args) => invoke('plugin:yaak-git|branch', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
mergeBranch: createFastMutation<void, string, { branch: string; force: boolean }>({
|
||||||
|
mutationKey: ['git', 'merge', dir],
|
||||||
|
mutationFn: (args) => invoke('plugin:yaak-git|merge_branch', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
deleteBranch: createFastMutation<void, string, { branch: string }>({
|
||||||
|
mutationKey: ['git', 'delete-branch', dir],
|
||||||
|
mutationFn: (args) => invoke('plugin:yaak-git|delete_branch', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
checkout: createFastMutation<string, string, { branch: string; force: boolean }>({
|
||||||
|
mutationKey: ['git', 'checkout', dir],
|
||||||
|
mutationFn: (args) => invoke('plugin:yaak-git|checkout', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
commit: createFastMutation<void, string, { message: string }>({
|
||||||
|
mutationKey: ['git', 'commit', dir],
|
||||||
|
mutationFn: (args) => invoke('plugin:yaak-git|commit', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
commitAndPush: createFastMutation<PushResult, string, { message: string }>({
|
||||||
|
mutationKey: ['git', 'commit_push', dir],
|
||||||
|
mutationFn: async (args) => {
|
||||||
|
await invoke('plugin:yaak-git|commit', { dir, ...args });
|
||||||
|
return push();
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
fetchAll: createFastMutation<string, string, void>({
|
||||||
|
mutationKey: ['git', 'checkout', dir],
|
||||||
|
mutationFn: () => invoke('plugin:yaak-git|fetch_all', { dir }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
push: createFastMutation<PushResult, string, void>({
|
||||||
|
mutationKey: ['git', 'push', dir],
|
||||||
|
mutationFn: push,
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
pull: createFastMutation<PullResult, string, void>({
|
||||||
|
mutationKey: ['git', 'pull', dir],
|
||||||
|
async mutationFn() {
|
||||||
|
const result = await invoke<PullResult>('plugin:yaak-git|pull', { dir });
|
||||||
|
if (result.type !== 'needs_credentials') return result;
|
||||||
|
|
||||||
|
// Needs credentials, prompt for them
|
||||||
|
const creds = await callbacks.promptCredentials(result);
|
||||||
|
if (creds == null) throw new Error('Canceled');
|
||||||
|
|
||||||
|
await invoke('plugin:yaak-git|add_credential', {
|
||||||
|
dir,
|
||||||
|
remoteUrl: result.url,
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pull again
|
||||||
|
return invoke<PullResult>('plugin:yaak-git|pull', { dir });
|
||||||
|
},
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
unstage: createFastMutation<void, string, { relaPaths: string[] }>({
|
||||||
|
mutationKey: ['git', 'unstage', dir],
|
||||||
|
mutationFn: (args) => invoke('plugin:yaak-git|unstage', { dir, ...args }),
|
||||||
|
onSuccess,
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getRemotes(dir: string) {
|
||||||
|
return invoke<GitRemote[]>('plugin:yaak-git|remotes', { dir });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
description = "Default permissions for the plugin"
|
description = "Default permissions for the plugin"
|
||||||
permissions = [
|
permissions = [
|
||||||
"allow-add",
|
"allow-add",
|
||||||
|
"allow-add-credential",
|
||||||
|
"allow-add-remote",
|
||||||
"allow-branch",
|
"allow-branch",
|
||||||
"allow-checkout",
|
"allow-checkout",
|
||||||
"allow-commit",
|
"allow-commit",
|
||||||
@@ -12,6 +14,8 @@ permissions = [
|
|||||||
"allow-merge-branch",
|
"allow-merge-branch",
|
||||||
"allow-pull",
|
"allow-pull",
|
||||||
"allow-push",
|
"allow-push",
|
||||||
|
"allow-remotes",
|
||||||
|
"allow-rm-remote",
|
||||||
"allow-status",
|
"allow-status",
|
||||||
"allow-unstage",
|
"allow-unstage",
|
||||||
]
|
]
|
||||||
|
|||||||
16
src-tauri/yaak-git/src/add.rs
Normal file
16
src-tauri/yaak-git/src/add.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use crate::error::Result;
|
||||||
|
use crate::repository::open_repo;
|
||||||
|
use git2::IndexAddOption;
|
||||||
|
use log::info;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub(crate) fn git_add(dir: &Path, rela_path: &Path) -> Result<()> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
let mut index = repo.index()?;
|
||||||
|
|
||||||
|
info!("Staging file {rela_path:?} to {dir:?}");
|
||||||
|
index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?;
|
||||||
|
index.write()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
16
src-tauri/yaak-git/src/binary.rs
Normal file
16
src-tauri/yaak-git/src/binary.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use crate::error::Error::GitNotFound;
|
||||||
|
use crate::error::Result;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub(crate) fn new_binary_command(dir: &Path) -> Result<Command> {
|
||||||
|
let status = Command::new("git").arg("--version").status();
|
||||||
|
|
||||||
|
if let Err(_) = status {
|
||||||
|
return Err(GitNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.arg("-C").arg(dir);
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
@@ -2,26 +2,12 @@ use crate::error::Error::GenericError;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::merge::do_merge;
|
use crate::merge::do_merge;
|
||||||
use crate::repository::open_repo;
|
use crate::repository::open_repo;
|
||||||
use crate::util::{
|
use crate::util::{bytes_to_string, get_branch_by_name, get_current_branch};
|
||||||
bytes_to_string, get_branch_by_name, get_current_branch, get_default_remote_for_push_in_repo,
|
use git2::BranchType;
|
||||||
};
|
|
||||||
use git2::build::CheckoutBuilder;
|
use git2::build::CheckoutBuilder;
|
||||||
use git2::{BranchType, Repository};
|
|
||||||
use log::info;
|
use log::info;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub(crate) fn branch_set_upstream_after_push(repo: &Repository, branch_name: &str) -> Result<()> {
|
|
||||||
let mut branch = repo.find_branch(branch_name, BranchType::Local)?;
|
|
||||||
|
|
||||||
if branch.upstream().is_err() {
|
|
||||||
let remote = get_default_remote_for_push_in_repo(repo)?;
|
|
||||||
let upstream_name = format!("{remote}/{branch_name}");
|
|
||||||
branch.set_upstream(Some(upstream_name.as_str()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
|
pub(crate) fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
|
||||||
if branch_name.starts_with("origin/") {
|
if branch_name.starts_with("origin/") {
|
||||||
return git_checkout_remote_branch(dir, branch_name, force);
|
return git_checkout_remote_branch(dir, branch_name, force);
|
||||||
@@ -43,7 +29,11 @@ pub(crate) fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) ->
|
|||||||
Ok(branch_name.to_string())
|
Ok(branch_name.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn git_checkout_remote_branch(dir: &Path, branch_name: &str, force: bool) -> Result<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 branch_name = branch_name.trim_start_matches("origin/");
|
||||||
let repo = open_repo(dir)?;
|
let repo = open_repo(dir)?;
|
||||||
|
|
||||||
@@ -55,7 +45,7 @@ pub(crate) fn git_checkout_remote_branch(dir: &Path, branch_name: &str, force: b
|
|||||||
let upstream_name = format!("origin/{}", branch_name);
|
let upstream_name = format!("origin/{}", branch_name);
|
||||||
new_branch.set_upstream(Some(&upstream_name))?;
|
new_branch.set_upstream(Some(&upstream_name))?;
|
||||||
|
|
||||||
return git_checkout_branch(dir, branch_name, force)
|
git_checkout_branch(dir, branch_name, force)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn git_create_branch(dir: &Path, name: &str) -> Result<()> {
|
pub(crate) fn git_create_branch(dir: &Path, name: &str) -> Result<()> {
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
use git2::{Cred, RemoteCallbacks};
|
|
||||||
use log::{debug, info};
|
|
||||||
use crate::util::find_ssh_key;
|
|
||||||
|
|
||||||
pub(crate) fn default_callbacks<'s>() -> RemoteCallbacks<'s> {
|
|
||||||
let mut callbacks = RemoteCallbacks::new();
|
|
||||||
|
|
||||||
let mut fail_next_call = false;
|
|
||||||
let mut tried_agent = false;
|
|
||||||
|
|
||||||
callbacks.credentials(move |url, username_from_url, allowed_types| {
|
|
||||||
if fail_next_call {
|
|
||||||
info!("Failed to get credentials for push");
|
|
||||||
return Err(git2::Error::from_str("Bad credentials."));
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("getting credentials {url} {username_from_url:?} {allowed_types:?}");
|
|
||||||
match (allowed_types.is_ssh_key(), username_from_url) {
|
|
||||||
(true, Some(username)) => {
|
|
||||||
if !tried_agent {
|
|
||||||
tried_agent = true;
|
|
||||||
return Cred::ssh_key_from_agent(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
fail_next_call = true; // This is our last try
|
|
||||||
|
|
||||||
// If the agent failed, try using the default SSH key
|
|
||||||
if let Some(key) = find_ssh_key() {
|
|
||||||
Cred::ssh_key(username, None, key.as_path(), None)
|
|
||||||
} else {
|
|
||||||
Err(git2::Error::from_str(
|
|
||||||
"Bad credentials. Ensure your key was added using ssh-add",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(true, None) => Err(git2::Error::from_str("Couldn't get username from url")),
|
|
||||||
_ => {
|
|
||||||
return Err(git2::Error::from_str("https remotes are not (yet) supported"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
callbacks.push_transfer_progress(|current, total, bytes| {
|
|
||||||
debug!("progress: {}/{} ({} B)", current, total, bytes,);
|
|
||||||
});
|
|
||||||
|
|
||||||
callbacks.transfer_progress(|p| {
|
|
||||||
debug!("transfer: {}/{}", p.received_objects(), p.total_objects());
|
|
||||||
true
|
|
||||||
});
|
|
||||||
|
|
||||||
callbacks.pack_progress(|stage, current, total| {
|
|
||||||
debug!("packing: {:?} - {}/{}", stage, current, total);
|
|
||||||
});
|
|
||||||
|
|
||||||
callbacks.push_update_reference(|reference, msg| {
|
|
||||||
debug!("push_update_reference: '{}' {:?}", reference, msg);
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
callbacks.update_tips(|name, a, b| {
|
|
||||||
debug!("update tips: '{}' {} -> {}", name, a, b);
|
|
||||||
if a != b {
|
|
||||||
// let mut push_result = push_result.lock().unwrap();
|
|
||||||
// *push_result = PushResult::Success
|
|
||||||
}
|
|
||||||
true
|
|
||||||
});
|
|
||||||
|
|
||||||
callbacks.sideband_progress(|data| {
|
|
||||||
debug!("sideband transfer: '{}'", String::from_utf8_lossy(data).trim());
|
|
||||||
true
|
|
||||||
});
|
|
||||||
|
|
||||||
callbacks
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
|
use crate::add::git_add;
|
||||||
use crate::branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch};
|
use crate::branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch};
|
||||||
|
use crate::commit::git_commit;
|
||||||
|
use crate::credential::git_add_credential;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::fetch::git_fetch_all;
|
use crate::fetch::git_fetch_all;
|
||||||
use crate::git::{
|
use crate::init::git_init;
|
||||||
git_add, git_commit, git_init, git_log, git_status, git_unstage, GitCommit, GitStatusSummary,
|
use crate::log::{GitCommit, git_log};
|
||||||
};
|
use crate::pull::{PullResult, git_pull};
|
||||||
use crate::pull::{git_pull, PullResult};
|
use crate::push::{PushResult, git_push};
|
||||||
use crate::push::{git_push, PushResult};
|
use crate::remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
||||||
|
use crate::status::{GitStatusSummary, git_status};
|
||||||
|
use crate::unstage::git_unstage;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
|
|
||||||
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -80,3 +86,28 @@ pub async fn unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn add_credential(
|
||||||
|
dir: &Path,
|
||||||
|
remote_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
git_add_credential(dir, remote_url, username, password).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn remotes(dir: &Path) -> Result<Vec<GitRemote>> {
|
||||||
|
git_remotes(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn add_remote(dir: &Path, name: &str, url: &str) -> Result<GitRemote> {
|
||||||
|
git_add_remote(dir, name, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn rm_remote(dir: &Path, name: &str) -> Result<()> {
|
||||||
|
git_rm_remote(dir, name)
|
||||||
|
}
|
||||||
|
|||||||
20
src-tauri/yaak-git/src/commit.rs
Normal file
20
src-tauri/yaak-git/src/commit.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use crate::binary::new_binary_command;
|
||||||
|
use crate::error::Error::GenericError;
|
||||||
|
use log::info;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub(crate) fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
|
||||||
|
let out = new_binary_command(dir)?.args(["commit", "--message", message]).output()?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let combined = stdout + stderr;
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(GenericError(format!("Failed to commit: {}", combined)));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Committed to {dir:?}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
47
src-tauri/yaak-git/src/credential.rs
Normal file
47
src-tauri/yaak-git/src/credential.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use crate::binary::new_binary_command;
|
||||||
|
use crate::error::Error::GenericError;
|
||||||
|
use crate::error::Result;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use tauri::Url;
|
||||||
|
|
||||||
|
pub(crate) async fn git_add_credential(
|
||||||
|
dir: &Path,
|
||||||
|
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)?
|
||||||
|
.args(["credential", "approve"])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let stdin = child.stdin.as_mut().unwrap();
|
||||||
|
writeln!(stdin, "protocol={}", protocol)?;
|
||||||
|
writeln!(stdin, "host={}", host)?;
|
||||||
|
if let Some(path) = path {
|
||||||
|
if !path.is_empty() {
|
||||||
|
writeln!(stdin, "path={}", path.trim_start_matches('/'))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeln!(stdin, "username={}", username)?;
|
||||||
|
writeln!(stdin, "password={}", password)?;
|
||||||
|
writeln!(stdin)?; // blank line terminator
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child.wait()?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(GenericError("Failed to approve git credential".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -33,9 +33,18 @@ pub enum Error {
|
|||||||
#[error("Git error: {0}")]
|
#[error("Git error: {0}")]
|
||||||
GenericError(String),
|
GenericError(String),
|
||||||
|
|
||||||
|
#[error("'git' not found. Please ensure it's installed and available in $PATH")]
|
||||||
|
GitNotFound,
|
||||||
|
|
||||||
|
#[error("Credentials required: {0}")]
|
||||||
|
CredentialsRequiredError(String),
|
||||||
|
|
||||||
#[error("No default remote found")]
|
#[error("No default remote found")]
|
||||||
NoDefaultRemoteFound,
|
NoDefaultRemoteFound,
|
||||||
|
|
||||||
|
#[error("No remotes found for repo")]
|
||||||
|
NoRemotesFound,
|
||||||
|
|
||||||
#[error("Merge failed due to conflicts")]
|
#[error("Merge failed due to conflicts")]
|
||||||
MergeConflicts,
|
MergeConflicts,
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,20 @@
|
|||||||
use crate::callbacks::default_callbacks;
|
use crate::binary::new_binary_command;
|
||||||
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::repository::open_repo;
|
|
||||||
use git2::{FetchOptions, ProxyOptions, Repository};
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub(crate) fn git_fetch_all(dir: &Path) -> Result<()> {
|
pub(crate) fn git_fetch_all(dir: &Path) -> Result<()> {
|
||||||
let repo = open_repo(dir)?;
|
let out = new_binary_command(dir)?
|
||||||
let remotes = repo.remotes()?.iter().flatten().map(String::from).collect::<Vec<_>>();
|
.args(["fetch", "--all", "--prune", "--tags"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let combined = stdout + stderr;
|
||||||
|
|
||||||
for (_idx, remote) in remotes.into_iter().enumerate() {
|
if !out.status.success() {
|
||||||
fetch_from_remote(&repo, &remote)?;
|
return Err(GenericError(format!("Failed to fetch: {}", combined)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_from_remote(repo: &Repository, remote: &str) -> Result<()> {
|
|
||||||
let mut remote = repo.find_remote(remote)?;
|
|
||||||
|
|
||||||
let mut options = FetchOptions::new();
|
|
||||||
let callbacks = default_callbacks();
|
|
||||||
|
|
||||||
options.prune(git2::FetchPrune::On);
|
|
||||||
let mut proxy = ProxyOptions::new();
|
|
||||||
proxy.auto();
|
|
||||||
|
|
||||||
options.proxy_options(proxy);
|
|
||||||
options.download_tags(git2::AutotagOption::All);
|
|
||||||
options.remote_callbacks(callbacks);
|
|
||||||
|
|
||||||
remote.fetch(&[] as &[&str], Some(&mut options), None)?;
|
|
||||||
// fetch tags (also removing remotely deleted ones)
|
|
||||||
remote.fetch(&["refs/tags/*:refs/tags/*"], Some(&mut options), None)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,673 +0,0 @@
|
|||||||
use crate::error::Result;
|
|
||||||
use crate::repository::open_repo;
|
|
||||||
use crate::util::{local_branch_names, remote_branch_names};
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use git2::IndexAddOption;
|
|
||||||
use log::{info, warn};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
use ts_rs::TS;
|
|
||||||
use yaak_sync::models::SyncModel;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub struct GitStatusSummary {
|
|
||||||
pub path: String,
|
|
||||||
pub head_ref: Option<String>,
|
|
||||||
pub head_ref_shorthand: Option<String>,
|
|
||||||
pub entries: Vec<GitStatusEntry>,
|
|
||||||
pub origins: Vec<String>,
|
|
||||||
pub local_branches: Vec<String>,
|
|
||||||
pub remote_branches: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub struct GitStatusEntry {
|
|
||||||
pub rela_path: String,
|
|
||||||
pub status: GitStatus,
|
|
||||||
pub staged: bool,
|
|
||||||
pub prev: Option<SyncModel>,
|
|
||||||
pub next: Option<SyncModel>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub enum GitStatus {
|
|
||||||
Untracked,
|
|
||||||
Conflict,
|
|
||||||
Current,
|
|
||||||
Modified,
|
|
||||||
Removed,
|
|
||||||
Renamed,
|
|
||||||
TypeChange,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub struct GitCommit {
|
|
||||||
author: GitAuthor,
|
|
||||||
when: DateTime<Utc>,
|
|
||||||
message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub struct GitAuthor {
|
|
||||||
name: Option<String>,
|
|
||||||
email: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_init(dir: &Path) -> Result<()> {
|
|
||||||
git2::Repository::init(dir)?;
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
// Default to main instead of master, to align with
|
|
||||||
// the official Git and GitHub behavior
|
|
||||||
repo.set_head("refs/heads/main")?;
|
|
||||||
info!("Initialized {dir:?}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_add(dir: &Path, rela_path: &Path) -> Result<()> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
let mut index = repo.index()?;
|
|
||||||
|
|
||||||
info!("Staging file {rela_path:?} to {dir:?}");
|
|
||||||
index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?;
|
|
||||||
index.write()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_unstage(dir: &Path, rela_path: &Path) -> Result<()> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
|
|
||||||
let head = match repo.head() {
|
|
||||||
Ok(h) => h,
|
|
||||||
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
|
|
||||||
info!("Unstaging file in empty branch {rela_path:?} to {dir:?}");
|
|
||||||
// Repo has no commits, so "unstage" means remove from index
|
|
||||||
let mut index = repo.index()?;
|
|
||||||
index.remove_path(rela_path)?;
|
|
||||||
index.write()?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// If repo has commits, update the index entry back to HEAD
|
|
||||||
info!("Unstaging file {rela_path:?} to {dir:?}");
|
|
||||||
let commit = head.peel_to_commit()?;
|
|
||||||
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
|
|
||||||
// Clear the in-memory index, add the paths, and write the tree for committing
|
|
||||||
let tree_oid = repo.index()?.write_tree()?;
|
|
||||||
let tree = repo.find_tree(tree_oid)?;
|
|
||||||
|
|
||||||
// Make the signature
|
|
||||||
let config = repo.config()?.snapshot()?;
|
|
||||||
let name = config.get_str("user.name").unwrap_or("Unknown");
|
|
||||||
let email = config.get_str("user.email")?;
|
|
||||||
let sig = git2::Signature::now(name, email)?;
|
|
||||||
|
|
||||||
// Get the current HEAD commit (if it exists)
|
|
||||||
let parent_commit = match repo.head() {
|
|
||||||
Ok(head) => Some(head.peel_to_commit()?),
|
|
||||||
Err(_) => None, // No parent if no HEAD exists (initial commit)
|
|
||||||
};
|
|
||||||
|
|
||||||
let parents = parent_commit.as_ref().map(|p| vec![p]).unwrap_or_default();
|
|
||||||
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, parents.as_slice())?;
|
|
||||||
|
|
||||||
info!("Committed to {dir:?}");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_log(dir: &Path) -> Result<Vec<GitCommit>> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
|
|
||||||
// Return empty if empty repo or no head (new repo)
|
|
||||||
if repo.is_empty()? || repo.head().is_err() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut revwalk = repo.revwalk()?;
|
|
||||||
revwalk.push_head()?;
|
|
||||||
revwalk.set_sorting(git2::Sort::TIME)?;
|
|
||||||
|
|
||||||
// Run git log
|
|
||||||
macro_rules! filter_try {
|
|
||||||
($e:expr) => {
|
|
||||||
match $e {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(_) => return None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let log: Vec<GitCommit> = revwalk
|
|
||||||
.filter_map(|oid| {
|
|
||||||
let oid = filter_try!(oid);
|
|
||||||
let commit = filter_try!(repo.find_commit(oid));
|
|
||||||
let author = commit.author();
|
|
||||||
Some(GitCommit {
|
|
||||||
author: GitAuthor {
|
|
||||||
name: author.name().map(|s| s.to_string()),
|
|
||||||
email: author.email().map(|s| s.to_string()),
|
|
||||||
},
|
|
||||||
when: convert_git_time_to_date(author.when()),
|
|
||||||
message: commit.message().map(|m| m.to_string()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(log)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_status(dir: &Path) -> Result<GitStatusSummary> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
|
|
||||||
Ok(head) => {
|
|
||||||
let tree = head.peel_to_tree().ok();
|
|
||||||
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
|
|
||||||
let head_ref = head.name().map(|s| s.to_string());
|
|
||||||
|
|
||||||
(tree, head_ref, head_ref_shorthand)
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// For "unborn" repos, reading from HEAD is the only way to get the branch name
|
|
||||||
// See https://github.com/starship/starship/pull/1336
|
|
||||||
let head_path = repo.path().join("HEAD");
|
|
||||||
let head_ref = fs::read_to_string(&head_path)
|
|
||||||
.ok()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.lines()
|
|
||||||
.next()
|
|
||||||
.map(|s| s.trim_start_matches("ref:").trim().to_string());
|
|
||||||
let head_ref_shorthand =
|
|
||||||
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
|
|
||||||
(None, head_ref, head_ref_shorthand)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut opts = git2::StatusOptions::new();
|
|
||||||
opts.include_ignored(false)
|
|
||||||
.include_untracked(true) // Include untracked
|
|
||||||
.recurse_untracked_dirs(true) // Show all untracked
|
|
||||||
.include_unmodified(true); // Include unchanged
|
|
||||||
|
|
||||||
// TODO: Support renames
|
|
||||||
|
|
||||||
let mut entries: Vec<GitStatusEntry> = Vec::new();
|
|
||||||
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
|
||||||
let rela_path = entry.path().unwrap().to_string();
|
|
||||||
let status = entry.status();
|
|
||||||
let index_status = match status {
|
|
||||||
// Note: order matters here, since we're checking a bitmap!
|
|
||||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
|
||||||
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
|
|
||||||
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
|
||||||
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
|
||||||
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
|
||||||
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
|
|
||||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
|
||||||
s => {
|
|
||||||
warn!("Unknown index status {s:?}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let worktree_status = match status {
|
|
||||||
// Note: order matters here, since we're checking a bitmap!
|
|
||||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
|
||||||
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
|
|
||||||
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
|
||||||
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
|
||||||
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
|
||||||
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
|
|
||||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
|
||||||
s => {
|
|
||||||
warn!("Unknown worktree status {s:?}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = if index_status == GitStatus::Current {
|
|
||||||
worktree_status.clone()
|
|
||||||
} else {
|
|
||||||
index_status.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
|
|
||||||
{
|
|
||||||
// No change, so can't be added
|
|
||||||
false
|
|
||||||
} else if index_status != GitStatus::Current {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get previous content from Git, if it's in there
|
|
||||||
let prev = match head_tree.clone() {
|
|
||||||
None => None,
|
|
||||||
Some(t) => match t.get_path(&Path::new(&rela_path)) {
|
|
||||||
Ok(entry) => {
|
|
||||||
let obj = entry.to_object(&repo)?;
|
|
||||||
let content = obj.as_blob().unwrap().content();
|
|
||||||
let name = Path::new(entry.name().unwrap_or_default());
|
|
||||||
SyncModel::from_bytes(content.into(), name)?.map(|m| m.0)
|
|
||||||
}
|
|
||||||
Err(_) => None,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let next = {
|
|
||||||
let full_path = repo.workdir().unwrap().join(rela_path.clone());
|
|
||||||
SyncModel::from_file(full_path.as_path())?.map(|m| m.0)
|
|
||||||
};
|
|
||||||
|
|
||||||
entries.push(GitStatusEntry {
|
|
||||||
status,
|
|
||||||
staged,
|
|
||||||
rela_path,
|
|
||||||
prev: prev.clone(),
|
|
||||||
next: next.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
|
|
||||||
let local_branches = local_branch_names(&repo)?;
|
|
||||||
let remote_branches = remote_branch_names(&repo)?;
|
|
||||||
|
|
||||||
Ok(GitStatusSummary {
|
|
||||||
entries,
|
|
||||||
origins,
|
|
||||||
path: dir.to_string_lossy().to_string(),
|
|
||||||
head_ref,
|
|
||||||
head_ref_shorthand,
|
|
||||||
local_branches,
|
|
||||||
remote_branches,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
|
|
||||||
DateTime::from_timestamp(0, 0).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
fn convert_git_time_to_date(git_time: git2::Time) -> DateTime<Utc> {
|
|
||||||
let timestamp = git_time.seconds();
|
|
||||||
DateTime::from_timestamp(timestamp, 0).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// // Write a test
|
|
||||||
// #[cfg(test)]
|
|
||||||
// mod test {
|
|
||||||
// use crate::error::Error::GitRepoNotFound;
|
|
||||||
// use crate::error::Result;
|
|
||||||
// use crate::git::{
|
|
||||||
// git_add, git_commit, git_init, git_log, git_status, git_unstage, open_repo, GitStatus,
|
|
||||||
// GitStatusEntry,
|
|
||||||
// };
|
|
||||||
// use std::fs::{create_dir_all, remove_file, File};
|
|
||||||
// use std::io::Write;
|
|
||||||
// use std::path::{Path, PathBuf};
|
|
||||||
// use tempdir::TempDir;
|
|
||||||
//
|
|
||||||
// fn new_dir() -> PathBuf {
|
|
||||||
// let p = TempDir::new("yaak-git").unwrap().into_path();
|
|
||||||
// p
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// fn new_file(path: &Path, content: &str) {
|
|
||||||
// let parent = path.parent().unwrap();
|
|
||||||
// create_dir_all(parent).unwrap();
|
|
||||||
// File::create(path).unwrap().write_all(content.as_bytes()).unwrap();
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[tokio::test]
|
|
||||||
// async fn test_status_no_repo() {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// let result = git_status(dir).await;
|
|
||||||
// assert!(matches!(result, Err(GitRepoNotFound(_))));
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[test]
|
|
||||||
// fn test_open_repo() -> Result<()> {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// git_init(dir)?;
|
|
||||||
// open_repo(dir.as_path())?;
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[test]
|
|
||||||
// fn test_open_repo_from_subdir() -> Result<()> {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// git_init(dir)?;
|
|
||||||
//
|
|
||||||
// let sub_dir = dir.join("a").join("b");
|
|
||||||
// create_dir_all(sub_dir.as_path())?; // Create sub dir
|
|
||||||
//
|
|
||||||
// open_repo(sub_dir.as_path())?;
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[tokio::test]
|
|
||||||
// async fn test_status() -> Result<()> {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// git_init(dir)?;
|
|
||||||
//
|
|
||||||
// assert_eq!(git_status(dir).await?.entries, Vec::new());
|
|
||||||
//
|
|
||||||
// new_file(&dir.join("foo.txt"), "foo");
|
|
||||||
// new_file(&dir.join("bar.txt"), "bar");
|
|
||||||
// new_file(&dir.join("dir/baz.txt"), "baz");
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "dir/baz.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("baz".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("foo".to_string()),
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// );
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[tokio::test]
|
|
||||||
// fn test_add() -> Result<()> {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// git_init(dir)?;
|
|
||||||
//
|
|
||||||
// new_file(&dir.join("foo.txt"), "foo");
|
|
||||||
// new_file(&dir.join("bar.txt"), "bar");
|
|
||||||
//
|
|
||||||
// git_add(dir, Path::new("foo.txt"))?;
|
|
||||||
//
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: true,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("foo".to_string()),
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// );
|
|
||||||
//
|
|
||||||
// new_file(&dir.join("foo.txt"), "foo foo");
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: true,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("foo foo".to_string()),
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// );
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[tokio::test]
|
|
||||||
// fn test_unstage() -> Result<()> {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// git_init(dir)?;
|
|
||||||
//
|
|
||||||
// new_file(&dir.join("foo.txt"), "foo");
|
|
||||||
// new_file(&dir.join("bar.txt"), "bar");
|
|
||||||
//
|
|
||||||
// git_add(dir, Path::new("foo.txt"))?;
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: true,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("foo".to_string()),
|
|
||||||
// },
|
|
||||||
// ]
|
|
||||||
// );
|
|
||||||
//
|
|
||||||
// git_unstage(dir, Path::new("foo.txt"))?;
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("foo".to_string()),
|
|
||||||
// }
|
|
||||||
// ]
|
|
||||||
// );
|
|
||||||
//
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[tokio::test]
|
|
||||||
// fn test_commit() -> Result<()> {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// git_init(dir)?;
|
|
||||||
//
|
|
||||||
// new_file(&dir.join("foo.txt"), "foo");
|
|
||||||
// new_file(&dir.join("bar.txt"), "bar");
|
|
||||||
//
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("foo".to_string()),
|
|
||||||
// },
|
|
||||||
// ]
|
|
||||||
// );
|
|
||||||
//
|
|
||||||
// git_add(dir, Path::new("foo.txt"))?;
|
|
||||||
// git_commit(dir, "This is my message")?;
|
|
||||||
//
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Current,
|
|
||||||
// staged: false,
|
|
||||||
// prev: Some("foo".to_string()),
|
|
||||||
// next: Some("foo".to_string()),
|
|
||||||
// },
|
|
||||||
// ]
|
|
||||||
// );
|
|
||||||
//
|
|
||||||
// new_file(&dir.join("foo.txt"), "foo foo");
|
|
||||||
// git_add(dir, Path::new("foo.txt"))?;
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Modified,
|
|
||||||
// staged: true,
|
|
||||||
// prev: Some("foo".to_string()),
|
|
||||||
// next: Some("foo foo".to_string()),
|
|
||||||
// },
|
|
||||||
// ]
|
|
||||||
// );
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[tokio::test]
|
|
||||||
// async fn test_add_removed_file() -> Result<()> {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// git_init(dir)?;
|
|
||||||
//
|
|
||||||
// let foo_path = &dir.join("foo.txt");
|
|
||||||
// let bar_path = &dir.join("bar.txt");
|
|
||||||
//
|
|
||||||
// new_file(foo_path, "foo");
|
|
||||||
// new_file(bar_path, "bar");
|
|
||||||
//
|
|
||||||
// git_add(dir, Path::new("foo.txt"))?;
|
|
||||||
// git_commit(dir, "Initial commit")?;
|
|
||||||
//
|
|
||||||
// remove_file(foo_path)?;
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Removed,
|
|
||||||
// staged: false,
|
|
||||||
// prev: Some("foo".to_string()),
|
|
||||||
// next: None,
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// );
|
|
||||||
//
|
|
||||||
// git_add(dir, Path::new("foo.txt"))?;
|
|
||||||
// assert_eq!(
|
|
||||||
// git_status(dir).await?.entries,
|
|
||||||
// vec![
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "bar.txt".to_string(),
|
|
||||||
// status: GitStatus::Added,
|
|
||||||
// staged: false,
|
|
||||||
// prev: None,
|
|
||||||
// next: Some("bar".to_string()),
|
|
||||||
// },
|
|
||||||
// GitStatusEntry {
|
|
||||||
// rela_path: "foo.txt".to_string(),
|
|
||||||
// status: GitStatus::Removed,
|
|
||||||
// staged: true,
|
|
||||||
// prev: Some("foo".to_string()),
|
|
||||||
// next: None,
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// );
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[tokio::test]
|
|
||||||
// fn test_log_empty() -> Result<()> {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// git_init(dir)?;
|
|
||||||
//
|
|
||||||
// let log = git_log(dir)?;
|
|
||||||
// assert_eq!(log.len(), 0);
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// #[test]
|
|
||||||
// fn test_log() -> Result<()> {
|
|
||||||
// let dir = &new_dir();
|
|
||||||
// git_init(dir)?;
|
|
||||||
//
|
|
||||||
// new_file(&dir.join("foo.txt"), "foo");
|
|
||||||
// new_file(&dir.join("bar.txt"), "bar");
|
|
||||||
//
|
|
||||||
// git_add(dir, Path::new("foo.txt"))?;
|
|
||||||
// git_commit(dir, "This is my message")?;
|
|
||||||
//
|
|
||||||
// let log = git_log(dir)?;
|
|
||||||
// assert_eq!(log.len(), 1);
|
|
||||||
// assert_eq!(log.get(0).unwrap().message, Some("This is my message".to_string()));
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
14
src-tauri/yaak-git/src/init.rs
Normal file
14
src-tauri/yaak-git/src/init.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::error::Result;
|
||||||
|
use crate::repository::open_repo;
|
||||||
|
use log::info;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub(crate) fn git_init(dir: &Path) -> Result<()> {
|
||||||
|
git2::Repository::init(dir)?;
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
// Default to main instead of master, to align with
|
||||||
|
// the official Git and GitHub behavior
|
||||||
|
repo.set_head("refs/heads/main")?;
|
||||||
|
info!("Initialized {dir:?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
use crate::commands::{add, branch, checkout, commit, delete_branch, fetch_all, initialize, log, merge_branch, pull, push, status, unstage};
|
use crate::commands::{add, add_credential, add_remote, branch, checkout, commit, delete_branch, fetch_all, initialize, log, merge_branch, pull, push, remotes, rm_remote, status, unstage};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
generate_handler,
|
Runtime, generate_handler,
|
||||||
plugin::{Builder, TauriPlugin},
|
plugin::{Builder, TauriPlugin},
|
||||||
Runtime,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod add;
|
||||||
|
mod binary;
|
||||||
mod branch;
|
mod branch;
|
||||||
mod callbacks;
|
|
||||||
mod commands;
|
mod commands;
|
||||||
pub mod error;
|
mod commit;
|
||||||
|
mod credential;
|
||||||
mod fetch;
|
mod fetch;
|
||||||
mod git;
|
mod init;
|
||||||
|
mod log;
|
||||||
mod merge;
|
mod merge;
|
||||||
mod pull;
|
mod pull;
|
||||||
mod push;
|
mod push;
|
||||||
|
mod remotes;
|
||||||
mod repository;
|
mod repository;
|
||||||
|
mod status;
|
||||||
|
mod unstage;
|
||||||
mod util;
|
mod util;
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
Builder::new("yaak-git")
|
Builder::new("yaak-git")
|
||||||
.invoke_handler(generate_handler![
|
.invoke_handler(generate_handler![
|
||||||
add,
|
add,
|
||||||
|
add_credential,
|
||||||
|
add_remote,
|
||||||
branch,
|
branch,
|
||||||
checkout,
|
checkout,
|
||||||
commit,
|
commit,
|
||||||
@@ -31,8 +39,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
merge_branch,
|
merge_branch,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
|
remotes,
|
||||||
|
rm_remote,
|
||||||
status,
|
status,
|
||||||
unstage
|
unstage,
|
||||||
])
|
])
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
73
src-tauri/yaak-git/src/log.rs
Normal file
73
src-tauri/yaak-git/src/log.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use crate::repository::open_repo;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub(crate) struct GitCommit {
|
||||||
|
pub author: GitAuthor,
|
||||||
|
pub when: DateTime<Utc>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub(crate) struct GitAuthor {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
|
||||||
|
// Return empty if empty repo or no head (new repo)
|
||||||
|
if repo.is_empty()? || repo.head().is_err() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut revwalk = repo.revwalk()?;
|
||||||
|
revwalk.push_head()?;
|
||||||
|
revwalk.set_sorting(git2::Sort::TIME)?;
|
||||||
|
|
||||||
|
// Run git log
|
||||||
|
macro_rules! filter_try {
|
||||||
|
($e:expr) => {
|
||||||
|
match $e {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let log: Vec<GitCommit> = revwalk
|
||||||
|
.filter_map(|oid| {
|
||||||
|
let oid = filter_try!(oid);
|
||||||
|
let commit = filter_try!(repo.find_commit(oid));
|
||||||
|
let author = commit.author();
|
||||||
|
Some(GitCommit {
|
||||||
|
author: GitAuthor {
|
||||||
|
name: author.name().map(|s| s.to_string()),
|
||||||
|
email: author.email().map(|s| s.to_string()),
|
||||||
|
},
|
||||||
|
when: convert_git_time_to_date(author.when()),
|
||||||
|
message: commit.message().map(|m| m.to_string()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
|
||||||
|
DateTime::from_timestamp(0, 0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn convert_git_time_to_date(git_time: git2::Time) -> DateTime<Utc> {
|
||||||
|
let timestamp = git_time.seconds();
|
||||||
|
DateTime::from_timestamp(timestamp, 0).unwrap()
|
||||||
|
}
|
||||||
@@ -1,54 +1,100 @@
|
|||||||
use crate::callbacks::default_callbacks;
|
use crate::binary::new_binary_command;
|
||||||
use crate::error::Error::NoActiveBranch;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::merge::do_merge;
|
|
||||||
use crate::repository::open_repo;
|
use crate::repository::open_repo;
|
||||||
use crate::util::{bytes_to_string, get_current_branch};
|
use crate::util::{get_current_branch_name, get_default_remote_in_repo};
|
||||||
use git2::{FetchOptions, ProxyOptions};
|
use log::info;
|
||||||
use log::debug;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "snake_case", tag = "type")]
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
pub(crate) struct PullResult {
|
pub(crate) enum PullResult {
|
||||||
received_bytes: usize,
|
Success { message: String },
|
||||||
received_objects: usize,
|
UpToDate,
|
||||||
|
NeedsCredentials { url: String, error: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn git_pull(dir: &Path) -> Result<PullResult> {
|
pub(crate) fn git_pull(dir: &Path) -> Result<PullResult> {
|
||||||
let repo = open_repo(dir)?;
|
let repo = open_repo(dir)?;
|
||||||
|
let branch_name = get_current_branch_name(&repo)?;
|
||||||
|
let remote = get_default_remote_in_repo(&repo)?;
|
||||||
|
let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
|
||||||
|
let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
|
||||||
|
|
||||||
let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?;
|
let out = new_binary_command(dir)?
|
||||||
let branch_ref = branch.get();
|
.args(["pull", &remote_name, &branch_name])
|
||||||
let branch_ref = bytes_to_string(branch_ref.name_bytes())?;
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
|
||||||
|
|
||||||
let remote_name = repo.branch_upstream_remote(&branch_ref)?;
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
let remote_name = bytes_to_string(&remote_name)?;
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
debug!("Pulling from {remote_name}");
|
let combined = stdout + stderr;
|
||||||
|
|
||||||
let mut remote = repo.find_remote(&remote_name)?;
|
info!("Pulled status={} {combined}", out.status);
|
||||||
|
|
||||||
let mut options = FetchOptions::new();
|
if combined.to_lowercase().contains("could not read") {
|
||||||
let callbacks = default_callbacks();
|
return Ok(PullResult::NeedsCredentials {
|
||||||
options.remote_callbacks(callbacks);
|
url: remote_url.to_string(),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let mut proxy = ProxyOptions::new();
|
if combined.to_lowercase().contains("unable to access") {
|
||||||
proxy.auto();
|
return Ok(PullResult::NeedsCredentials {
|
||||||
options.proxy_options(proxy);
|
url: remote_url.to_string(),
|
||||||
|
error: Some(combined.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
remote.fetch(&[&branch_ref], Some(&mut options), None)?;
|
if !out.status.success() {
|
||||||
|
return Err(GenericError(format!("Failed to pull {combined}")));
|
||||||
|
}
|
||||||
|
|
||||||
let stats = remote.stats();
|
if combined.to_lowercase().contains("up to date") {
|
||||||
|
return Ok(PullResult::UpToDate);
|
||||||
|
}
|
||||||
|
|
||||||
let fetch_head = repo.find_reference("FETCH_HEAD")?;
|
Ok(PullResult::Success {
|
||||||
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
|
message: format!("Pulled from {}/{}", remote_name, branch_name),
|
||||||
do_merge(&repo, &branch, &fetch_commit)?;
|
|
||||||
|
|
||||||
Ok(PullResult {
|
|
||||||
received_bytes: stats.received_bytes(),
|
|
||||||
received_objects: stats.received_objects(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pub(crate) fn git_pull_old(dir: &Path) -> Result<PullResult> {
|
||||||
|
// let repo = open_repo(dir)?;
|
||||||
|
//
|
||||||
|
// let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?;
|
||||||
|
// let branch_ref = branch.get();
|
||||||
|
// let branch_ref = bytes_to_string(branch_ref.name_bytes())?;
|
||||||
|
//
|
||||||
|
// let remote_name = repo.branch_upstream_remote(&branch_ref)?;
|
||||||
|
// let remote_name = bytes_to_string(&remote_name)?;
|
||||||
|
// debug!("Pulling from {remote_name}");
|
||||||
|
//
|
||||||
|
// let mut remote = repo.find_remote(&remote_name)?;
|
||||||
|
//
|
||||||
|
// let mut options = FetchOptions::new();
|
||||||
|
// let callbacks = default_callbacks();
|
||||||
|
// options.remote_callbacks(callbacks);
|
||||||
|
//
|
||||||
|
// let mut proxy = ProxyOptions::new();
|
||||||
|
// proxy.auto();
|
||||||
|
// options.proxy_options(proxy);
|
||||||
|
//
|
||||||
|
// remote.fetch(&[&branch_ref], Some(&mut options), None)?;
|
||||||
|
//
|
||||||
|
// let stats = remote.stats();
|
||||||
|
//
|
||||||
|
// let fetch_head = repo.find_reference("FETCH_HEAD")?;
|
||||||
|
// let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
|
||||||
|
// do_merge(&repo, &branch, &fetch_commit)?;
|
||||||
|
//
|
||||||
|
// Ok(PullResult::Success {
|
||||||
|
// message: "Hello".to_string(),
|
||||||
|
// // received_bytes: stats.received_bytes(),
|
||||||
|
// // received_objects: stats.received_objects(),
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|||||||
@@ -1,74 +1,64 @@
|
|||||||
use crate::branch::branch_set_upstream_after_push;
|
use crate::binary::new_binary_command;
|
||||||
use crate::callbacks::default_callbacks;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::repository::open_repo;
|
use crate::repository::open_repo;
|
||||||
use git2::{ProxyOptions, PushOptions};
|
use crate::util::{get_current_branch_name, get_default_remote_for_push_in_repo};
|
||||||
|
use log::info;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case", tag = "type")]
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub(crate) enum PushType {
|
|
||||||
Branch,
|
|
||||||
Tag,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
pub(crate) enum PushResult {
|
pub(crate) enum PushResult {
|
||||||
Success,
|
Success { message: String },
|
||||||
NothingToPush,
|
UpToDate,
|
||||||
|
NeedsCredentials { url: String, error: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn git_push(dir: &Path) -> Result<PushResult> {
|
pub(crate) fn git_push(dir: &Path) -> Result<PushResult> {
|
||||||
let repo = open_repo(dir)?;
|
let repo = open_repo(dir)?;
|
||||||
let head = repo.head()?;
|
let branch_name = get_current_branch_name(&repo)?;
|
||||||
let branch = head.shorthand().unwrap();
|
let remote = get_default_remote_for_push_in_repo(&repo)?;
|
||||||
let mut remote = repo.find_remote("origin")?;
|
let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
|
||||||
|
let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
|
||||||
|
|
||||||
let mut options = PushOptions::new();
|
let out = new_binary_command(dir)?
|
||||||
options.packbuilder_parallelism(0);
|
.args(["push", &remote_name, &branch_name])
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
let push_result = Mutex::new(PushResult::NothingToPush);
|
.output()
|
||||||
|
.map_err(|e| GenericError(format!("failed to run git push: {e}")))?;
|
||||||
let mut callbacks = default_callbacks();
|
|
||||||
callbacks.push_transfer_progress(|_current, _total, _bytes| {
|
|
||||||
let mut push_result = push_result.lock().unwrap();
|
|
||||||
*push_result = PushResult::Success;
|
|
||||||
});
|
|
||||||
|
|
||||||
options.remote_callbacks(default_callbacks());
|
|
||||||
|
|
||||||
let mut proxy = ProxyOptions::new();
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
proxy.auto();
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
options.proxy_options(proxy);
|
let combined = stdout + stderr;
|
||||||
|
|
||||||
// Push the current branch
|
info!("Pushed to repo status={} {combined}", out.status);
|
||||||
let force = false;
|
|
||||||
let delete = false;
|
|
||||||
let branch_modifier = match (force, delete) {
|
|
||||||
(true, true) => "+:",
|
|
||||||
(false, true) => ":",
|
|
||||||
(true, false) => "+",
|
|
||||||
(false, false) => "",
|
|
||||||
};
|
|
||||||
|
|
||||||
let ref_type = PushType::Branch;
|
if combined.to_lowercase().contains("could not read") {
|
||||||
|
return Ok(PushResult::NeedsCredentials {
|
||||||
|
url: remote_url.to_string(),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let ref_type = match ref_type {
|
if combined.to_lowercase().contains("unable to access") {
|
||||||
PushType::Branch => "heads",
|
return Ok(PushResult::NeedsCredentials {
|
||||||
PushType::Tag => "tags",
|
url: remote_url.to_string(),
|
||||||
};
|
error: Some(combined.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let refspec = format!("{branch_modifier}refs/{ref_type}/{branch}");
|
if combined.to_lowercase().contains("up-to-date") {
|
||||||
remote.push(&[refspec], Some(&mut options))?;
|
return Ok(PushResult::UpToDate);
|
||||||
|
}
|
||||||
|
|
||||||
branch_set_upstream_after_push(&repo, branch)?;
|
if !out.status.success() {
|
||||||
|
return Err(GenericError(format!("Failed to push {combined}")));
|
||||||
|
}
|
||||||
|
|
||||||
let push_result = push_result.lock().unwrap();
|
Ok(PushResult::Success {
|
||||||
Ok(push_result.clone())
|
message: format!("Pushed to {}/{}", remote_name, branch_name),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
53
src-tauri/yaak-git/src/remotes.rs
Normal file
53
src-tauri/yaak-git/src/remotes.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use crate::error::Result;
|
||||||
|
use crate::repository::open_repo;
|
||||||
|
use log::warn;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub(crate) struct GitRemote {
|
||||||
|
name: String,
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn git_remotes(dir: &Path) -> Result<Vec<GitRemote>> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
let mut remotes = Vec::new();
|
||||||
|
|
||||||
|
for remote in repo.remotes()?.into_iter() {
|
||||||
|
let name = match remote {
|
||||||
|
None => continue,
|
||||||
|
Some(name) => name,
|
||||||
|
};
|
||||||
|
let r = match repo.find_remote(name) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to get remote {name}: {e:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
remotes.push(GitRemote {
|
||||||
|
name: name.to_string(),
|
||||||
|
url: r.url().map(|u| u.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(remotes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn git_add_remote(dir: &Path, name: &str, url: &str) -> Result<GitRemote> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
repo.remote(name, url)?;
|
||||||
|
Ok(GitRemote {
|
||||||
|
name: name.to_string(),
|
||||||
|
url: Some(url.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn git_rm_remote(dir: &Path, name: &str) -> Result<()> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
repo.remote_delete(name)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
172
src-tauri/yaak-git/src/status.rs
Normal file
172
src-tauri/yaak-git/src/status.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::repository::open_repo;
|
||||||
|
use crate::util::{local_branch_names, remote_branch_names};
|
||||||
|
use log::warn;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use ts_rs::TS;
|
||||||
|
use yaak_sync::models::SyncModel;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub struct GitStatusSummary {
|
||||||
|
pub path: String,
|
||||||
|
pub head_ref: Option<String>,
|
||||||
|
pub head_ref_shorthand: Option<String>,
|
||||||
|
pub entries: Vec<GitStatusEntry>,
|
||||||
|
pub origins: Vec<String>,
|
||||||
|
pub local_branches: Vec<String>,
|
||||||
|
pub remote_branches: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub struct GitStatusEntry {
|
||||||
|
pub rela_path: String,
|
||||||
|
pub status: GitStatus,
|
||||||
|
pub staged: bool,
|
||||||
|
pub prev: Option<SyncModel>,
|
||||||
|
pub next: Option<SyncModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
|
pub enum GitStatus {
|
||||||
|
Untracked,
|
||||||
|
Conflict,
|
||||||
|
Current,
|
||||||
|
Modified,
|
||||||
|
Removed,
|
||||||
|
Renamed,
|
||||||
|
TypeChange,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
|
||||||
|
Ok(head) => {
|
||||||
|
let tree = head.peel_to_tree().ok();
|
||||||
|
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
|
||||||
|
let head_ref = head.name().map(|s| s.to_string());
|
||||||
|
|
||||||
|
(tree, head_ref, head_ref_shorthand)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// For "unborn" repos, reading from HEAD is the only way to get the branch name
|
||||||
|
// See https://github.com/starship/starship/pull/1336
|
||||||
|
let head_path = repo.path().join("HEAD");
|
||||||
|
let head_ref = fs::read_to_string(&head_path)
|
||||||
|
.ok()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.map(|s| s.trim_start_matches("ref:").trim().to_string());
|
||||||
|
let head_ref_shorthand =
|
||||||
|
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
|
||||||
|
(None, head_ref, head_ref_shorthand)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut opts = git2::StatusOptions::new();
|
||||||
|
opts.include_ignored(false)
|
||||||
|
.include_untracked(true) // Include untracked
|
||||||
|
.recurse_untracked_dirs(true) // Show all untracked
|
||||||
|
.include_unmodified(true); // Include unchanged
|
||||||
|
|
||||||
|
// TODO: Support renames
|
||||||
|
|
||||||
|
let mut entries: Vec<GitStatusEntry> = Vec::new();
|
||||||
|
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
||||||
|
let rela_path = entry.path().unwrap().to_string();
|
||||||
|
let status = entry.status();
|
||||||
|
let index_status = match status {
|
||||||
|
// Note: order matters here, since we're checking a bitmap!
|
||||||
|
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||||
|
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
|
||||||
|
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
||||||
|
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
||||||
|
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
||||||
|
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
|
||||||
|
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||||
|
s => {
|
||||||
|
warn!("Unknown index status {s:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let worktree_status = match status {
|
||||||
|
// Note: order matters here, since we're checking a bitmap!
|
||||||
|
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||||
|
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
|
||||||
|
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
||||||
|
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
||||||
|
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
||||||
|
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
|
||||||
|
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||||
|
s => {
|
||||||
|
warn!("Unknown worktree status {s:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = if index_status == GitStatus::Current {
|
||||||
|
worktree_status.clone()
|
||||||
|
} else {
|
||||||
|
index_status.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
|
||||||
|
{
|
||||||
|
// No change, so can't be added
|
||||||
|
false
|
||||||
|
} else if index_status != GitStatus::Current {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get previous content from Git, if it's in there
|
||||||
|
let prev = match head_tree.clone() {
|
||||||
|
None => None,
|
||||||
|
Some(t) => match t.get_path(&Path::new(&rela_path)) {
|
||||||
|
Ok(entry) => {
|
||||||
|
let obj = entry.to_object(&repo)?;
|
||||||
|
let content = obj.as_blob().unwrap().content();
|
||||||
|
let name = Path::new(entry.name().unwrap_or_default());
|
||||||
|
SyncModel::from_bytes(content.into(), name)?.map(|m| m.0)
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let next = {
|
||||||
|
let full_path = repo.workdir().unwrap().join(rela_path.clone());
|
||||||
|
SyncModel::from_file(full_path.as_path())?.map(|m| m.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.push(GitStatusEntry {
|
||||||
|
status,
|
||||||
|
staged,
|
||||||
|
rela_path,
|
||||||
|
prev: prev.clone(),
|
||||||
|
next: next.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
|
||||||
|
let local_branches = local_branch_names(&repo)?;
|
||||||
|
let remote_branches = remote_branch_names(&repo)?;
|
||||||
|
|
||||||
|
Ok(GitStatusSummary {
|
||||||
|
entries,
|
||||||
|
origins,
|
||||||
|
path: dir.to_string_lossy().to_string(),
|
||||||
|
head_ref,
|
||||||
|
head_ref_shorthand,
|
||||||
|
local_branches,
|
||||||
|
remote_branches,
|
||||||
|
})
|
||||||
|
}
|
||||||
28
src-tauri/yaak-git/src/unstage.rs
Normal file
28
src-tauri/yaak-git/src/unstage.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use log::info;
|
||||||
|
use crate::repository::open_repo;
|
||||||
|
|
||||||
|
pub(crate) fn git_unstage(dir: &Path, rela_path: &Path) -> crate::error::Result<()> {
|
||||||
|
let repo = open_repo(dir)?;
|
||||||
|
|
||||||
|
let head = match repo.head() {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
|
||||||
|
info!("Unstaging file in empty branch {rela_path:?} to {dir:?}");
|
||||||
|
// Repo has no commits, so "unstage" means remove from index
|
||||||
|
let mut index = repo.index()?;
|
||||||
|
index.remove_path(rela_path)?;
|
||||||
|
index.write()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If repo has commits, update the index entry back to HEAD
|
||||||
|
info!("Unstaging file {rela_path:?} to {dir:?}");
|
||||||
|
let commit = head.peel_to_commit()?;
|
||||||
|
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,29 +1,9 @@
|
|||||||
use crate::error::Error::{GenericError, NoDefaultRemoteFound};
|
use crate::error::Error::{GenericError, NoDefaultRemoteFound};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use git2::{Branch, BranchType, Repository};
|
use git2::{Branch, BranchType, Remote, Repository};
|
||||||
use std::env;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
const DEFAULT_REMOTE_NAME: &str = "origin";
|
const DEFAULT_REMOTE_NAME: &str = "origin";
|
||||||
|
|
||||||
pub(crate) fn find_ssh_key() -> Option<PathBuf> {
|
|
||||||
let home_dir = env::var("HOME").ok()?;
|
|
||||||
let key_paths = [
|
|
||||||
format!("{}/.ssh/id_ed25519", home_dir),
|
|
||||||
format!("{}/.ssh/id_rsa", home_dir),
|
|
||||||
format!("{}/.ssh/id_ecdsa", home_dir),
|
|
||||||
format!("{}/.ssh/id_dsa", home_dir),
|
|
||||||
];
|
|
||||||
|
|
||||||
for key_path in key_paths.iter() {
|
|
||||||
let path = Path::new(key_path);
|
|
||||||
if path.exists() {
|
|
||||||
return Some(path.to_path_buf());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch<'_>>> {
|
pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch<'_>>> {
|
||||||
for b in repo.branches(None)? {
|
for b in repo.branches(None)? {
|
||||||
let branch = b?.0;
|
let branch = b?.0;
|
||||||
@@ -34,10 +14,18 @@ pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch<'_>>
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_current_branch_name(repo: &Repository) -> Result<String> {
|
||||||
|
Ok(get_current_branch(&repo)?
|
||||||
|
.ok_or(GenericError("Failed to get current branch".to_string()))?
|
||||||
|
.name()?
|
||||||
|
.ok_or(GenericError("Failed to get current branch name".to_string()))?
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn local_branch_names(repo: &Repository) -> Result<Vec<String>> {
|
pub(crate) fn local_branch_names(repo: &Repository) -> Result<Vec<String>> {
|
||||||
let mut branches = Vec::new();
|
let mut branches = Vec::new();
|
||||||
for branch in repo.branches(Some(BranchType::Local))? {
|
for branch in repo.branches(Some(BranchType::Local))? {
|
||||||
let branch = branch?.0;
|
let (branch, _) = branch?;
|
||||||
let name = branch.name_bytes()?;
|
let name = branch.name_bytes()?;
|
||||||
let name = bytes_to_string(name)?;
|
let name = bytes_to_string(name)?;
|
||||||
branches.push(name);
|
branches.push(name);
|
||||||
@@ -48,9 +36,12 @@ pub(crate) fn local_branch_names(repo: &Repository) -> Result<Vec<String>> {
|
|||||||
pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
|
pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
|
||||||
let mut branches = Vec::new();
|
let mut branches = Vec::new();
|
||||||
for branch in repo.branches(Some(BranchType::Remote))? {
|
for branch in repo.branches(Some(BranchType::Remote))? {
|
||||||
let branch = branch?.0;
|
let (branch, _) = branch?;
|
||||||
let name = branch.name_bytes()?;
|
let name = branch.name_bytes()?;
|
||||||
let name = bytes_to_string(name)?;
|
let name = bytes_to_string(name)?;
|
||||||
|
if name.ends_with("/HEAD") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
branches.push(name);
|
branches.push(name);
|
||||||
}
|
}
|
||||||
Ok(branches)
|
Ok(branches)
|
||||||
@@ -64,7 +55,13 @@ pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {
|
|||||||
Ok(String::from_utf8(bytes.to_vec())?)
|
Ok(String::from_utf8(bytes.to_vec())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_default_remote_for_push_in_repo(repo: &Repository) -> Result<String> {
|
pub(crate) fn get_default_remote_for_push_in_repo(repo: &'_ Repository) -> Result<Remote<'_>> {
|
||||||
|
let name = get_default_remote_name_for_push_in_repo(repo)?;
|
||||||
|
let remote = repo.find_remote(&name)?;
|
||||||
|
Ok(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_default_remote_name_for_push_in_repo(repo: &Repository) -> Result<String> {
|
||||||
let config = repo.config()?;
|
let config = repo.config()?;
|
||||||
|
|
||||||
let branch = get_current_branch(repo)?;
|
let branch = get_current_branch(repo)?;
|
||||||
@@ -89,12 +86,22 @@ pub(crate) fn get_default_remote_for_push_in_repo(repo: &Repository) -> Result<S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get_default_remote_in_repo(repo)
|
get_default_remote_name_in_repo(repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_default_remote_in_repo(repo: &Repository) -> Result<String> {
|
pub(crate) fn get_default_remote_in_repo(repo: &'_ Repository) -> Result<Remote<'_>> {
|
||||||
|
let name = get_default_remote_name_in_repo(repo)?;
|
||||||
|
let remote = repo.find_remote(&name)?;
|
||||||
|
Ok(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_default_remote_name_in_repo(repo: &Repository) -> Result<String> {
|
||||||
let remotes = repo.remotes()?;
|
let remotes = repo.remotes()?;
|
||||||
|
|
||||||
|
if remotes.is_empty() {
|
||||||
|
return Err(NoDefaultRemoteFound);
|
||||||
|
}
|
||||||
|
|
||||||
// if `origin` exists return that
|
// if `origin` exists return that
|
||||||
let found_origin = remotes.iter().any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME));
|
let found_origin = remotes.iter().any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME));
|
||||||
if found_origin {
|
if found_origin {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useGitInit } from '@yaakapp-internal/git';
|
import { gitMutations } from '@yaakapp-internal/git';
|
||||||
import type { WorkspaceMeta } from '@yaakapp-internal/models';
|
import type { WorkspaceMeta } from '@yaakapp-internal/models';
|
||||||
import { createGlobalModel, updateModel } from '@yaakapp-internal/models';
|
import { createGlobalModel, updateModel } from '@yaakapp-internal/models';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -12,6 +12,7 @@ import { Label } from './core/Label';
|
|||||||
import { PlainInput } from './core/PlainInput';
|
import { PlainInput } from './core/PlainInput';
|
||||||
import { VStack } from './core/Stacks';
|
import { VStack } from './core/Stacks';
|
||||||
import { EncryptionHelp } from './EncryptionHelp';
|
import { EncryptionHelp } from './EncryptionHelp';
|
||||||
|
import { gitCallbacks } from './git/callbacks';
|
||||||
import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
|
import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,7 +21,6 @@ interface Props {
|
|||||||
|
|
||||||
export function CreateWorkspaceDialog({ hide }: Props) {
|
export function CreateWorkspaceDialog({ hide }: Props) {
|
||||||
const [name, setName] = useState<string>('');
|
const [name, setName] = useState<string>('');
|
||||||
const gitInit = useGitInit();
|
|
||||||
const [syncConfig, setSyncConfig] = useState<{
|
const [syncConfig, setSyncConfig] = useState<{
|
||||||
filePath: string | null;
|
filePath: string | null;
|
||||||
initGit?: boolean;
|
initGit?: boolean;
|
||||||
@@ -48,9 +48,11 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (syncConfig.initGit && syncConfig.filePath) {
|
if (syncConfig.initGit && syncConfig.filePath) {
|
||||||
gitInit.mutateAsync({ dir: syncConfig.filePath }).catch((err) => {
|
gitMutations(syncConfig.filePath, gitCallbacks(syncConfig.filePath))
|
||||||
showErrorToast('git-init-error', String(err));
|
.init.mutateAsync()
|
||||||
});
|
.catch((err) => {
|
||||||
|
showErrorToast('git-init-error', String(err));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to workspace
|
// Navigate to workspace
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ function DialogInstance({ render: Component, onClose, id, ...props }: DialogInst
|
|||||||
}, [id, onClose]);
|
}, [id, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary name={`Dialog ${id}`}>
|
<Dialog open onClose={handleClose} {...props}>
|
||||||
<Dialog open onClose={handleClose} {...props}>
|
<ErrorBoundary name={`Dialog ${id}`}>
|
||||||
<Component hide={hide} {...props} />
|
<Component hide={hide} {...props} />
|
||||||
</Dialog>
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ import { Checkbox } from './core/Checkbox';
|
|||||||
import { DetailsBanner } from './core/DetailsBanner';
|
import { DetailsBanner } from './core/DetailsBanner';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
import { IconButton } from './core/IconButton';
|
import { IconButton } from './core/IconButton';
|
||||||
|
import type { InputProps } from './core/Input';
|
||||||
import { Input } from './core/Input';
|
import { Input } from './core/Input';
|
||||||
import { Label } from './core/Label';
|
import { Label } from './core/Label';
|
||||||
|
import { PlainInput } from './core/PlainInput';
|
||||||
import { Select } from './core/Select';
|
import { Select } from './core/Select';
|
||||||
import { VStack } from './core/Stacks';
|
import { VStack } from './core/Stacks';
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
@@ -269,28 +271,31 @@ function TextArg({
|
|||||||
autocompleteVariables: boolean;
|
autocompleteVariables: boolean;
|
||||||
stateKey: string;
|
stateKey: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const props: InputProps = {
|
||||||
<Input
|
onChange,
|
||||||
name={arg.name}
|
name: arg.name,
|
||||||
multiLine={arg.multiLine}
|
multiLine: arg.multiLine,
|
||||||
onChange={onChange}
|
className: arg.multiLine ? 'min-h-[4rem]' : undefined,
|
||||||
className={arg.multiLine ? 'min-h-[4rem]' : undefined}
|
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
|
||||||
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
required: !arg.optional,
|
||||||
required={!arg.optional}
|
disabled: arg.disabled,
|
||||||
disabled={arg.disabled}
|
help: arg.description,
|
||||||
help={arg.description}
|
type: arg.password ? 'password' : 'text',
|
||||||
type={arg.password ? 'password' : 'text'}
|
label: arg.label ?? arg.name,
|
||||||
label={arg.label ?? arg.name}
|
size: INPUT_SIZE,
|
||||||
size={INPUT_SIZE}
|
hideLabel: arg.hideLabel ?? arg.label == null,
|
||||||
hideLabel={arg.hideLabel ?? arg.label == null}
|
placeholder: arg.placeholder ?? undefined,
|
||||||
placeholder={arg.placeholder ?? undefined}
|
forceUpdateKey: stateKey,
|
||||||
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
|
autocomplete: arg.completionOptions ? { options: arg.completionOptions } : undefined,
|
||||||
autocompleteFunctions={autocompleteFunctions}
|
stateKey,
|
||||||
autocompleteVariables={autocompleteVariables}
|
autocompleteFunctions,
|
||||||
stateKey={stateKey}
|
autocompleteVariables,
|
||||||
forceUpdateKey={stateKey}
|
};
|
||||||
/>
|
if (autocompleteVariables || autocompleteFunctions) {
|
||||||
);
|
return <Input {...props} />;
|
||||||
|
} else {
|
||||||
|
return <PlainInput {...props} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditorArg({
|
function EditorArg({
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EnvironmentEditor
|
<EnvironmentEditor
|
||||||
|
key={selectedEnvironment.id}
|
||||||
setRef={setRef}
|
setRef={setRef}
|
||||||
className="pl-4 pt-3"
|
className="pl-4 pt-3"
|
||||||
environment={selectedEnvironment}
|
environment={selectedEnvironment}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ import type {
|
|||||||
WebsocketRequest,
|
WebsocketRequest,
|
||||||
Workspace,
|
Workspace,
|
||||||
} from '@yaakapp-internal/models';
|
} from '@yaakapp-internal/models';
|
||||||
|
import {
|
||||||
|
duplicateModel,
|
||||||
|
foldersAtom,
|
||||||
|
getAnyModel,
|
||||||
|
getModel,
|
||||||
|
grpcConnectionsAtom,
|
||||||
|
httpResponsesAtom,
|
||||||
|
patchModel,
|
||||||
|
websocketConnectionsAtom,
|
||||||
|
workspacesAtom,
|
||||||
|
} from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
@@ -55,18 +66,7 @@ import type { TreeNode } from './core/tree/common';
|
|||||||
import type { TreeHandle, TreeProps } from './core/tree/Tree';
|
import type { TreeHandle, TreeProps } from './core/tree/Tree';
|
||||||
import { Tree } from './core/tree/Tree';
|
import { Tree } from './core/tree/Tree';
|
||||||
import type { TreeItemProps } from './core/tree/TreeItem';
|
import type { TreeItemProps } from './core/tree/TreeItem';
|
||||||
import { GitDropdown } from './GitDropdown';
|
import { GitDropdown } from './git/GitDropdown';
|
||||||
import {
|
|
||||||
getAnyModel,
|
|
||||||
duplicateModel,
|
|
||||||
foldersAtom,
|
|
||||||
getModel,
|
|
||||||
grpcConnectionsAtom,
|
|
||||||
httpResponsesAtom,
|
|
||||||
patchModel,
|
|
||||||
websocketConnectionsAtom,
|
|
||||||
workspacesAtom,
|
|
||||||
} from '@yaakapp-internal/models';
|
|
||||||
|
|
||||||
type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
|
type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
|
||||||
function isSidebarLeafModel(m: AnyModel): boolean {
|
function isSidebarLeafModel(m: AnyModel): boolean {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function Banner({ children, className, color }: BannerProps) {
|
|||||||
color && 'bg-surface',
|
color && 'bg-surface',
|
||||||
`x-theme-banner--${color}`,
|
`x-theme-banner--${color}`,
|
||||||
'border border-border border-dashed',
|
'border border-border border-dashed',
|
||||||
'px-4 py-2 rounded-lg select-auto',
|
'px-4 py-2 rounded-lg select-auto cursor-auto',
|
||||||
'overflow-auto text-text',
|
'overflow-auto text-text',
|
||||||
'mb-auto', // Don't stretch all the way down if the parent is in grid or flexbox
|
'mb-auto', // Don't stretch all the way down if the parent is in grid or flexbox
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
GitPullRequestIcon,
|
GitPullRequestIcon,
|
||||||
GripVerticalIcon,
|
GripVerticalIcon,
|
||||||
HandIcon,
|
HandIcon,
|
||||||
|
HardDriveDownloadIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
ImportIcon,
|
ImportIcon,
|
||||||
@@ -202,6 +203,7 @@ const icons = {
|
|||||||
grip_vertical: GripVerticalIcon,
|
grip_vertical: GripVerticalIcon,
|
||||||
circle_off: CircleOffIcon,
|
circle_off: CircleOffIcon,
|
||||||
hand: HandIcon,
|
hand: HandIcon,
|
||||||
|
hard_drive_download: HardDriveDownloadIcon,
|
||||||
help: CircleHelpIcon,
|
help: CircleHelpIcon,
|
||||||
history: HistoryIcon,
|
history: HistoryIcon,
|
||||||
house: HomeIcon,
|
house: HomeIcon,
|
||||||
|
|||||||
@@ -16,7 +16,18 @@ import type { InputProps } from './Input';
|
|||||||
import { Label } from './Label';
|
import { Label } from './Label';
|
||||||
import { HStack } from './Stacks';
|
import { HStack } from './Stacks';
|
||||||
|
|
||||||
export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type' | 'stateKey'> &
|
export type PlainInputProps = Omit<
|
||||||
|
InputProps,
|
||||||
|
| 'wrapLines'
|
||||||
|
| 'onKeyDown'
|
||||||
|
| 'type'
|
||||||
|
| 'stateKey'
|
||||||
|
| 'autocompleteVariables'
|
||||||
|
| 'autocompleteFunctions'
|
||||||
|
| 'autocomplete'
|
||||||
|
| 'extraExtensions'
|
||||||
|
| 'forcedEnvironmentId'
|
||||||
|
> &
|
||||||
Pick<HTMLAttributes<HTMLInputElement>, 'onKeyDownCapture'> & {
|
Pick<HTMLAttributes<HTMLInputElement>, 'onKeyDownCapture'> & {
|
||||||
onFocusRaw?: HTMLAttributes<HTMLInputElement>['onFocus'];
|
onFocusRaw?: HTMLAttributes<HTMLInputElement>['onFocus'];
|
||||||
type?: 'text' | 'password' | 'number';
|
type?: 'text' | 'password' | 'number';
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
import type { PromptTextRequest } from '@yaakapp-internal/plugins';
|
import type { FormInput, JsonPrimitive } from '@yaakapp-internal/plugins';
|
||||||
import type { FormEvent, ReactNode } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { generateId } from '../../lib/generateId';
|
||||||
|
import { DynamicForm } from '../DynamicForm';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { PlainInput } from './PlainInput';
|
|
||||||
import { HStack } from './Stacks';
|
import { HStack } from './Stacks';
|
||||||
|
|
||||||
export type PromptProps = Omit<PromptTextRequest, 'id' | 'title' | 'description'> & {
|
export interface PromptProps {
|
||||||
description?: ReactNode;
|
inputs: FormInput[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onResult: (value: string | null) => void;
|
onResult: (value: Record<string, JsonPrimitive> | null) => void;
|
||||||
};
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function Prompt({
|
export function Prompt({
|
||||||
onCancel,
|
onCancel,
|
||||||
label,
|
inputs,
|
||||||
defaultValue,
|
|
||||||
placeholder,
|
|
||||||
password,
|
|
||||||
onResult,
|
onResult,
|
||||||
required,
|
confirmText = 'Confirm',
|
||||||
confirmText,
|
cancelText = 'Cancel',
|
||||||
cancelText,
|
|
||||||
}: PromptProps) {
|
}: PromptProps) {
|
||||||
const [value, setValue] = useState<string>(defaultValue ?? '');
|
const [value, setValue] = useState<Record<string, JsonPrimitive>>({});
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(e: FormEvent<HTMLFormElement>) => {
|
(e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -31,20 +30,14 @@ export function Prompt({
|
|||||||
[onResult, value],
|
[onResult, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const id = 'prompt.form.' + useRef(generateId()).current;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
|
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
<PlainInput
|
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
|
||||||
autoSelect
|
|
||||||
required={required}
|
|
||||||
placeholder={placeholder ?? 'Enter text'}
|
|
||||||
type={password ? 'password' : 'text'}
|
|
||||||
label={label}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
onChange={setValue}
|
|
||||||
/>
|
|
||||||
<HStack space={2} justifyContent="end">
|
<HStack space={2} justifyContent="end">
|
||||||
<Button onClick={onCancel} variant="border" color="secondary">
|
<Button onClick={onCancel} variant="border" color="secondary">
|
||||||
{cancelText || 'Cancel'}
|
{cancelText || 'Cancel'}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
|||||||
[open],
|
[open],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toastIcon = icon === null ? null : icon ?? (color && color in ICONS && ICONS[color]);
|
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<m.div
|
<m.div
|
||||||
@@ -64,7 +64,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
|||||||
<div className="px-3 py-3 flex items-start gap-2 w-full">
|
<div className="px-3 py-3 flex items-start gap-2 w-full">
|
||||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1" />}
|
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1" />}
|
||||||
<VStack space={2} className="w-full">
|
<VStack space={2} className="w-full">
|
||||||
<div>{children}</div>
|
<div className="select-auto">{children}</div>
|
||||||
{action?.({ hide: onClose })}
|
{action?.({ hide: onClose })}
|
||||||
</VStack>
|
</VStack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,19 +11,21 @@ import type {
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
import { resolvedModelName } from '../../lib/resolvedModelName';
|
||||||
import { showErrorToast, showToast } from '../lib/toast';
|
import { showErrorToast } from '../../lib/toast';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from '../core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from '../core/Button';
|
||||||
import type { CheckboxProps } from './core/Checkbox';
|
import type { CheckboxProps } from '../core/Checkbox';
|
||||||
import { Checkbox } from './core/Checkbox';
|
import { Checkbox } from '../core/Checkbox';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from '../core/Icon';
|
||||||
import { InlineCode } from './core/InlineCode';
|
import { InlineCode } from '../core/InlineCode';
|
||||||
import { Input } from './core/Input';
|
import { Input } from '../core/Input';
|
||||||
import { Separator } from './core/Separator';
|
import { Separator } from '../core/Separator';
|
||||||
import { SplitLayout } from './core/SplitLayout';
|
import { SplitLayout } from '../core/SplitLayout';
|
||||||
import { HStack } from './core/Stacks';
|
import { HStack } from '../core/Stacks';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from '../EmptyStateText';
|
||||||
|
import { handlePushResult } from './git-util';
|
||||||
|
import { gitCallbacks } from './callbacks';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
syncDir: string;
|
syncDir: string;
|
||||||
@@ -39,25 +41,34 @@ interface CommitTreeNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||||
const [{ status }, { commit, commitAndPush, add, unstage, push }] = useGit(syncDir);
|
const [{ status }, { commit, commitAndPush, add, unstage }] = useGit(
|
||||||
|
syncDir,
|
||||||
|
gitCallbacks(syncDir),
|
||||||
|
);
|
||||||
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
|
const [commitError, setCommitError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string>('');
|
const [message, setMessage] = useState<string>('');
|
||||||
|
|
||||||
const handleCreateCommit = async () => {
|
const handleCreateCommit = async () => {
|
||||||
|
setCommitError(null);
|
||||||
try {
|
try {
|
||||||
await commit.mutateAsync({ message });
|
await commit.mutateAsync({ message });
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast('git-commit-error', String(err));
|
setCommitError(String(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateCommitAndPush = async () => {
|
const handleCreateCommitAndPush = async () => {
|
||||||
|
setIsPushing(true);
|
||||||
try {
|
try {
|
||||||
await commitAndPush.mutateAsync({ message });
|
const r = await commitAndPush.mutateAsync({ message });
|
||||||
showToast({ id: 'git-push-success', message: 'Pushed changes', color: 'success' });
|
handlePushResult(r);
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast('git-commit-and-push-error', String(err));
|
showErrorToast('git-commit-and-push-error', String(err));
|
||||||
|
} finally {
|
||||||
|
setIsPushing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,7 +92,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
const hasAnythingToAdd = allEntries.find((e) => e.status !== 'current') != null;
|
const hasAnythingToAdd = allEntries.find((e) => e.status !== 'current') != null;
|
||||||
|
|
||||||
const tree: CommitTreeNode | null = useMemo(() => {
|
const tree: CommitTreeNode | null = useMemo(() => {
|
||||||
const next = (model: CommitTreeNode['model'], ancestors: CommitTreeNode[]): CommitTreeNode | null => {
|
const next = (
|
||||||
|
model: CommitTreeNode['model'],
|
||||||
|
ancestors: CommitTreeNode[],
|
||||||
|
): CommitTreeNode | null => {
|
||||||
const statusEntry = internalEntries?.find((s) => s.relaPath.includes(model.id));
|
const statusEntry = internalEntries?.find((s) => s.relaPath.includes(model.id));
|
||||||
if (statusEntry == null) {
|
if (statusEntry == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -147,7 +161,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
defaultRatio={0.3}
|
defaultRatio={0.3}
|
||||||
firstSlot={({ style }) => (
|
firstSlot={({ style }) => (
|
||||||
<div style={style} className="h-full overflow-y-auto -ml-1 pb-3">
|
<div style={style} className="h-full overflow-y-auto pb-3">
|
||||||
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} />
|
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} />
|
||||||
{externalEntries.find((e) => e.status !== 'current') && (
|
{externalEntries.find((e) => e.status !== 'current') && (
|
||||||
<>
|
<>
|
||||||
@@ -175,7 +189,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
multiLine
|
multiLine
|
||||||
hideLabel
|
hideLabel
|
||||||
/>
|
/>
|
||||||
{commit.error && <Banner color="danger">{commit.error}</Banner>}
|
{commitError && <Banner color="danger">{commitError}</Banner>}
|
||||||
<HStack alignItems="center">
|
<HStack alignItems="center">
|
||||||
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
|
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
|
||||||
<HStack space={2} className="ml-auto">
|
<HStack space={2} className="ml-auto">
|
||||||
@@ -184,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCreateCommit}
|
onClick={handleCreateCommit}
|
||||||
disabled={!hasAddedAnything}
|
disabled={!hasAddedAnything}
|
||||||
isLoading={push.isPending || commitAndPush.isPending || commit.isPending}
|
isLoading={isPushing}
|
||||||
>
|
>
|
||||||
Commit
|
Commit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -193,7 +207,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={!hasAddedAnything}
|
disabled={!hasAddedAnything}
|
||||||
onClick={handleCreateCommitAndPush}
|
onClick={handleCreateCommitAndPush}
|
||||||
isLoading={push.isPending || commitAndPush.isPending || commit.isPending}
|
isLoading={isPushing}
|
||||||
>
|
>
|
||||||
Commit and Push
|
Commit and Push
|
||||||
</Button>
|
</Button>
|
||||||
@@ -4,22 +4,25 @@ import classNames from 'classnames';
|
|||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
|
import { openWorkspaceSettings } from '../../commands/openWorkspaceSettings';
|
||||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
|
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../../hooks/useActiveWorkspace';
|
||||||
import { useKeyValue } from '../hooks/useKeyValue';
|
import { useKeyValue } from '../../hooks/useKeyValue';
|
||||||
import { sync } from '../init/sync';
|
import { sync } from '../../init/sync';
|
||||||
import { showConfirm, showConfirmDelete } from '../lib/confirm';
|
import { showConfirm, showConfirmDelete } from '../../lib/confirm';
|
||||||
import { showDialog } from '../lib/dialog';
|
import { showDialog } from '../../lib/dialog';
|
||||||
import { showPrompt } from '../lib/prompt';
|
import { showPrompt } from '../../lib/prompt';
|
||||||
import { showErrorToast, showToast } from '../lib/toast';
|
import { showErrorToast, showToast } from '../../lib/toast';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from '../core/Banner';
|
||||||
import type { DropdownItem } from './core/Dropdown';
|
import type { DropdownItem } from '../core/Dropdown';
|
||||||
import { Dropdown } from './core/Dropdown';
|
import { Dropdown } from '../core/Dropdown';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from '../core/Icon';
|
||||||
import { InlineCode } from './core/InlineCode';
|
import { InlineCode } from '../core/InlineCode';
|
||||||
import { BranchSelectionDialog } from './git/BranchSelectionDialog';
|
import { BranchSelectionDialog } from './BranchSelectionDialog';
|
||||||
import { HistoryDialog } from './git/HistoryDialog';
|
import { gitCallbacks } from './callbacks';
|
||||||
|
import { handlePullResult } from './git-util';
|
||||||
import { GitCommitDialog } from './GitCommitDialog';
|
import { GitCommitDialog } from './GitCommitDialog';
|
||||||
|
import { GitRemotesDialog } from './GitRemotesDialog';
|
||||||
|
import { HistoryDialog } from './HistoryDialog';
|
||||||
|
|
||||||
export function GitDropdown() {
|
export function GitDropdown() {
|
||||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
||||||
@@ -37,7 +40,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
const [
|
const [
|
||||||
{ status, log },
|
{ status, log },
|
||||||
{ branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init },
|
{ branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init },
|
||||||
] = useGit(syncDir);
|
] = useGit(syncDir, gitCallbacks(syncDir));
|
||||||
|
|
||||||
const localBranches = status.data?.localBranches ?? [];
|
const localBranches = status.data?.localBranches ?? [];
|
||||||
const remoteBranches = status.data?.remoteBranches ?? [];
|
const remoteBranches = status.data?.remoteBranches ?? [];
|
||||||
@@ -52,9 +55,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
|
|
||||||
const noRepo = status.error?.includes('not found');
|
const noRepo = status.error?.includes('not found');
|
||||||
if (noRepo) {
|
if (noRepo) {
|
||||||
return (
|
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
|
||||||
<SetupGitDropdown workspaceId={workspace.id} initRepo={() => init.mutate({ dir: syncDir })} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tryCheckout = (branch: string, force: boolean) => {
|
const tryCheckout = (branch: string, force: boolean) => {
|
||||||
@@ -110,6 +111,12 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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" />,
|
leftSlot: <Icon icon="git_branch_plus" />,
|
||||||
@@ -119,17 +126,17 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
title: 'Create Branch',
|
title: 'Create Branch',
|
||||||
label: 'Branch Name',
|
label: 'Branch Name',
|
||||||
});
|
});
|
||||||
if (name) {
|
if (!name) return;
|
||||||
await branch.mutateAsync(
|
|
||||||
{ branch: name },
|
await branch.mutateAsync(
|
||||||
{
|
{ branch: name },
|
||||||
onError: (err) => {
|
{
|
||||||
showErrorToast('git-branch-error', String(err));
|
onError: (err) => {
|
||||||
},
|
showErrorToast('git-branch-error', String(err));
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
tryCheckout(name, false);
|
);
|
||||||
}
|
tryCheckout(name, false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -214,18 +221,11 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Push',
|
label: 'Push',
|
||||||
hidden: (status.data?.origins ?? []).length === 0,
|
|
||||||
leftSlot: <Icon icon="arrow_up_from_line" />,
|
leftSlot: <Icon icon="arrow_up_from_line" />,
|
||||||
waitForOnSelect: true,
|
waitForOnSelect: true,
|
||||||
async onSelect() {
|
async onSelect() {
|
||||||
push.mutate(undefined, {
|
await push.mutateAsync(undefined, {
|
||||||
onSuccess(message) {
|
onSuccess: handlePullResult,
|
||||||
if (message === 'nothing_to_push') {
|
|
||||||
showToast({ id: 'push-success', message: 'Nothing to push', color: 'info' });
|
|
||||||
} else {
|
|
||||||
showToast({ id: 'push-success', message: 'Push successful', color: 'success' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showErrorToast('git-pull-error', String(err));
|
showErrorToast('git-pull-error', String(err));
|
||||||
},
|
},
|
||||||
@@ -238,26 +238,17 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
|||||||
leftSlot: <Icon icon="arrow_down_to_line" />,
|
leftSlot: <Icon icon="arrow_down_to_line" />,
|
||||||
waitForOnSelect: true,
|
waitForOnSelect: true,
|
||||||
async onSelect() {
|
async onSelect() {
|
||||||
const result = await pull.mutateAsync(undefined, {
|
await pull.mutateAsync(undefined, {
|
||||||
|
onSuccess: handlePullResult,
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showErrorToast('git-pull-error', String(err));
|
showErrorToast('git-pull-error', String(err));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result.receivedObjects > 0) {
|
|
||||||
showToast({
|
|
||||||
id: 'git-pull-success',
|
|
||||||
message: `Pulled ${result.receivedObjects} objects`,
|
|
||||||
color: 'success',
|
|
||||||
});
|
|
||||||
await sync({ force: true });
|
|
||||||
} else {
|
|
||||||
showToast({ id: 'git-pull-success', message: 'Already up to date', color: 'info' });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Commit',
|
label: 'Commit',
|
||||||
leftSlot: <Icon icon="git_branch" />,
|
leftSlot: <Icon icon="git_commit_vertical" />,
|
||||||
onSelect() {
|
onSelect() {
|
||||||
showDialog({
|
showDialog({
|
||||||
id: 'commit',
|
id: 'commit',
|
||||||
67
src-web/components/git/GitRemotesDialog.tsx
Normal file
67
src-web/components/git/GitRemotesDialog.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useGit } from '@yaakapp-internal/git';
|
||||||
|
import { showDialog } from '../../lib/dialog';
|
||||||
|
import { Button } from '../core/Button';
|
||||||
|
import { IconButton } from '../core/IconButton';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
|
||||||
|
import { gitCallbacks } from './callbacks';
|
||||||
|
import { addGitRemote } from './showAddRemoteDialog';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dir: string;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitRemotesDialog({ dir }: Props) {
|
||||||
|
const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeaderCell>Name</TableHeaderCell>
|
||||||
|
<TableHeaderCell>URL</TableHeaderCell>
|
||||||
|
<TableHeaderCell>
|
||||||
|
<Button
|
||||||
|
className="text-text-subtle ml-auto"
|
||||||
|
size="2xs"
|
||||||
|
color="primary"
|
||||||
|
title="Add remote"
|
||||||
|
variant="border"
|
||||||
|
onClick={() => addGitRemote(dir)}
|
||||||
|
>
|
||||||
|
Add Remote
|
||||||
|
</Button>
|
||||||
|
</TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{remotes.data?.map((r, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>{r.name}</TableCell>
|
||||||
|
<TableCell>{r.url}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
className="text-text-subtle ml-auto"
|
||||||
|
icon="trash"
|
||||||
|
title="Remove remote"
|
||||||
|
onClick={() => rmRemote.mutate({ name: r.name })}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GitRemotesDialog.show = function (dir: string) {
|
||||||
|
showDialog({
|
||||||
|
id: 'git-remotes',
|
||||||
|
title: 'Manage Remotes',
|
||||||
|
size: 'md',
|
||||||
|
render: ({ hide }) => <GitRemotesDialog onDone={hide} dir={dir} />,
|
||||||
|
});
|
||||||
|
};
|
||||||
48
src-web/components/git/callbacks.tsx
Normal file
48
src-web/components/git/callbacks.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { GitCallbacks } from '@yaakapp-internal/git';
|
||||||
|
import { showPromptForm } from '../../lib/prompt-form';
|
||||||
|
import { Banner } from '../core/Banner';
|
||||||
|
import { InlineCode } from '../core/InlineCode';
|
||||||
|
import { addGitRemote } from './showAddRemoteDialog';
|
||||||
|
|
||||||
|
export function gitCallbacks(dir: string): GitCallbacks {
|
||||||
|
return {
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
30
src-web/components/git/git-util.ts
Normal file
30
src-web/components/git/git-util.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PullResult, PushResult } from '@yaakapp-internal/git';
|
||||||
|
import { showToast } from '../../lib/toast';
|
||||||
|
|
||||||
|
export function handlePushResult(r: PushResult) {
|
||||||
|
switch (r.type) {
|
||||||
|
case 'needs_credentials':
|
||||||
|
showToast({ id: 'push-error', message: 'Credentials not found', color: 'danger' });
|
||||||
|
break;
|
||||||
|
case 'success':
|
||||||
|
showToast({ id: 'push-success', message: r.message, color: 'success' });
|
||||||
|
break;
|
||||||
|
case 'up_to_date':
|
||||||
|
showToast({ id: 'push-nothing', message: 'Already up-to-date', color: 'info' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePullResult(r: PullResult) {
|
||||||
|
switch (r.type) {
|
||||||
|
case 'needs_credentials':
|
||||||
|
showToast({ id: 'pull-error', message: 'Credentials not found', color: 'danger' });
|
||||||
|
break;
|
||||||
|
case 'success':
|
||||||
|
showToast({ id: 'pull-success', message: r.message, color: 'success' });
|
||||||
|
break;
|
||||||
|
case 'up_to_date':
|
||||||
|
showToast({ id: 'pull-nothing', message: 'Already up-to-date', color: 'info' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src-web/components/git/showAddRemoteDialog.tsx
Normal file
20
src-web/components/git/showAddRemoteDialog.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { GitRemote } from '@yaakapp-internal/git';
|
||||||
|
import { gitMutations } from '@yaakapp-internal/git';
|
||||||
|
import { showPromptForm } from '../../lib/prompt-form';
|
||||||
|
import { gitCallbacks } from './callbacks';
|
||||||
|
|
||||||
|
export async function addGitRemote(dir: string): Promise<GitRemote> {
|
||||||
|
const r = await showPromptForm({
|
||||||
|
id: 'add-remote',
|
||||||
|
title: 'Add Remote',
|
||||||
|
inputs: [
|
||||||
|
{ type: 'text', label: 'Name', name: 'name' },
|
||||||
|
{ type: 'text', label: 'URL', name: 'url' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (r == null) throw new Error('Cancelled remote prompt');
|
||||||
|
|
||||||
|
const name = String(r.name ?? '');
|
||||||
|
const url = String(r.url ?? '');
|
||||||
|
return gitMutations(dir, gitCallbacks(dir)).addRemote.mutateAsync({ name, url });
|
||||||
|
}
|
||||||
@@ -24,7 +24,13 @@ type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> &
|
|||||||
request: HttpRequest;
|
request: HttpRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorProps }: Props) {
|
export function GraphQLEditor(props: Props) {
|
||||||
|
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
||||||
|
// key on the request ID as a workaround for now.
|
||||||
|
return <GraphQLEditorInner key={props.request.id} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProps }: Props) {
|
||||||
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>('graphQLAutoIntrospectDisabled', {});
|
>('graphQLAutoIntrospectDisabled', {});
|
||||||
|
|||||||
39
src-web/lib/prompt-form.tsx
Normal file
39
src-web/lib/prompt-form.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DialogProps } from '../components/core/Dialog';
|
||||||
|
import type { PromptProps } from '../components/core/Prompt';
|
||||||
|
import { Prompt } from '../components/core/Prompt';
|
||||||
|
import { showDialog } from './dialog';
|
||||||
|
|
||||||
|
type FormArgs = Pick<DialogProps, 'title' | 'description'> &
|
||||||
|
Omit<PromptProps, 'onClose' | 'onCancel' | 'onResult'> & {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function showPromptForm({ id, title, description, ...props }: FormArgs) {
|
||||||
|
return new Promise((resolve: PromptProps['onResult']) => {
|
||||||
|
showDialog({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
hideX: true,
|
||||||
|
size: 'sm',
|
||||||
|
disableBackdropClose: true, // Prevent accidental dismisses
|
||||||
|
onClose: () => {
|
||||||
|
// Click backdrop, close, or escape
|
||||||
|
resolve(null);
|
||||||
|
},
|
||||||
|
render: ({ hide }) =>
|
||||||
|
Prompt({
|
||||||
|
onCancel: () => {
|
||||||
|
// Click cancel button within dialog
|
||||||
|
resolve(null);
|
||||||
|
hide();
|
||||||
|
},
|
||||||
|
onResult: (v) => {
|
||||||
|
resolve(v);
|
||||||
|
hide();
|
||||||
|
},
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import type { FormInput, PromptTextRequest } from '@yaakapp-internal/plugins';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import type { DialogProps } from '../components/core/Dialog';
|
import type { DialogProps } from '../components/core/Dialog';
|
||||||
import type { PromptProps } from '../components/core/Prompt';
|
import { showPromptForm } from './prompt-form';
|
||||||
import { Prompt } from '../components/core/Prompt';
|
|
||||||
import { showDialog } from './dialog';
|
type PromptProps = Omit<PromptTextRequest, 'id' | 'title' | 'description'> & {
|
||||||
|
description?: ReactNode;
|
||||||
|
onCancel: () => void;
|
||||||
|
onResult: (value: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type PromptArgs = Pick<DialogProps, 'title' | 'description'> &
|
type PromptArgs = Pick<DialogProps, 'title' | 'description'> &
|
||||||
Omit<PromptProps, 'onClose' | 'onCancel' | 'onResult'> & { id: string };
|
Omit<PromptProps, 'onClose' | 'onCancel' | 'onResult'> & { id: string };
|
||||||
@@ -10,35 +16,26 @@ export async function showPrompt({
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
required = true,
|
cancelText,
|
||||||
|
confirmText,
|
||||||
...props
|
...props
|
||||||
}: PromptArgs) {
|
}: PromptArgs) {
|
||||||
return new Promise((resolve: PromptProps['onResult']) => {
|
const inputs: FormInput[] = [
|
||||||
showDialog({
|
{
|
||||||
id,
|
type: 'text',
|
||||||
title,
|
name: 'value',
|
||||||
description,
|
...props,
|
||||||
hideX: true,
|
},
|
||||||
size: 'sm',
|
];
|
||||||
disableBackdropClose: true, // Prevent accidental dismisses
|
|
||||||
onClose: () => {
|
const result = await showPromptForm({
|
||||||
// Click backdrop, close, or escape
|
id,
|
||||||
resolve(null);
|
title,
|
||||||
},
|
description,
|
||||||
render: ({ hide }) =>
|
inputs,
|
||||||
Prompt({
|
cancelText,
|
||||||
required,
|
confirmText,
|
||||||
onCancel: () => {
|
|
||||||
// Click cancel button within dialog
|
|
||||||
resolve(null);
|
|
||||||
hide();
|
|
||||||
},
|
|
||||||
onResult: (v) => {
|
|
||||||
resolve(v);
|
|
||||||
hide();
|
|
||||||
},
|
|
||||||
...props,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return result?.value ? String(result.value) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user