mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-05 04:02:30 -05:00
Compare commits
11 Commits
actions-sy
...
v2026.2.0-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c31718f5e | ||
|
|
8f1463e5d0 | ||
|
|
0dc8807808 | ||
|
|
f24a159b8a | ||
|
|
0b91d3aaff | ||
|
|
431dc1c896 | ||
|
|
bc8277b56b | ||
|
|
0afed185d9 | ||
|
|
55cee00601 | ||
|
|
b41a8e04cb | ||
|
|
eff4519d91 |
@@ -22,7 +22,7 @@
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <!-- sponsors-base -->
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <!-- sponsors-base -->
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::models::HttpRequestHeader;
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
@@ -23,20 +22,6 @@ impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_show_workspace_key<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
let key = window.crypto().reveal_workspace_key(workspace_id)?;
|
||||
window
|
||||
.dialog()
|
||||
.message(format!("Your workspace key is \n\n{}", key))
|
||||
.kind(MessageDialogKind::Info)
|
||||
.show(|_v| {});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
|
||||
@@ -9,8 +9,8 @@ use yaak_git::{
|
||||
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
||||
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
|
||||
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
|
||||
git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, git_rename_branch,
|
||||
git_rm_remote, git_status, git_unstage,
|
||||
git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
|
||||
git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,
|
||||
};
|
||||
|
||||
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
||||
@@ -89,6 +89,20 @@ pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {
|
||||
Ok(git_pull(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_pull_force_reset(
|
||||
dir: &Path,
|
||||
remote: &str,
|
||||
branch: &str,
|
||||
) -> Result<PullResult> {
|
||||
Ok(git_pull_force_reset(dir, remote, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||
Ok(git_pull_merge(dir, remote, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
||||
for path in rela_paths {
|
||||
@@ -105,6 +119,11 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
|
||||
Ok(git_reset_changes(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add_credential(
|
||||
remote_url: &str,
|
||||
|
||||
@@ -182,7 +182,14 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
);
|
||||
let env_chain =
|
||||
window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?;
|
||||
let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?;
|
||||
let mut cancel_rx = cancelled_rx.clone();
|
||||
let render_options = RenderOptions::throw();
|
||||
let request = tokio::select! {
|
||||
result = render_http_request(&resolved, env_chain, &cb, &render_options) => result?,
|
||||
_ = cancel_rx.changed() => {
|
||||
return Err(GenericError("Request canceled".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
// Build the sendable request using the new SendableHttpRequest type
|
||||
let options = SendableHttpRequestOptions {
|
||||
@@ -244,16 +251,22 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Apply authentication to the request
|
||||
apply_authentication(
|
||||
&window,
|
||||
&mut sendable_request,
|
||||
&request,
|
||||
auth_context_id,
|
||||
&plugin_manager,
|
||||
plugin_context,
|
||||
)
|
||||
.await?;
|
||||
// Apply authentication to the request, racing against cancellation since
|
||||
// auth plugins (e.g. OAuth2) can block indefinitely waiting for user action.
|
||||
let mut cancel_rx = cancelled_rx.clone();
|
||||
tokio::select! {
|
||||
result = apply_authentication(
|
||||
&window,
|
||||
&mut sendable_request,
|
||||
&request,
|
||||
auth_context_id,
|
||||
&plugin_manager,
|
||||
plugin_context,
|
||||
) => result?,
|
||||
_ = cancel_rx.changed() => {
|
||||
return Err(GenericError("Request canceled".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone());
|
||||
let result = execute_transaction(
|
||||
|
||||
@@ -37,8 +37,8 @@ use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
||||
use yaak_mac_window::AppHandleMacWindowExt;
|
||||
use yaak_models::models::{
|
||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState,
|
||||
Plugin, Workspace, WorkspaceMeta,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Plugin,
|
||||
Workspace, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
||||
use yaak_plugins::events::{
|
||||
@@ -1271,35 +1271,6 @@ async fn cmd_save_response<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_send_folder<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
environment_id: Option<String>,
|
||||
cookie_jar_id: Option<String>,
|
||||
folder_id: &str,
|
||||
) -> YaakResult<()> {
|
||||
let requests = app_handle.db().list_http_requests_for_folder_recursive(folder_id)?;
|
||||
for request in requests {
|
||||
let app_handle = app_handle.clone();
|
||||
let window = window.clone();
|
||||
let environment_id = environment_id.clone();
|
||||
let cookie_jar_id = cookie_jar_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = cmd_send_http_request(
|
||||
app_handle,
|
||||
window,
|
||||
environment_id.as_deref(),
|
||||
cookie_jar_id.as_deref(),
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_send_http_request<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
@@ -1396,27 +1367,6 @@ async fn cmd_install_plugin<R: Runtime>(
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_create_grpc_request<R: Runtime>(
|
||||
workspace_id: &str,
|
||||
name: &str,
|
||||
sort_priority: f64,
|
||||
folder_id: Option<&str>,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> YaakResult<GrpcRequest> {
|
||||
Ok(app_handle.db().upsert_grpc_request(
|
||||
&GrpcRequest {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
name: name.to_string(),
|
||||
folder_id: folder_id.map(|s| s.to_string()),
|
||||
sort_priority,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_reload_plugins<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
@@ -1679,7 +1629,6 @@ pub fn run() {
|
||||
cmd_call_folder_action,
|
||||
cmd_call_grpc_request_action,
|
||||
cmd_check_for_updates,
|
||||
cmd_create_grpc_request,
|
||||
cmd_curl_to_request,
|
||||
cmd_delete_all_grpc_connections,
|
||||
cmd_delete_all_http_responses,
|
||||
@@ -1713,7 +1662,6 @@ pub fn run() {
|
||||
cmd_save_response,
|
||||
cmd_send_ephemeral_request,
|
||||
cmd_send_http_request,
|
||||
cmd_send_folder,
|
||||
cmd_template_function_config,
|
||||
cmd_template_function_summaries,
|
||||
cmd_template_tokens_to_string,
|
||||
@@ -1728,7 +1676,6 @@ pub fn run() {
|
||||
crate::commands::cmd_reveal_workspace_key,
|
||||
crate::commands::cmd_secure_template,
|
||||
crate::commands::cmd_set_workspace_key,
|
||||
crate::commands::cmd_show_workspace_key,
|
||||
//
|
||||
// Models commands
|
||||
models_ext::models_delete,
|
||||
@@ -1762,8 +1709,11 @@ pub fn run() {
|
||||
git_ext::cmd_git_fetch_all,
|
||||
git_ext::cmd_git_push,
|
||||
git_ext::cmd_git_pull,
|
||||
git_ext::cmd_git_pull_force_reset,
|
||||
git_ext::cmd_git_pull_merge,
|
||||
git_ext::cmd_git_add,
|
||||
git_ext::cmd_git_unstage,
|
||||
git_ext::cmd_git_reset_changes,
|
||||
git_ext::cmd_git_add_credential,
|
||||
git_ext::cmd_git_remotes,
|
||||
git_ext::cmd_git_add_remote,
|
||||
@@ -1777,14 +1727,7 @@ pub fn run() {
|
||||
plugins_ext::cmd_plugins_update_all,
|
||||
//
|
||||
// WebSocket commands
|
||||
ws_ext::cmd_ws_upsert_request,
|
||||
ws_ext::cmd_ws_duplicate_request,
|
||||
ws_ext::cmd_ws_delete_request,
|
||||
ws_ext::cmd_ws_delete_connection,
|
||||
ws_ext::cmd_ws_delete_connections,
|
||||
ws_ext::cmd_ws_list_events,
|
||||
ws_ext::cmd_ws_list_requests,
|
||||
ws_ext::cmd_ws_list_connections,
|
||||
ws_ext::cmd_ws_send,
|
||||
ws_ext::cmd_ws_close,
|
||||
ws_ext::cmd_ws_connect,
|
||||
|
||||
@@ -162,11 +162,16 @@ pub(crate) fn create_window<R: Runtime>(
|
||||
"dev.reset_size" => webview_window
|
||||
.set_size(LogicalSize::new(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT))
|
||||
.unwrap(),
|
||||
"dev.reset_size_record" => {
|
||||
"dev.reset_size_16x9" => {
|
||||
let width = webview_window.outer_size().unwrap().width;
|
||||
let height = width * 9 / 16;
|
||||
webview_window.set_size(PhysicalSize::new(width, height)).unwrap()
|
||||
}
|
||||
"dev.reset_size_16x10" => {
|
||||
let width = webview_window.outer_size().unwrap().width;
|
||||
let height = width * 10 / 16;
|
||||
webview_window.set_size(PhysicalSize::new(width, height)).unwrap()
|
||||
}
|
||||
"dev.refresh" => webview_window.eval("location.reload()").unwrap(),
|
||||
"dev.generate_theme_css" => {
|
||||
w.emit("generate_theme_css", true).unwrap();
|
||||
|
||||
@@ -154,8 +154,13 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
|
||||
&MenuItemBuilder::with_id("dev.reset_size".to_string(), "Reset Size")
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id(
|
||||
"dev.reset_size_record".to_string(),
|
||||
"Reset Size 16x9",
|
||||
"dev.reset_size_16x9".to_string(),
|
||||
"Resize to 16x9",
|
||||
)
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id(
|
||||
"dev.reset_size_16x10".to_string(),
|
||||
"Resize to 16x10",
|
||||
)
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id(
|
||||
|
||||
@@ -28,52 +28,6 @@ use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_upsert_request<R: Runtime>(
|
||||
request: WebsocketRequest,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<WebsocketRequest> {
|
||||
Ok(app_handle
|
||||
.db()
|
||||
.upsert_websocket_request(&request, &UpdateSource::from_window_label(window.label()))?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_duplicate_request<R: Runtime>(
|
||||
request_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<WebsocketRequest> {
|
||||
let db = app_handle.db();
|
||||
let request = db.get_websocket_request(request_id)?;
|
||||
Ok(db.duplicate_websocket_request(&request, &UpdateSource::from_window_label(window.label()))?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_delete_request<R: Runtime>(
|
||||
request_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<WebsocketRequest> {
|
||||
Ok(app_handle.db().delete_websocket_request_by_id(
|
||||
request_id,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_delete_connection<R: Runtime>(
|
||||
connection_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(app_handle.db().delete_websocket_connection_by_id(
|
||||
connection_id,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_delete_connections<R: Runtime>(
|
||||
request_id: &str,
|
||||
@@ -86,30 +40,6 @@ pub async fn cmd_ws_delete_connections<R: Runtime>(
|
||||
)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_list_events<R: Runtime>(
|
||||
connection_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(app_handle.db().list_websocket_events(connection_id)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_list_requests<R: Runtime>(
|
||||
workspace_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<WebsocketRequest>> {
|
||||
Ok(app_handle.db().list_websocket_requests(workspace_id)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_list_connections<R: Runtime>(
|
||||
workspace_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<WebsocketConnection>> {
|
||||
Ok(app_handle.db().list_websocket_connections(workspace_id)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_ws_send<R: Runtime>(
|
||||
connection_id: &str,
|
||||
|
||||
4
crates/yaak-git/bindings/gen_git.ts
generated
4
crates/yaak-git/bindings/gen_git.ts
generated
@@ -15,8 +15,8 @@ export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "rem
|
||||
|
||||
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>, ahead: number, behind: number, };
|
||||
|
||||
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" };
|
||||
|
||||
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation';
|
||||
import { queryClient } from '@yaakapp/app/lib/queryClient';
|
||||
import { useMemo } from 'react';
|
||||
import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
|
||||
import { showToast } from '@yaakapp/app/lib/toast';
|
||||
|
||||
export * from './bindings/gen_git';
|
||||
export * from './bindings/gen_models';
|
||||
@@ -13,11 +14,20 @@ export interface GitCredentials {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type DivergedStrategy = 'force_reset' | 'merge' | 'cancel';
|
||||
|
||||
export type UncommittedChangesStrategy = 'reset' | 'cancel';
|
||||
|
||||
export interface GitCallbacks {
|
||||
addRemote: () => Promise<GitRemote | null>;
|
||||
promptCredentials: (
|
||||
result: Extract<PushResult, { type: 'needs_credentials' }>,
|
||||
) => Promise<GitCredentials | null>;
|
||||
promptDiverged: (
|
||||
result: Extract<PullResult, { type: 'diverged' }>,
|
||||
) => Promise<DivergedStrategy>;
|
||||
promptUncommittedChanges: () => Promise<UncommittedChangesStrategy>;
|
||||
forceSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
|
||||
@@ -69,6 +79,15 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
return invoke<PushResult>('cmd_git_push', { dir });
|
||||
};
|
||||
|
||||
const handleError = (err: unknown) => {
|
||||
showToast({
|
||||
id: `${err}`,
|
||||
message: `${err}`,
|
||||
color: 'danger',
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
init: createFastMutation<void, string, void>({
|
||||
mutationKey: ['git', 'init'],
|
||||
@@ -133,10 +152,9 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
},
|
||||
onSuccess,
|
||||
}),
|
||||
fetchAll: createFastMutation<string, string, void>({
|
||||
mutationKey: ['git', 'checkout', dir],
|
||||
fetchAll: createFastMutation<void, string, void>({
|
||||
mutationKey: ['git', 'fetch_all', dir],
|
||||
mutationFn: () => invoke('cmd_git_fetch_all', { dir }),
|
||||
onSuccess,
|
||||
}),
|
||||
push: createFastMutation<PushResult, string, void>({
|
||||
mutationKey: ['git', 'push', dir],
|
||||
@@ -147,20 +165,51 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
mutationKey: ['git', 'pull', dir],
|
||||
async mutationFn() {
|
||||
const result = await invoke<PullResult>('cmd_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');
|
||||
if (result.type === 'needs_credentials') {
|
||||
const creds = await callbacks.promptCredentials(result);
|
||||
if (creds == null) throw new Error('Canceled');
|
||||
|
||||
await invoke('cmd_git_add_credential', {
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
await invoke('cmd_git_add_credential', {
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
|
||||
// Pull again
|
||||
return invoke<PullResult>('cmd_git_pull', { dir });
|
||||
// Pull again after credentials
|
||||
return invoke<PullResult>('cmd_git_pull', { dir });
|
||||
}
|
||||
|
||||
if (result.type === 'uncommitted_changes') {
|
||||
callbacks.promptUncommittedChanges().then(async (strategy) => {
|
||||
if (strategy === 'cancel') return;
|
||||
|
||||
await invoke('cmd_git_reset_changes', { dir });
|
||||
return invoke<PullResult>('cmd_git_pull', { dir });
|
||||
}).then(async () => { onSuccess(); await callbacks.forceSync(); }, handleError);
|
||||
}
|
||||
|
||||
if (result.type === 'diverged') {
|
||||
callbacks.promptDiverged(result).then((strategy) => {
|
||||
if (strategy === 'cancel') return;
|
||||
|
||||
if (strategy === 'force_reset') {
|
||||
return invoke<PullResult>('cmd_git_pull_force_reset', {
|
||||
dir,
|
||||
remote: result.remote,
|
||||
branch: result.branch,
|
||||
});
|
||||
}
|
||||
|
||||
return invoke<PullResult>('cmd_git_pull_merge', {
|
||||
dir,
|
||||
remote: result.remote,
|
||||
branch: result.branch,
|
||||
});
|
||||
}).then(async () => { onSuccess(); await callbacks.forceSync(); }, handleError);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
onSuccess,
|
||||
}),
|
||||
@@ -169,6 +218,11 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
mutationFn: (args) => invoke('cmd_git_unstage', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
resetChanges: createFastMutation<void, string, void>({
|
||||
mutationKey: ['git', 'reset-changes', dir],
|
||||
mutationFn: () => invoke('cmd_git_reset_changes', { dir }),
|
||||
onSuccess,
|
||||
}),
|
||||
} as const;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ mod pull;
|
||||
mod push;
|
||||
mod remotes;
|
||||
mod repository;
|
||||
mod reset;
|
||||
mod status;
|
||||
mod unstage;
|
||||
mod util;
|
||||
@@ -29,8 +30,9 @@ pub use credential::git_add_credential;
|
||||
pub use fetch::git_fetch_all;
|
||||
pub use init::git_init;
|
||||
pub use log::{GitCommit, git_log};
|
||||
pub use pull::{PullResult, git_pull};
|
||||
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
|
||||
pub use push::{PushResult, git_push};
|
||||
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
||||
pub use reset::git_reset_changes;
|
||||
pub use status::{GitStatusSummary, git_status};
|
||||
pub use unstage::git_unstage;
|
||||
|
||||
@@ -15,9 +15,23 @@ pub enum PullResult {
|
||||
Success { message: String },
|
||||
UpToDate,
|
||||
NeedsCredentials { url: String, error: Option<String> },
|
||||
Diverged { remote: String, branch: String },
|
||||
UncommittedChanges,
|
||||
}
|
||||
|
||||
fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
|
||||
let repo = open_repo(dir)?;
|
||||
let mut opts = git2::StatusOptions::new();
|
||||
opts.include_ignored(false).include_untracked(false);
|
||||
let statuses = repo.statuses(Some(&mut opts))?;
|
||||
Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT))
|
||||
}
|
||||
|
||||
pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
||||
if has_uncommitted_changes(dir)? {
|
||||
return Ok(PullResult::UncommittedChanges);
|
||||
}
|
||||
|
||||
// Extract all git2 data before any await points (git2 types are not Send)
|
||||
let (branch_name, remote_name, remote_url) = {
|
||||
let repo = open_repo(dir)?;
|
||||
@@ -56,6 +70,13 @@ pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
||||
}
|
||||
|
||||
if !out.status.success() {
|
||||
let combined_lower = combined.to_lowercase();
|
||||
if combined_lower.contains("cannot fast-forward")
|
||||
|| combined_lower.contains("not possible to fast-forward")
|
||||
|| combined_lower.contains("diverged")
|
||||
{
|
||||
return Ok(PullResult::Diverged { remote: remote_name, branch: branch_name });
|
||||
}
|
||||
return Err(GenericError(format!("Failed to pull {combined}")));
|
||||
}
|
||||
|
||||
@@ -66,6 +87,65 @@ pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
||||
Ok(PullResult::Success { message: format!("Pulled from {}/{}", remote_name, branch_name) })
|
||||
}
|
||||
|
||||
pub async fn git_pull_force_reset(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||
// Step 1: fetch the remote
|
||||
let fetch_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["fetch", remote])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?;
|
||||
|
||||
if !fetch_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&fetch_out.stderr);
|
||||
return Err(GenericError(format!("Failed to fetch: {stderr}")));
|
||||
}
|
||||
|
||||
// Step 2: reset --hard to remote/branch
|
||||
let ref_name = format!("{}/{}", remote, branch);
|
||||
let reset_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["reset", "--hard", &ref_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git reset: {e}")))?;
|
||||
|
||||
if !reset_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&reset_out.stderr);
|
||||
return Err(GenericError(format!("Failed to reset: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(PullResult::Success { message: format!("Reset to {}/{}", remote, branch) })
|
||||
}
|
||||
|
||||
pub async fn git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["pull", "--no-rebase", remote, branch])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git pull --no-rebase: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
info!("Pull merge status={} {combined}", out.status);
|
||||
|
||||
if !out.status.success() {
|
||||
if combined.to_lowercase().contains("conflict") {
|
||||
return Err(GenericError(
|
||||
"Merge conflicts detected. Please resolve them manually.".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(GenericError(format!("Failed to merge pull: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(PullResult::Success { message: format!("Merged from {}/{}", remote, branch) })
|
||||
}
|
||||
|
||||
// pub(crate) fn git_pull_old(dir: &Path) -> Result<PullResult> {
|
||||
// let repo = open_repo(dir)?;
|
||||
//
|
||||
|
||||
20
crates/yaak-git/src/reset.rs
Normal file
20
crates/yaak-git/src/reset.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
|
||||
pub async fn git_reset_changes(dir: &Path) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["reset", "--hard", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git reset: {e}")))?;
|
||||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(GenericError(format!("Failed to reset: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -18,6 +18,8 @@ pub struct GitStatusSummary {
|
||||
pub origins: Vec<String>,
|
||||
pub local_branches: Vec<String>,
|
||||
pub remote_branches: Vec<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
@@ -160,6 +162,18 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||
let local_branches = local_branch_names(&repo)?;
|
||||
let remote_branches = remote_branch_names(&repo)?;
|
||||
|
||||
// Compute ahead/behind relative to remote tracking branch
|
||||
let (ahead, behind) = (|| -> Option<(usize, usize)> {
|
||||
let head = repo.head().ok()?;
|
||||
let local_oid = head.target()?;
|
||||
let branch_name = head.shorthand()?;
|
||||
let upstream_ref =
|
||||
repo.find_branch(&format!("origin/{branch_name}"), git2::BranchType::Remote).ok()?;
|
||||
let upstream_oid = upstream_ref.get().target()?;
|
||||
repo.graph_ahead_behind(local_oid, upstream_oid).ok()
|
||||
})()
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
Ok(GitStatusSummary {
|
||||
entries,
|
||||
origins,
|
||||
@@ -168,5 +182,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||
head_ref_shorthand,
|
||||
local_branches,
|
||||
remote_branches,
|
||||
ahead: ahead as u32,
|
||||
behind: behind as u32,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ impl<S: HttpSender> HttpTransaction<S> {
|
||||
response.drain().await?;
|
||||
|
||||
// Update the request URL
|
||||
let previous_url = current_url.clone();
|
||||
current_url = if location.starts_with("http://") || location.starts_with("https://") {
|
||||
// Absolute URL
|
||||
location
|
||||
@@ -181,6 +182,8 @@ impl<S: HttpSender> HttpTransaction<S> {
|
||||
format!("{}/{}", base_path, location)
|
||||
};
|
||||
|
||||
Self::remove_sensitive_headers(&mut current_headers, &previous_url, ¤t_url);
|
||||
|
||||
// Determine redirect behavior based on status code and method
|
||||
let behavior = if status == 303 {
|
||||
// 303 See Other always changes to GET
|
||||
@@ -220,6 +223,33 @@ impl<S: HttpSender> HttpTransaction<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove sensitive headers when redirecting to a different host.
|
||||
/// This matches reqwest's `remove_sensitive_headers()` behavior and prevents
|
||||
/// credentials from being forwarded to third-party servers (e.g., an
|
||||
/// Authorization header sent from an API redirect to an S3 bucket).
|
||||
fn remove_sensitive_headers(
|
||||
headers: &mut Vec<(String, String)>,
|
||||
previous_url: &str,
|
||||
next_url: &str,
|
||||
) {
|
||||
let previous_host = Url::parse(previous_url).ok().and_then(|u| {
|
||||
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
|
||||
});
|
||||
let next_host = Url::parse(next_url).ok().and_then(|u| {
|
||||
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
|
||||
});
|
||||
if previous_host != next_host {
|
||||
headers.retain(|h| {
|
||||
let name_lower = h.0.to_lowercase();
|
||||
name_lower != "authorization"
|
||||
&& name_lower != "cookie"
|
||||
&& name_lower != "cookie2"
|
||||
&& name_lower != "proxy-authorization"
|
||||
&& name_lower != "www-authenticate"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a status code indicates a redirect
|
||||
fn is_redirect(status: u16) -> bool {
|
||||
matches!(status, 301 | 302 | 303 | 307 | 308)
|
||||
@@ -269,9 +299,20 @@ mod tests {
|
||||
use tokio::io::AsyncRead;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Captured request metadata for test assertions
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
struct CapturedRequest {
|
||||
url: String,
|
||||
method: String,
|
||||
headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Mock sender for testing
|
||||
struct MockSender {
|
||||
responses: Arc<Mutex<Vec<MockResponse>>>,
|
||||
/// Captured requests for assertions
|
||||
captured_requests: Arc<Mutex<Vec<CapturedRequest>>>,
|
||||
}
|
||||
|
||||
struct MockResponse {
|
||||
@@ -282,7 +323,10 @@ mod tests {
|
||||
|
||||
impl MockSender {
|
||||
fn new(responses: Vec<MockResponse>) -> Self {
|
||||
Self { responses: Arc::new(Mutex::new(responses)) }
|
||||
Self {
|
||||
responses: Arc::new(Mutex::new(responses)),
|
||||
captured_requests: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,9 +334,16 @@ mod tests {
|
||||
impl HttpSender for MockSender {
|
||||
async fn send(
|
||||
&self,
|
||||
_request: SendableHttpRequest,
|
||||
request: SendableHttpRequest,
|
||||
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
// Capture the request metadata for later assertions
|
||||
self.captured_requests.lock().await.push(CapturedRequest {
|
||||
url: request.url.clone(),
|
||||
method: request.method.clone(),
|
||||
headers: request.headers.clone(),
|
||||
});
|
||||
|
||||
let mut responses = self.responses.lock().await;
|
||||
if responses.is_empty() {
|
||||
Err(crate::error::Error::RequestError("No more mock responses".to_string()))
|
||||
@@ -726,4 +777,116 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(request_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cross_origin_redirect_strips_auth_headers() {
|
||||
// Redirect from api.example.com -> s3.amazonaws.com should strip Authorization
|
||||
let responses = vec![
|
||||
MockResponse {
|
||||
status: 302,
|
||||
headers: vec![(
|
||||
"Location".to_string(),
|
||||
"https://s3.amazonaws.com/bucket/file.pdf".to_string(),
|
||||
)],
|
||||
body: vec![],
|
||||
},
|
||||
MockResponse { status: 200, headers: Vec::new(), body: b"PDF content".to_vec() },
|
||||
];
|
||||
|
||||
let sender = MockSender::new(responses);
|
||||
let captured = sender.captured_requests.clone();
|
||||
let transaction = HttpTransaction::new(sender);
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://api.example.com/download".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: vec![
|
||||
("Authorization".to_string(), "Basic dXNlcjpwYXNz".to_string()),
|
||||
("Accept".to_string(), "application/pdf".to_string()),
|
||||
],
|
||||
options: crate::types::SendableHttpRequestOptions {
|
||||
follow_redirects: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
|
||||
let requests = captured.lock().await;
|
||||
assert_eq!(requests.len(), 2);
|
||||
|
||||
// First request should have the Authorization header
|
||||
assert!(
|
||||
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
|
||||
"First request should have Authorization header"
|
||||
);
|
||||
|
||||
// Second request (to different host) should NOT have the Authorization header
|
||||
assert!(
|
||||
!requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
|
||||
"Redirected request to different host should NOT have Authorization header"
|
||||
);
|
||||
|
||||
// Non-sensitive headers should still be present
|
||||
assert!(
|
||||
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")),
|
||||
"Non-sensitive headers should be preserved across cross-origin redirects"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_origin_redirect_preserves_auth_headers() {
|
||||
// Redirect within the same host should keep Authorization
|
||||
let responses = vec![
|
||||
MockResponse {
|
||||
status: 302,
|
||||
headers: vec![(
|
||||
"Location".to_string(),
|
||||
"https://api.example.com/v2/download".to_string(),
|
||||
)],
|
||||
body: vec![],
|
||||
},
|
||||
MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() },
|
||||
];
|
||||
|
||||
let sender = MockSender::new(responses);
|
||||
let captured = sender.captured_requests.clone();
|
||||
let transaction = HttpTransaction::new(sender);
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://api.example.com/v1/download".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: vec![
|
||||
("Authorization".to_string(), "Bearer token123".to_string()),
|
||||
("Accept".to_string(), "application/json".to_string()),
|
||||
],
|
||||
options: crate::types::SendableHttpRequestOptions {
|
||||
follow_redirects: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
|
||||
let requests = captured.lock().await;
|
||||
assert_eq!(requests.len(), 2);
|
||||
|
||||
// Both requests should have the Authorization header (same host)
|
||||
assert!(
|
||||
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
|
||||
"First request should have Authorization header"
|
||||
);
|
||||
assert!(
|
||||
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
|
||||
"Redirected request to same host should preserve Authorization header"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use std::time::Duration;
|
||||
use tokio::fs::read_dir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio::sync::{Mutex, mpsc, oneshot};
|
||||
use tokio::time::{Instant, timeout};
|
||||
use yaak_models::models::Plugin;
|
||||
use yaak_models::util::generate_id;
|
||||
@@ -43,6 +43,7 @@ pub struct PluginManager {
|
||||
subscribers: Arc<Mutex<HashMap<String, mpsc::Sender<InternalEvent>>>>,
|
||||
plugin_handles: Arc<Mutex<Vec<PluginHandle>>>,
|
||||
kill_tx: tokio::sync::watch::Sender<bool>,
|
||||
killed_rx: Arc<Mutex<Option<oneshot::Receiver<()>>>>,
|
||||
ws_service: Arc<PluginRuntimeServerWebsocket>,
|
||||
vendored_plugin_dir: PathBuf,
|
||||
pub(crate) installed_plugin_dir: PathBuf,
|
||||
@@ -70,6 +71,7 @@ impl PluginManager {
|
||||
) -> PluginManager {
|
||||
let (events_tx, mut events_rx) = mpsc::channel(2048);
|
||||
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
|
||||
let (killed_tx, killed_rx) = oneshot::channel();
|
||||
|
||||
let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128);
|
||||
let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false);
|
||||
@@ -81,6 +83,7 @@ impl PluginManager {
|
||||
subscribers: Default::default(),
|
||||
ws_service: Arc::new(ws_service.clone()),
|
||||
kill_tx: kill_server_tx,
|
||||
killed_rx: Arc::new(Mutex::new(Some(killed_rx))),
|
||||
vendored_plugin_dir,
|
||||
installed_plugin_dir,
|
||||
dev_mode,
|
||||
@@ -141,9 +144,15 @@ impl PluginManager {
|
||||
});
|
||||
|
||||
// 2. Start Node.js runtime
|
||||
start_nodejs_plugin_runtime(&node_bin_path, &plugin_runtime_main, addr, &kill_server_rx)
|
||||
.await
|
||||
.unwrap();
|
||||
start_nodejs_plugin_runtime(
|
||||
&node_bin_path,
|
||||
&plugin_runtime_main,
|
||||
addr,
|
||||
&kill_server_rx,
|
||||
killed_tx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
info!("Waiting for plugins to initialize");
|
||||
init_plugins_task.await.unwrap();
|
||||
|
||||
@@ -296,8 +305,15 @@ impl PluginManager {
|
||||
pub async fn terminate(&self) {
|
||||
self.kill_tx.send_replace(true);
|
||||
|
||||
// Give it a bit of time to kill
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
// Wait for the plugin runtime process to actually exit
|
||||
let killed_rx = self.killed_rx.lock().await.take();
|
||||
if let Some(rx) = killed_rx {
|
||||
if timeout(Duration::from_secs(5), rx).await.is_err() {
|
||||
warn!("Timed out waiting for plugin runtime to exit");
|
||||
} else {
|
||||
info!("Plugin runtime exited")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reply(
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::watch::Receiver;
|
||||
use yaak_common::command::new_xplatform_command;
|
||||
|
||||
@@ -19,6 +20,7 @@ pub async fn start_nodejs_plugin_runtime(
|
||||
plugin_runtime_main: &Path,
|
||||
addr: SocketAddr,
|
||||
kill_rx: &Receiver<bool>,
|
||||
killed_tx: oneshot::Sender<()>,
|
||||
) -> Result<()> {
|
||||
// HACK: Remove UNC prefix for Windows paths to pass to sidecar
|
||||
let plugin_runtime_main_str =
|
||||
@@ -72,6 +74,7 @@ pub async fn start_nodejs_plugin_runtime(
|
||||
warn!("Failed to kill plugin runtime: {e}");
|
||||
}
|
||||
info!("Killed plugin runtime");
|
||||
let _ = killed_tx.send(());
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { WebsocketConnection, WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
|
||||
export function upsertWebsocketRequest(
|
||||
request: WebsocketRequest | Partial<Omit<WebsocketRequest, 'id'>>,
|
||||
) {
|
||||
return invoke('cmd_ws_upsert_request', {
|
||||
request,
|
||||
}) as Promise<WebsocketRequest>;
|
||||
}
|
||||
|
||||
export function duplicateWebsocketRequest(requestId: string) {
|
||||
return invoke('cmd_ws_duplicate_request', {
|
||||
requestId,
|
||||
}) as Promise<WebsocketRequest>;
|
||||
}
|
||||
|
||||
export function deleteWebsocketRequest(requestId: string) {
|
||||
return invoke('cmd_ws_delete_request', {
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteWebsocketConnection(connectionId: string) {
|
||||
return invoke('cmd_ws_delete_connection', {
|
||||
connectionId,
|
||||
});
|
||||
}
|
||||
import { WebsocketConnection } from '@yaakapp-internal/models';
|
||||
|
||||
export function deleteWebsocketConnections(requestId: string) {
|
||||
return invoke('cmd_ws_delete_connections', {
|
||||
@@ -33,20 +7,6 @@ export function deleteWebsocketConnections(requestId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function listWebsocketRequests({ workspaceId }: { workspaceId: string }) {
|
||||
return invoke('cmd_ws_list_requests', { workspaceId }) as Promise<WebsocketRequest[]>;
|
||||
}
|
||||
|
||||
export function listWebsocketEvents({ connectionId }: { connectionId: string }) {
|
||||
return invoke('cmd_ws_list_events', { connectionId }) as Promise<WebsocketEvent[]>;
|
||||
}
|
||||
|
||||
export function listWebsocketConnections({ workspaceId }: { workspaceId: string }) {
|
||||
return invoke('cmd_ws_list_connections', { workspaceId }) as Promise<
|
||||
WebsocketConnection[]
|
||||
>;
|
||||
}
|
||||
|
||||
export function connectWebsocket({
|
||||
requestId,
|
||||
environmentId,
|
||||
|
||||
@@ -788,12 +788,12 @@ export class PluginInstance {
|
||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
||||
return folders.find((f) => f.id === args.id) ?? null;
|
||||
},
|
||||
create: async (args) => {
|
||||
create: async ({ name, ...args }) => {
|
||||
const payload = {
|
||||
type: 'upsert_model_request',
|
||||
model: {
|
||||
name: '',
|
||||
...args,
|
||||
name: name ?? '',
|
||||
id: '',
|
||||
model: 'folder',
|
||||
},
|
||||
|
||||
@@ -184,6 +184,18 @@ export function buildHostedCallbackRedirectUri(localPort: number, localPath: str
|
||||
return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the active callback server if one is running.
|
||||
* Called during plugin dispose to ensure the server is cleaned up before the process exits.
|
||||
*/
|
||||
export function stopActiveServer(): void {
|
||||
if (activeServer) {
|
||||
console.log('[oauth2] Stopping active callback server during dispose');
|
||||
activeServer.stop();
|
||||
activeServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an authorization URL in the system browser, start a local callback server,
|
||||
* and wait for the OAuth provider to redirect back.
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
JsonPrimitive,
|
||||
PluginDefinition,
|
||||
} from '@yaakapp/api';
|
||||
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL } from './callbackServer';
|
||||
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
|
||||
import {
|
||||
type CallbackType,
|
||||
DEFAULT_PKCE_METHOD,
|
||||
@@ -78,6 +78,9 @@ const accessTokenUrls = [
|
||||
];
|
||||
|
||||
export const plugin: PluginDefinition = {
|
||||
dispose() {
|
||||
stopActiveServer();
|
||||
},
|
||||
authentication: {
|
||||
name: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { getModel } from '@yaakapp-internal/models';
|
||||
import { Icon } from '../components/core/Icon';
|
||||
import { HStack } from '../components/core/Stacks';
|
||||
import type { FolderSettingsTab } from '../components/FolderSettingsDialog';
|
||||
import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
|
||||
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
||||
const folder = getModel('folder', folderId);
|
||||
if (folder == null) return;
|
||||
showDialog({
|
||||
id: 'folder-settings',
|
||||
title: (
|
||||
<HStack space={2} alignItems="center">
|
||||
<Icon icon="folder_cog" size="xl" color="secondary" />
|
||||
{resolvedModelName(folder)}
|
||||
</HStack>
|
||||
),
|
||||
title: null,
|
||||
size: 'lg',
|
||||
className: 'h-[50rem]',
|
||||
noPadding: true,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models';
|
||||
import {
|
||||
createWorkspaceModel,
|
||||
foldersAtom,
|
||||
patchModel,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useAuthTab } from '../hooks/useAuthTab';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||
import { useModelAncestors } from '../hooks/useModelAncestors';
|
||||
import { Button } from './core/Button';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Icon } from './core/Icon';
|
||||
import { Input } from './core/Input';
|
||||
import { Link } from './core/Link';
|
||||
import { VStack } from './core/Stacks';
|
||||
@@ -37,6 +43,8 @@ export type FolderSettingsTab =
|
||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const folder = folders.find((f) => f.id === folderId) ?? null;
|
||||
const ancestors = useModelAncestors(folder);
|
||||
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
|
||||
const authTab = useAuthTab(TAB_AUTH, folder);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, folder);
|
||||
const inheritedHeaders = useInheritedHeaders(folder);
|
||||
@@ -67,76 +75,107 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
if (folder == null) return null;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={tab ?? TAB_GENERAL}
|
||||
label="Folder Settings"
|
||||
className="pt-2 pb-2 pl-3 pr-1"
|
||||
layout="horizontal"
|
||||
addBorders
|
||||
tabs={tabs}
|
||||
>
|
||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
||||
<HttpAuthenticationEditor model={folder} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
||||
<VStack space={3} className="pb-3 h-full">
|
||||
<Input
|
||||
label="Folder Name"
|
||||
defaultValue={folder.name}
|
||||
onChange={(name) => patchModel(folder, { name })}
|
||||
stateKey={`name.${folder.id}`}
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
|
||||
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
|
||||
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
{index > 0 && (
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
size="lg"
|
||||
className="opacity-50 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-text-subtle truncate min-w-0" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="whitespace-nowrap"
|
||||
title={folder.name}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab ?? TAB_GENERAL}
|
||||
label="Folder Settings"
|
||||
className="pt-2 pb-2 pl-3 pr-1 flex-1"
|
||||
layout="horizontal"
|
||||
addBorders
|
||||
tabs={tabs}
|
||||
>
|
||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
||||
<HttpAuthenticationEditor model={folder} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
||||
<VStack space={3} className="pb-3 h-full">
|
||||
<Input
|
||||
label="Folder Name"
|
||||
defaultValue={folder.name}
|
||||
onChange={(name) => patchModel(folder, { name })}
|
||||
stateKey={`name.${folder.id}`}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="folder-description"
|
||||
placeholder="Folder description"
|
||||
className="border border-border px-2"
|
||||
defaultValue={folder.description}
|
||||
stateKey={`description.${folder.id}`}
|
||||
onChange={(description) => patchModel(folder, { description })}
|
||||
/>
|
||||
</VStack>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={folder.id}
|
||||
headers={folder.headers}
|
||||
onChange={(headers) => patchModel(folder, { headers })}
|
||||
stateKey={`headers.${folder.id}`}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="folder-description"
|
||||
placeholder="Folder description"
|
||||
className="border border-border px-2"
|
||||
defaultValue={folder.description}
|
||||
stateKey={`description.${folder.id}`}
|
||||
onChange={(description) => patchModel(folder, { description })}
|
||||
/>
|
||||
</VStack>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={folder.id}
|
||||
headers={folder.headers}
|
||||
onChange={(headers) => patchModel(folder, { headers })}
|
||||
stateKey={`headers.${folder.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
||||
{folderEnvironment == null ? (
|
||||
<EmptyStateText>
|
||||
<VStack alignItems="center" space={1.5}>
|
||||
<p>
|
||||
Override{' '}
|
||||
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
|
||||
Variables
|
||||
</Link>{' '}
|
||||
for requests within this folder.
|
||||
</p>
|
||||
<Button
|
||||
variant="border"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await createWorkspaceModel({
|
||||
workspaceId: folder.workspaceId,
|
||||
parentModel: 'folder',
|
||||
parentId: folder.id,
|
||||
model: 'environment',
|
||||
name: 'Folder Environment',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Create Folder Environment
|
||||
</Button>
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
) : (
|
||||
<EnvironmentEditor hideName environment={folderEnvironment} />
|
||||
)}
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
||||
{folderEnvironment == null ? (
|
||||
<EmptyStateText>
|
||||
<VStack alignItems="center" space={1.5}>
|
||||
<p>
|
||||
Override{' '}
|
||||
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
|
||||
Variables
|
||||
</Link>{' '}
|
||||
for requests within this folder.
|
||||
</p>
|
||||
<Button
|
||||
variant="border"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await createWorkspaceModel({
|
||||
workspaceId: folder.workspaceId,
|
||||
parentModel: 'folder',
|
||||
parentId: folder.id,
|
||||
model: 'environment',
|
||||
name: 'Folder Environment',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Create Folder Environment
|
||||
</Button>
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
) : (
|
||||
<EnvironmentEditor hideName environment={folderEnvironment} />
|
||||
)}
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ export function Workspace() {
|
||||
open={!floatingSidebarHidden}
|
||||
portalName="sidebar"
|
||||
onClose={() => setFloatingSidebarHidden(true)}
|
||||
zIndex={20}
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
|
||||
@@ -111,6 +111,7 @@ import {
|
||||
RefreshCcwIcon,
|
||||
RefreshCwIcon,
|
||||
RocketIcon,
|
||||
RotateCcwIcon,
|
||||
Rows2Icon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
@@ -249,6 +250,7 @@ const icons = {
|
||||
puzzle: PuzzleIcon,
|
||||
refresh: RefreshCwIcon,
|
||||
rocket: RocketIcon,
|
||||
rotate_ccw: RotateCcwIcon,
|
||||
rows_2: Rows2Icon,
|
||||
save: SaveIcon,
|
||||
search: SearchIcon,
|
||||
|
||||
66
src-web/components/core/RadioCards.tsx
Normal file
66
src-web/components/core/RadioCards.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface RadioCardOption<T extends string> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
export interface RadioCardsProps<T extends string> {
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
options: RadioCardOption<T>[];
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function RadioCards<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
name,
|
||||
}: RadioCardsProps<T>) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={classNames(
|
||||
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer',
|
||||
'transition-colors',
|
||||
selected
|
||||
? 'border-border-focus'
|
||||
: 'border-border-subtle hocus:border-text-subtlest',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={selected}
|
||||
onChange={() => onChange(option.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
'mt-1 w-4 h-4 flex-shrink-0 rounded-full border',
|
||||
'flex items-center justify-center',
|
||||
selected ? 'border-focus' : 'border-border',
|
||||
)}
|
||||
>
|
||||
{selected && <div className="w-2 h-2 rounded-full bg-text" />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold text-text">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-sm text-text-subtle">{option.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { InlineCode } from '../core/InlineCode';
|
||||
import { gitCallbacks } from './callbacks';
|
||||
import { GitCommitDialog } from './GitCommitDialog';
|
||||
import { GitRemotesDialog } from './GitRemotesDialog';
|
||||
import { handlePullResult } from './git-util';
|
||||
import { handlePullResult, handlePushResult } from './git-util';
|
||||
import { HistoryDialog } from './HistoryDialog';
|
||||
|
||||
export function GitDropdown() {
|
||||
@@ -48,6 +48,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
push,
|
||||
pull,
|
||||
checkout,
|
||||
resetChanges,
|
||||
init,
|
||||
},
|
||||
] = useGit(syncDir, gitCallbacks(syncDir));
|
||||
@@ -72,6 +73,9 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
}
|
||||
|
||||
const currentBranch = status.data.headRefShorthand;
|
||||
const hasChanges = status.data.entries.some((e) => e.status !== 'current');
|
||||
const hasRemotes = (status.data.origins ?? []).length > 0;
|
||||
const { ahead, behind } = status.data;
|
||||
|
||||
const tryCheckout = (branch: string, force: boolean) => {
|
||||
checkout.mutate(
|
||||
@@ -168,12 +172,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Push',
|
||||
disabled: !hasRemotes || ahead === 0,
|
||||
leftSlot: <Icon icon="arrow_up_from_line" />,
|
||||
waitForOnSelect: true,
|
||||
async onSelect() {
|
||||
await push.mutateAsync(undefined, {
|
||||
disableToastError: true,
|
||||
onSuccess: handlePullResult,
|
||||
onSuccess: handlePushResult,
|
||||
onError(err) {
|
||||
showErrorToast({
|
||||
id: 'git-push-error',
|
||||
@@ -186,7 +191,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
},
|
||||
{
|
||||
label: 'Pull',
|
||||
hidden: (status.data?.origins ?? []).length === 0,
|
||||
disabled: !hasRemotes || behind === 0,
|
||||
leftSlot: <Icon icon="arrow_down_to_line" />,
|
||||
waitForOnSelect: true,
|
||||
async onSelect() {
|
||||
@@ -205,6 +210,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
},
|
||||
{
|
||||
label: 'Commit...',
|
||||
disabled: !hasChanges,
|
||||
leftSlot: <Icon icon="git_commit_vertical" />,
|
||||
onSelect() {
|
||||
showDialog({
|
||||
@@ -218,6 +224,41 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Reset Changes',
|
||||
hidden: !hasChanges,
|
||||
leftSlot: <Icon icon="rotate_ccw" />,
|
||||
color: 'danger',
|
||||
async onSelect() {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'git-reset-changes',
|
||||
title: 'Reset Changes',
|
||||
description: 'This will discard all uncommitted changes. This cannot be undone.',
|
||||
confirmText: 'Reset',
|
||||
color: 'danger',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
await resetChanges.mutateAsync(undefined, {
|
||||
disableToastError: true,
|
||||
onSuccess() {
|
||||
showToast({
|
||||
id: 'git-reset-success',
|
||||
message: 'Changes have been reset',
|
||||
color: 'success',
|
||||
});
|
||||
sync({ force: true });
|
||||
},
|
||||
onError(err) {
|
||||
showErrorToast({
|
||||
id: 'git-reset-error',
|
||||
title: 'Error resetting changes',
|
||||
message: String(err),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{ type: 'separator', label: 'Branches', hidden: localBranches.length < 1 },
|
||||
...localBranches.map((branch) => {
|
||||
const isCurrent = currentBranch === branch;
|
||||
@@ -463,8 +504,14 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
return (
|
||||
<Dropdown fullWidth items={items} onOpen={fetchAll.mutate}>
|
||||
<GitMenuButton>
|
||||
<InlineCode>{currentBranch}</InlineCode>
|
||||
<Icon icon="git_branch" size="sm" />
|
||||
<InlineCode className="flex items-center gap-1">
|
||||
<Icon icon="git_branch" size="xs" className="opacity-50" />
|
||||
{currentBranch}
|
||||
</InlineCode>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{ahead > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-primary">↗</span>{ahead}</span>}
|
||||
{behind > 0 && <span className="text-xs flex items-center gap-0.5"><span className="text-info">↙</span>{behind}</span>}
|
||||
</div>
|
||||
</GitMenuButton>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { GitCallbacks } from '@yaakapp-internal/git';
|
||||
import { sync } from '../../init/sync';
|
||||
import { promptCredentials } from './credentials';
|
||||
import { promptDivergedStrategy } from './diverged';
|
||||
import { promptUncommittedChangesStrategy } from './uncommitted';
|
||||
import { addGitRemote } from './showAddRemoteDialog';
|
||||
|
||||
export function gitCallbacks(dir: string): GitCallbacks {
|
||||
@@ -12,5 +15,12 @@ export function gitCallbacks(dir: string): GitCallbacks {
|
||||
if (creds == null) throw new Error('Cancelled credentials prompt');
|
||||
return creds;
|
||||
},
|
||||
promptDiverged: async ({ remote, branch }) => {
|
||||
return promptDivergedStrategy({ remote, branch });
|
||||
},
|
||||
promptUncommittedChanges: async () => {
|
||||
return promptUncommittedChangesStrategy();
|
||||
},
|
||||
forceSync: () => sync({ force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
102
src-web/components/git/diverged.tsx
Normal file
102
src-web/components/git/diverged.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { DivergedStrategy } from '@yaakapp-internal/git';
|
||||
import { useState } from 'react';
|
||||
import { showDialog } from '../../lib/dialog';
|
||||
import { Button } from '../core/Button';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { RadioCards } from '../core/RadioCards';
|
||||
import { HStack } from '../core/Stacks';
|
||||
|
||||
type Resolution = 'force_reset' | 'merge';
|
||||
|
||||
const resolutionLabel: Record<Resolution, string> = {
|
||||
force_reset: 'Force Pull',
|
||||
merge: 'Merge',
|
||||
};
|
||||
|
||||
interface DivergedDialogProps {
|
||||
remote: string;
|
||||
branch: string;
|
||||
onResult: (strategy: DivergedStrategy) => void;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProps) {
|
||||
const [selected, setSelected] = useState<Resolution | null>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selected == null) return;
|
||||
onResult(selected);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onResult('cancel');
|
||||
onHide();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mb-4">
|
||||
<p className="text-text-subtle">
|
||||
Your local branch has diverged from{' '}
|
||||
<InlineCode>
|
||||
{remote}/{branch}
|
||||
</InlineCode>. How would you like to resolve this?
|
||||
</p>
|
||||
<RadioCards
|
||||
name="diverged-strategy"
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
options={[
|
||||
{
|
||||
value: 'merge',
|
||||
label: 'Merge Commit',
|
||||
description: 'Combining local and remote changes into a single merge commit',
|
||||
},
|
||||
{
|
||||
value: 'force_reset',
|
||||
label: 'Force Pull',
|
||||
description: 'Discard local commits and reset to match the remote branch',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<HStack space={2} justifyContent="start" className="flex-row-reverse">
|
||||
<Button
|
||||
color={selected === 'force_reset' ? 'danger' : 'primary'}
|
||||
disabled={selected == null}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{selected != null ? resolutionLabel[selected] : 'Select an option'}
|
||||
</Button>
|
||||
<Button variant="border" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function promptDivergedStrategy({
|
||||
remote,
|
||||
branch,
|
||||
}: {
|
||||
remote: string;
|
||||
branch: string;
|
||||
}): Promise<DivergedStrategy> {
|
||||
return new Promise((resolve) => {
|
||||
showDialog({
|
||||
id: 'git-diverged',
|
||||
title: 'Branches Diverged',
|
||||
hideX: true,
|
||||
size: 'sm',
|
||||
disableBackdropClose: true,
|
||||
onClose: () => resolve('cancel'),
|
||||
render: ({ hide }) =>
|
||||
DivergedDialog({
|
||||
remote,
|
||||
branch,
|
||||
onHide: hide,
|
||||
onResult: resolve,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -26,5 +26,11 @@ export function handlePullResult(r: PullResult) {
|
||||
case 'up_to_date':
|
||||
showToast({ id: 'pull-nothing', message: 'Already up-to-date', color: 'info' });
|
||||
break;
|
||||
case 'diverged':
|
||||
// Handled by mutation callback before reaching here
|
||||
break;
|
||||
case 'uncommitted_changes':
|
||||
// Handled by mutation callback before reaching here
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
13
src-web/components/git/uncommitted.tsx
Normal file
13
src-web/components/git/uncommitted.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { UncommittedChangesStrategy } from '@yaakapp-internal/git';
|
||||
import { showConfirm } from '../../lib/confirm';
|
||||
|
||||
export async function promptUncommittedChangesStrategy(): Promise<UncommittedChangesStrategy> {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'git-uncommitted-changes',
|
||||
title: 'Uncommitted Changes',
|
||||
description: 'You have uncommitted changes. Commit or reset your changes before pulling.',
|
||||
confirmText: 'Reset and Pull',
|
||||
color: 'danger',
|
||||
});
|
||||
return confirmed ? 'reset' : 'cancel';
|
||||
}
|
||||
@@ -9,7 +9,6 @@ type TauriCmd =
|
||||
| 'cmd_call_workspace_action'
|
||||
| 'cmd_call_folder_action'
|
||||
| 'cmd_check_for_updates'
|
||||
| 'cmd_create_grpc_request'
|
||||
| 'cmd_curl_to_request'
|
||||
| 'cmd_decrypt_template'
|
||||
| 'cmd_default_headers'
|
||||
@@ -48,9 +47,7 @@ type TauriCmd =
|
||||
| 'cmd_save_response'
|
||||
| 'cmd_secure_template'
|
||||
| 'cmd_send_ephemeral_request'
|
||||
| 'cmd_send_folder'
|
||||
| 'cmd_send_http_request'
|
||||
| 'cmd_show_workspace_key'
|
||||
| 'cmd_template_function_summaries'
|
||||
| 'cmd_template_function_config'
|
||||
| 'cmd_template_tokens_to_string';
|
||||
|
||||
Reference in New Issue
Block a user