Compare commits

...

11 Commits

Author SHA1 Message Date
Gregory Schier
7c31718f5e Git Improvements (#382) 2026-02-04 11:46:04 -08:00
Gregory Schier
8f1463e5d0 More cleanup 2026-02-04 06:45:55 -08:00
Gregory Schier
0dc8807808 Cleanup more unused functions 2026-02-04 06:44:13 -08:00
Gregory Schier
f24a159b8a Clean up unused functions 2026-02-04 06:28:45 -08:00
Zhizhen He
0b91d3aaff feat: add breadcrumbs to folder setting (#296) 2026-02-03 07:40:24 -08:00
Gregory Schier
431dc1c896 Adjust dev menu 2026-02-02 17:58:58 -08:00
Gregory Schier
bc8277b56b Fix header behavior on cross-origin redirects (#378)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:58:27 -08:00
gschier
0afed185d9 Deploying to main from @ mountain-loop/yaak@55cee00601 🚀 2026-02-02 15:46:27 +00:00
Gregory Schier
55cee00601 More reliable plugin runtime kill 2026-02-02 07:45:19 -08:00
Gregory Schier
b41a8e04cb Graceful oauth server shutdown 2026-02-02 07:31:55 -08:00
Gregory Schier
eff4519d91 Have cancellation work before the request is sent 2026-02-02 07:09:48 -08:00
32 changed files with 827 additions and 322 deletions

View File

@@ -22,7 +22,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)?;
//

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

View File

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

View File

@@ -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, &current_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"
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

@@ -135,6 +135,7 @@ export function Workspace() {
open={!floatingSidebarHidden}
portalName="sidebar"
onClose={() => setFloatingSidebarHidden(true)}
zIndex={20}
>
<m.div
initial={{ opacity: 0, x: -20 }}

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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