mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-16 15:47:47 +01:00
Compare commits
20 Commits
v2025.10.0
...
v2026.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7e840a57 | ||
|
|
8969748c3c | ||
|
|
4e15ac10a6 | ||
|
|
47a3d44888 | ||
|
|
eb10910d20 | ||
|
|
6ba83d424d | ||
|
|
beb47a6b6a | ||
|
|
1893b8f8dd | ||
|
|
7a5bca7aae | ||
|
|
9a75bc2ae7 | ||
|
|
65514e3882 | ||
|
|
9ddaafb79f | ||
|
|
de47ee19ec | ||
|
|
ea730d0184 | ||
|
|
fe706998d4 | ||
|
|
99209e088f | ||
|
|
3eb29ff2fe | ||
|
|
b759003c83 | ||
|
|
6cba38ac89 | ||
|
|
ba8f85baaf |
@@ -37,3 +37,11 @@ The skill generates markdown-formatted release notes following this structure:
|
||||
|
||||
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
|
||||
**IMPORTANT**: PRs by `@gschier` should not mention the @username
|
||||
|
||||
## After Generating Release Notes
|
||||
|
||||
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
|
||||
|
||||
```bash
|
||||
gh release create <tag> --draft --prerelease --title "<tag>" --notes '<release notes>'
|
||||
```
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -89,6 +89,8 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run bootstrap
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: npm test
|
||||
|
||||
@@ -64,7 +64,7 @@ visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment
|
||||
## Useful Resources
|
||||
|
||||
- [Feedback and Bug Reports](https://feedback.yaak.app)
|
||||
- [Documentation](https://feedback.yaak.app/help)
|
||||
- [Documentation](https://yaak.app/docs)
|
||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||
|
||||
@@ -4,6 +4,8 @@ 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;
|
||||
use yaak_plugins::events::GetThemesResponse;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::{
|
||||
@@ -97,3 +99,17 @@ pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
|
||||
window.crypto().set_human_key(workspace_id, key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().disable_encryption(workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
|
||||
default_headers()
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ use crate::error::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::command;
|
||||
use yaak_git::{
|
||||
GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, git_add, git_add_credential,
|
||||
git_add_remote, git_checkout_branch, git_commit, git_create_branch, git_delete_branch,
|
||||
git_fetch_all, git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes,
|
||||
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
||||
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
|
||||
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
|
||||
git_init, git_log, git_merge_branch, git_pull, git_push, git_remotes, git_rename_branch,
|
||||
git_rm_remote, git_status, git_unstage,
|
||||
};
|
||||
|
||||
@@ -16,22 +17,36 @@ use yaak_git::{
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> {
|
||||
Ok(git_checkout_branch(dir, branch, force)?)
|
||||
Ok(git_checkout_branch(dir, branch, force).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||
Ok(git_create_branch(dir, branch)?)
|
||||
pub async fn cmd_git_branch(dir: &Path, branch: &str, base: Option<&str>) -> Result<()> {
|
||||
Ok(git_create_branch(dir, branch, base).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_delete_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||
Ok(git_delete_branch(dir, branch)?)
|
||||
pub async fn cmd_git_delete_branch(
|
||||
dir: &Path,
|
||||
branch: &str,
|
||||
force: Option<bool>,
|
||||
) -> Result<BranchDeleteResult> {
|
||||
Ok(git_delete_branch(dir, branch, force.unwrap_or(false)).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_merge_branch(dir: &Path, branch: &str, force: bool) -> Result<()> {
|
||||
Ok(git_merge_branch(dir, branch, force)?)
|
||||
pub async fn cmd_git_delete_remote_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||
Ok(git_delete_remote_branch(dir, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_merge_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||
Ok(git_merge_branch(dir, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
|
||||
Ok(git_rename_branch(dir, old_name, new_name).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
@@ -49,6 +64,11 @@ pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
||||
Ok(git_init(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
|
||||
Ok(git_clone(url, dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||
Ok(git_commit(dir, message).await?)
|
||||
@@ -87,12 +107,11 @@ pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()>
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add_credential(
|
||||
dir: &Path,
|
||||
remote_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
Ok(git_add_credential(dir, remote_url, username, password).await?)
|
||||
Ok(git_add_credential(remote_url, username, password).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::http_request::{resolve_http_request, send_http_request};
|
||||
use crate::import::import_data;
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_template};
|
||||
use crate::render::{render_grpc_request, render_json_value, render_template};
|
||||
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
|
||||
use crate::uri_scheme::handle_deep_link;
|
||||
use error::Result as YaakResult;
|
||||
@@ -101,6 +101,7 @@ struct AppMetaData {
|
||||
app_data_dir: String,
|
||||
app_log_dir: String,
|
||||
vendored_plugin_dir: String,
|
||||
default_project_dir: String,
|
||||
feature_updater: bool,
|
||||
feature_license: bool,
|
||||
}
|
||||
@@ -111,6 +112,7 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
|
||||
let app_log_dir = app_handle.path().app_log_dir()?;
|
||||
let vendored_plugin_dir =
|
||||
app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?;
|
||||
let default_project_dir = app_handle.path().home_dir()?.join("YaakProjects");
|
||||
Ok(AppMetaData {
|
||||
is_dev: is_dev(),
|
||||
version: app_handle.package_info().version.to_string(),
|
||||
@@ -118,6 +120,7 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
|
||||
app_data_dir: app_data_dir.to_string_lossy().to_string(),
|
||||
app_log_dir: app_log_dir.to_string_lossy().to_string(),
|
||||
vendored_plugin_dir: vendored_plugin_dir.to_string_lossy().to_string(),
|
||||
default_project_dir: default_project_dir.to_string_lossy().to_string(),
|
||||
feature_license: cfg!(feature = "license"),
|
||||
feature_updater: cfg!(feature = "updater"),
|
||||
})
|
||||
@@ -189,7 +192,6 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
request_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
proto_files: Vec<String>,
|
||||
skip_cache: Option<bool>,
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||
@@ -224,18 +226,21 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
let settings = window.db().get_settings();
|
||||
let client_certificate =
|
||||
find_client_certificate(req.url.as_str(), &settings.client_certificates);
|
||||
let proto_files: Vec<PathBuf> =
|
||||
proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect();
|
||||
|
||||
Ok(grpc_handle
|
||||
.lock()
|
||||
.await
|
||||
// Always invalidate cached pool when this command is called, to force re-reflection
|
||||
let mut handle = grpc_handle.lock().await;
|
||||
handle.invalidate_pool(&req.id, &uri, &proto_files);
|
||||
|
||||
Ok(handle
|
||||
.services(
|
||||
&req.id,
|
||||
&uri,
|
||||
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
|
||||
&proto_files,
|
||||
&metadata,
|
||||
workspace.setting_validate_certificates,
|
||||
client_certificate,
|
||||
skip_cache.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| GenericError(e.to_string()))?)
|
||||
@@ -1055,14 +1060,54 @@ async fn cmd_get_http_authentication_summaries<R: Runtime>(
|
||||
#[tauri::command]
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
_environment_id: Option<&str>,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<GetHttpAuthenticationConfigResponse> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication config".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values for token lookup
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.get_http_authentication_config(&window.plugin_context(), auth_name, values, model.id())
|
||||
.get_http_authentication_config(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
rendered_values,
|
||||
model.id(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
@@ -1109,19 +1154,54 @@ async fn cmd_call_grpc_request_action<R: Runtime>(
|
||||
#[tauri::command]
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
_environment_id: Option<&str>,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<()> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication action".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Send,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.call_http_authentication_action(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
action_index,
|
||||
values,
|
||||
rendered_values,
|
||||
&model.id(),
|
||||
)
|
||||
.await?)
|
||||
@@ -1641,6 +1721,8 @@ pub fn run() {
|
||||
//
|
||||
// Migrated commands
|
||||
crate::commands::cmd_decrypt_template,
|
||||
crate::commands::cmd_default_headers,
|
||||
crate::commands::cmd_disable_encryption,
|
||||
crate::commands::cmd_enable_encryption,
|
||||
crate::commands::cmd_get_themes,
|
||||
crate::commands::cmd_reveal_workspace_key,
|
||||
@@ -1669,10 +1751,13 @@ pub fn run() {
|
||||
git_ext::cmd_git_checkout,
|
||||
git_ext::cmd_git_branch,
|
||||
git_ext::cmd_git_delete_branch,
|
||||
git_ext::cmd_git_delete_remote_branch,
|
||||
git_ext::cmd_git_merge_branch,
|
||||
git_ext::cmd_git_rename_branch,
|
||||
git_ext::cmd_git_status,
|
||||
git_ext::cmd_git_log,
|
||||
git_ext::cmd_git_initialize,
|
||||
git_ext::cmd_git_clone,
|
||||
git_ext::cmd_git_commit,
|
||||
git_ext::cmd_git_fetch_all,
|
||||
git_ext::cmd_git_push,
|
||||
|
||||
@@ -11,3 +11,7 @@ export function revealWorkspaceKey(workspaceId: string) {
|
||||
export function setWorkspaceKey(args: { workspaceId: string; key: string }) {
|
||||
return invoke<void>('cmd_set_workspace_key', args);
|
||||
}
|
||||
|
||||
export function disableEncryption(workspaceId: string) {
|
||||
return invoke<void>('cmd_disable_encryption', { workspaceId });
|
||||
}
|
||||
|
||||
@@ -115,6 +115,35 @@ impl EncryptionManager {
|
||||
self.set_workspace_key(workspace_id, &wkey)
|
||||
}
|
||||
|
||||
pub fn disable_encryption(&self, workspace_id: &str) -> Result<()> {
|
||||
info!("Disabling encryption for {workspace_id}");
|
||||
|
||||
self.query_manager.with_tx::<(), Error>(|tx| {
|
||||
let workspace = tx.get_workspace(workspace_id)?;
|
||||
let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;
|
||||
|
||||
// Clear encryption challenge on workspace
|
||||
tx.upsert_workspace(
|
||||
&Workspace { encryption_key_challenge: None, ..workspace },
|
||||
&UpdateSource::Background,
|
||||
)?;
|
||||
|
||||
// Clear encryption key on workspace meta
|
||||
tx.upsert_workspace_meta(
|
||||
&WorkspaceMeta { encryption_key: None, ..workspace_meta },
|
||||
&UpdateSource::Background,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
// Remove from cache
|
||||
let mut cache = self.cached_workspace_keys.lock().unwrap();
|
||||
cache.remove(workspace_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceKey> {
|
||||
{
|
||||
let cache = self.cached_workspace_keys.lock().unwrap();
|
||||
|
||||
4
crates/yaak-git/bindings/gen_git.ts
generated
4
crates/yaak-git/bindings/gen_git.ts
generated
@@ -1,6 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SyncModel } from "./gen_models";
|
||||
|
||||
export type BranchDeleteResult = { "type": "success", message: string, } | { "type": "not_fully_merged" };
|
||||
|
||||
export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||
|
||||
export type GitAuthor = { name: string | null, email: string | null, };
|
||||
|
||||
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation';
|
||||
import { queryClient } from '@yaakapp/app/lib/queryClient';
|
||||
import { useMemo } from 'react';
|
||||
import { GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
|
||||
import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
|
||||
|
||||
export * from './bindings/gen_git';
|
||||
|
||||
@@ -59,7 +59,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
if (creds == null) throw new Error('Canceled');
|
||||
|
||||
await invoke('cmd_git_add_credential', {
|
||||
dir,
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
@@ -90,21 +89,31 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
branch: createFastMutation<void, string, { branch: string }>({
|
||||
createBranch: createFastMutation<void, string, { branch: string; base?: string }>({
|
||||
mutationKey: ['git', 'branch', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
mergeBranch: createFastMutation<void, string, { branch: string; force: boolean }>({
|
||||
mergeBranch: createFastMutation<void, string, { branch: string }>({
|
||||
mutationKey: ['git', 'merge', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
deleteBranch: createFastMutation<void, string, { branch: string }>({
|
||||
deleteBranch: createFastMutation<BranchDeleteResult, string, { branch: string, force?: boolean }>({
|
||||
mutationKey: ['git', 'delete-branch', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
deleteRemoteBranch: createFastMutation<void, string, { branch: string }>({
|
||||
mutationKey: ['git', 'delete-remote-branch', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_delete_remote_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
renameBranch: createFastMutation<void, string, { oldName: string, newName: string }>({
|
||||
mutationKey: ['git', 'rename-branch', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_rename_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
checkout: createFastMutation<string, string, { branch: string; force: boolean }>({
|
||||
mutationKey: ['git', 'checkout', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_checkout', { dir, ...args }),
|
||||
@@ -144,7 +153,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
if (creds == null) throw new Error('Canceled');
|
||||
|
||||
await invoke('cmd_git_add_credential', {
|
||||
dir,
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
@@ -166,3 +174,28 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
async function getRemotes(dir: string) {
|
||||
return invoke<GitRemote[]>('cmd_git_remotes', { dir });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a git repository, prompting for credentials if needed.
|
||||
*/
|
||||
export async function gitClone(
|
||||
url: string,
|
||||
dir: string,
|
||||
promptCredentials: (args: { url: string; error: string | null }) => Promise<GitCredentials | null>,
|
||||
): Promise<CloneResult> {
|
||||
const result = await invoke<CloneResult>('cmd_git_clone', { url, dir });
|
||||
if (result.type !== 'needs_credentials') return result;
|
||||
|
||||
// Prompt for credentials
|
||||
const creds = await promptCredentials({ url: result.url, error: result.error });
|
||||
if (creds == null) return {type: 'cancelled'};
|
||||
|
||||
// Store credentials and retry
|
||||
await invoke('cmd_git_add_credential', {
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
|
||||
return invoke<CloneResult>('cmd_git_clone', { url, dir });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,15 @@ use std::process::Stdio;
|
||||
use tokio::process::Command;
|
||||
use yaak_common::command::new_xplatform_command;
|
||||
|
||||
/// Create a git command that runs in the specified directory
|
||||
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
||||
let mut cmd = new_binary_command_global().await?;
|
||||
cmd.arg("-C").arg(dir);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// Create a git command without a specific directory (for global operations)
|
||||
pub(crate) async fn new_binary_command_global() -> Result<Command> {
|
||||
// 1. Probe that `git` exists and is runnable
|
||||
let mut probe = new_xplatform_command("git");
|
||||
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
||||
@@ -17,8 +25,6 @@ pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
||||
}
|
||||
|
||||
// 2. Build the reusable git command
|
||||
let mut cmd = new_xplatform_command("git");
|
||||
cmd.arg("-C").arg(dir);
|
||||
|
||||
let cmd = new_xplatform_command("git");
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
@@ -1,99 +1,153 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::merge::do_merge;
|
||||
use crate::repository::open_repo;
|
||||
use crate::util::{bytes_to_string, get_branch_by_name, get_current_branch};
|
||||
use git2::BranchType;
|
||||
use git2::build::CheckoutBuilder;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
|
||||
if branch_name.starts_with("origin/") {
|
||||
return git_checkout_remote_branch(dir, branch_name, force);
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum BranchDeleteResult {
|
||||
Success { message: String },
|
||||
NotFullyMerged,
|
||||
}
|
||||
|
||||
let repo = open_repo(dir)?;
|
||||
let branch = get_branch_by_name(&repo, branch_name)?;
|
||||
let branch_ref = branch.into_reference();
|
||||
let branch_tree = branch_ref.peel_to_tree()?;
|
||||
pub async fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
|
||||
let branch_name = branch_name.trim_start_matches("origin/");
|
||||
|
||||
let mut options = CheckoutBuilder::default();
|
||||
let mut args = vec!["checkout"];
|
||||
if force {
|
||||
options.force();
|
||||
args.push("--force");
|
||||
}
|
||||
args.push(branch_name);
|
||||
|
||||
repo.checkout_tree(branch_tree.as_object(), Some(&mut options))?;
|
||||
repo.set_head(branch_ref.name().unwrap())?;
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git checkout: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to checkout: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(branch_name.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn git_checkout_remote_branch(
|
||||
dir: &Path,
|
||||
branch_name: &str,
|
||||
force: bool,
|
||||
) -> Result<String> {
|
||||
let branch_name = branch_name.trim_start_matches("origin/");
|
||||
let repo = open_repo(dir)?;
|
||||
|
||||
let refname = format!("refs/remotes/origin/{}", branch_name);
|
||||
let remote_ref = repo.find_reference(&refname)?;
|
||||
let commit = remote_ref.peel_to_commit()?;
|
||||
|
||||
let mut new_branch = repo.branch(branch_name, &commit, false)?;
|
||||
let upstream_name = format!("origin/{}", branch_name);
|
||||
new_branch.set_upstream(Some(&upstream_name))?;
|
||||
|
||||
git_checkout_branch(dir, branch_name, force)
|
||||
}
|
||||
|
||||
pub fn git_create_branch(dir: &Path, name: &str) -> Result<()> {
|
||||
let repo = open_repo(dir)?;
|
||||
let head = match repo.head() {
|
||||
Ok(h) => h,
|
||||
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
|
||||
let msg = "Cannot create branch when there are no commits";
|
||||
return Err(GenericError(msg.into()));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let head = head.peel_to_commit()?;
|
||||
|
||||
repo.branch(name, &head, false)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn git_delete_branch(dir: &Path, name: &str) -> Result<()> {
|
||||
let repo = open_repo(dir)?;
|
||||
let mut branch = get_branch_by_name(&repo, name)?;
|
||||
|
||||
if branch.is_head() {
|
||||
info!("Deleting head branch");
|
||||
let branches = repo.branches(Some(BranchType::Local))?;
|
||||
let other_branch = branches.into_iter().filter_map(|b| b.ok()).find(|b| !b.0.is_head());
|
||||
let other_branch = match other_branch {
|
||||
None => return Err(GenericError("Cannot delete only branch".into())),
|
||||
Some(b) => bytes_to_string(b.0.name_bytes()?)?,
|
||||
};
|
||||
|
||||
git_checkout_branch(dir, &other_branch, true)?;
|
||||
pub async fn git_create_branch(dir: &Path, name: &str, base: Option<&str>) -> Result<()> {
|
||||
let mut cmd = new_binary_command(dir).await?;
|
||||
cmd.arg("branch").arg(name);
|
||||
if let Some(base_branch) = base {
|
||||
cmd.arg(base_branch);
|
||||
}
|
||||
|
||||
branch.delete()?;
|
||||
let out =
|
||||
cmd.output().await.map_err(|e| GenericError(format!("failed to run git branch: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to create branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn git_merge_branch(dir: &Path, name: &str, _force: bool) -> Result<()> {
|
||||
let repo = open_repo(dir)?;
|
||||
let local_branch = get_current_branch(&repo)?.unwrap();
|
||||
pub async fn git_delete_branch(dir: &Path, name: &str, force: bool) -> Result<BranchDeleteResult> {
|
||||
let mut cmd = new_binary_command(dir).await?;
|
||||
|
||||
let commit_to_merge = get_branch_by_name(&repo, name)?.into_reference();
|
||||
let commit_to_merge = repo.reference_to_annotated_commit(&commit_to_merge)?;
|
||||
let out =
|
||||
if force { cmd.args(["branch", "-D", name]) } else { cmd.args(["branch", "-d", name]) }
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git branch -d: {e}")))?;
|
||||
|
||||
do_merge(&repo, &local_branch, &commit_to_merge)?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() && stderr.to_lowercase().contains("not fully merged") {
|
||||
return Ok(BranchDeleteResult::NotFullyMerged);
|
||||
}
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to delete branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(BranchDeleteResult::Success { message: combined })
|
||||
}
|
||||
|
||||
pub async fn git_merge_branch(dir: &Path, name: &str) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["merge", name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git merge: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
// Check for merge conflicts
|
||||
if combined.to_lowercase().contains("conflict") {
|
||||
return Err(GenericError(
|
||||
"Merge conflicts detected. Please resolve them manually.".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(GenericError(format!("Failed to merge: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_delete_remote_branch(dir: &Path, name: &str) -> Result<()> {
|
||||
// Remote branch names come in as "origin/branch-name", extract the branch name
|
||||
let branch_name = name.trim_start_matches("origin/");
|
||||
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["push", "origin", "--delete", branch_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git push --delete: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to delete remote branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["branch", "-m", old_name, new_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git branch -m: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to rename branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
53
crates/yaak-git/src/clone.rs
Normal file
53
crates/yaak-git/src/clone.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum CloneResult {
|
||||
Success,
|
||||
Cancelled,
|
||||
NeedsCredentials { url: String, error: Option<String> },
|
||||
}
|
||||
|
||||
pub async fn git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
|
||||
let parent = dir.parent().ok_or_else(|| GenericError("Invalid clone directory".to_string()))?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| GenericError(format!("Failed to create directory: {e}")))?;
|
||||
let mut cmd = new_binary_command(parent).await?;
|
||||
cmd.args(["clone", url]).arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
||||
|
||||
let out =
|
||||
cmd.output().await.map_err(|e| GenericError(format!("failed to run git clone: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
let combined_lower = combined.to_lowercase();
|
||||
|
||||
info!("Cloned status={}: {combined}", out.status);
|
||||
|
||||
if !out.status.success() {
|
||||
// Check for credentials error
|
||||
if combined_lower.contains("could not read") {
|
||||
return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: None });
|
||||
}
|
||||
if combined_lower.contains("unable to access")
|
||||
|| combined_lower.contains("authentication failed")
|
||||
{
|
||||
return Ok(CloneResult::NeedsCredentials {
|
||||
url: url.to_string(),
|
||||
error: Some(combined.to_string()),
|
||||
});
|
||||
}
|
||||
return Err(GenericError(format!("Failed to clone: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(CloneResult::Success)
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::binary::new_binary_command_global;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use url::Url;
|
||||
|
||||
pub async fn git_add_credential(
|
||||
dir: &Path,
|
||||
remote_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
pub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> {
|
||||
let url = Url::parse(remote_url)
|
||||
.map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?;
|
||||
let protocol = url.scheme();
|
||||
let host = url.host_str().unwrap();
|
||||
let path = Some(url.path());
|
||||
|
||||
let mut child = new_binary_command(dir)
|
||||
let mut child = new_binary_command_global()
|
||||
.await?
|
||||
.args(["credential", "approve"])
|
||||
.stdin(Stdio::piped())
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
mod add;
|
||||
mod binary;
|
||||
mod branch;
|
||||
mod clone;
|
||||
mod commit;
|
||||
mod credential;
|
||||
pub mod error;
|
||||
mod fetch;
|
||||
mod init;
|
||||
mod log;
|
||||
mod merge;
|
||||
|
||||
mod pull;
|
||||
mod push;
|
||||
mod remotes;
|
||||
@@ -18,7 +19,11 @@ mod util;
|
||||
|
||||
// Re-export all git functions for external use
|
||||
pub use add::git_add;
|
||||
pub use branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch};
|
||||
pub use branch::{
|
||||
BranchDeleteResult, git_checkout_branch, git_create_branch, git_delete_branch,
|
||||
git_delete_remote_branch, git_merge_branch, git_rename_branch,
|
||||
};
|
||||
pub use clone::{CloneResult, git_clone};
|
||||
pub use commit::git_commit;
|
||||
pub use credential::git_add_credential;
|
||||
pub use fetch::git_fetch_all;
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
use crate::error::Error::MergeConflicts;
|
||||
use crate::util::bytes_to_string;
|
||||
use git2::{AnnotatedCommit, Branch, IndexEntry, Reference, Repository};
|
||||
use log::{debug, info};
|
||||
|
||||
pub(crate) fn do_merge(
|
||||
repo: &Repository,
|
||||
local_branch: &Branch,
|
||||
commit_to_merge: &AnnotatedCommit,
|
||||
) -> crate::error::Result<()> {
|
||||
debug!("Merging remote branches");
|
||||
let analysis = repo.merge_analysis(&[&commit_to_merge])?;
|
||||
|
||||
if analysis.0.is_fast_forward() {
|
||||
let refname = bytes_to_string(local_branch.get().name_bytes())?;
|
||||
match repo.find_reference(&refname) {
|
||||
Ok(mut r) => {
|
||||
merge_fast_forward(repo, &mut r, &commit_to_merge)?;
|
||||
}
|
||||
Err(_) => {
|
||||
// The branch doesn't exist, so set the reference to the commit directly. Usually
|
||||
// this is because you are pulling into an empty repository.
|
||||
repo.reference(
|
||||
&refname,
|
||||
commit_to_merge.id(),
|
||||
true,
|
||||
&format!("Setting {} to {}", refname, commit_to_merge.id()),
|
||||
)?;
|
||||
repo.set_head(&refname)?;
|
||||
repo.checkout_head(Some(
|
||||
git2::build::CheckoutBuilder::default()
|
||||
.allow_conflicts(true)
|
||||
.conflict_style_merge(true)
|
||||
.force(),
|
||||
))?;
|
||||
}
|
||||
};
|
||||
} else if analysis.0.is_normal() {
|
||||
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
|
||||
merge_normal(repo, &head_commit, commit_to_merge)?;
|
||||
} else {
|
||||
debug!("Skipping merge. Nothing to do")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn merge_fast_forward(
|
||||
repo: &Repository,
|
||||
local_reference: &mut Reference,
|
||||
remote_commit: &AnnotatedCommit,
|
||||
) -> crate::error::Result<()> {
|
||||
info!("Performing fast forward");
|
||||
let name = match local_reference.name() {
|
||||
Some(s) => s.to_string(),
|
||||
None => String::from_utf8_lossy(local_reference.name_bytes()).to_string(),
|
||||
};
|
||||
let msg = format!("Fast-Forward: Setting {} to id: {}", name, remote_commit.id());
|
||||
local_reference.set_target(remote_commit.id(), &msg)?;
|
||||
repo.set_head(&name)?;
|
||||
repo.checkout_head(Some(
|
||||
git2::build::CheckoutBuilder::default()
|
||||
// For some reason, the force is required to make the working directory actually get
|
||||
// updated I suspect we should be adding some logic to handle dirty working directory
|
||||
// states, but this is just an example so maybe not.
|
||||
.force(),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn merge_normal(
|
||||
repo: &Repository,
|
||||
local: &AnnotatedCommit,
|
||||
remote: &AnnotatedCommit,
|
||||
) -> crate::error::Result<()> {
|
||||
info!("Performing normal merge");
|
||||
let local_tree = repo.find_commit(local.id())?.tree()?;
|
||||
let remote_tree = repo.find_commit(remote.id())?.tree()?;
|
||||
let ancestor = repo.find_commit(repo.merge_base(local.id(), remote.id())?)?.tree()?;
|
||||
|
||||
let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
|
||||
|
||||
if idx.has_conflicts() {
|
||||
let conflicts = idx.conflicts()?;
|
||||
for conflict in conflicts {
|
||||
if let Ok(conflict) = conflict {
|
||||
print_conflict(&conflict);
|
||||
}
|
||||
}
|
||||
return Err(MergeConflicts);
|
||||
}
|
||||
|
||||
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
|
||||
// now create the merge commit
|
||||
let msg = format!("Merge: {} into {}", remote.id(), local.id());
|
||||
let sig = repo.signature()?;
|
||||
let local_commit = repo.find_commit(local.id())?;
|
||||
let remote_commit = repo.find_commit(remote.id())?;
|
||||
|
||||
// Do our merge commit and set current branch head to that commit.
|
||||
let _merge_commit = repo.commit(
|
||||
Some("HEAD"),
|
||||
&sig,
|
||||
&sig,
|
||||
&msg,
|
||||
&result_tree,
|
||||
&[&local_commit, &remote_commit],
|
||||
)?;
|
||||
|
||||
// Set working tree to match head.
|
||||
repo.checkout_head(None)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_conflict(conflict: &git2::IndexConflict) {
|
||||
let ancestor = conflict.ancestor.as_ref().map(path_from_index_entry);
|
||||
let ours = conflict.our.as_ref().map(path_from_index_entry);
|
||||
let theirs = conflict.their.as_ref().map(path_from_index_entry);
|
||||
|
||||
println!("Conflict detected:");
|
||||
if let Some(path) = ancestor {
|
||||
println!(" Common ancestor: {:?}", path);
|
||||
}
|
||||
if let Some(path) = ours {
|
||||
println!(" Ours: {:?}", path);
|
||||
}
|
||||
if let Some(path) = theirs {
|
||||
println!(" Theirs: {:?}", path);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_from_index_entry(entry: &IndexEntry) -> String {
|
||||
String::from_utf8_lossy(entry.path.as_slice()).into_owned()
|
||||
}
|
||||
@@ -47,10 +47,6 @@ pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
pub(crate) fn get_branch_by_name<'s>(repo: &'s Repository, name: &str) -> Result<Branch<'s>> {
|
||||
Ok(repo.find_branch(name, BranchType::Local)?)
|
||||
}
|
||||
|
||||
pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {
|
||||
Ok(String::from_utf8(bytes.to_vec())?)
|
||||
}
|
||||
|
||||
@@ -340,10 +340,9 @@ impl GrpcHandle {
|
||||
metadata: &BTreeMap<String, String>,
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
skip_cache: bool,
|
||||
) -> Result<Vec<ServiceDefinition>> {
|
||||
// Ensure we have a pool; reflect only if missing
|
||||
if skip_cache || self.get_pool(id, uri, proto_files).is_none() {
|
||||
if self.get_pool(id, uri, proto_files).is_none() {
|
||||
info!("Reflecting gRPC services for {} at {}", id, uri);
|
||||
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
|
||||
.await?;
|
||||
|
||||
@@ -376,6 +376,9 @@ impl HttpSender for ReqwestSender {
|
||||
|
||||
// Add headers
|
||||
for header in request.headers {
|
||||
if header.0.is_empty() {
|
||||
continue;
|
||||
}
|
||||
req_builder = req_builder.header(&header.0, &header.1);
|
||||
}
|
||||
|
||||
|
||||
@@ -209,12 +209,24 @@ export function replaceModelsInStore<
|
||||
export function mergeModelsInStore<
|
||||
M extends AnyModel['model'],
|
||||
T extends Extract<AnyModel, { model: M }>,
|
||||
>(model: M, models: T[]) {
|
||||
>(model: M, models: T[], filter?: (model: T) => boolean) {
|
||||
mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {
|
||||
const existingModels = { ...prev[model] } as Record<string, T>;
|
||||
|
||||
// Merge in new models first
|
||||
for (const m of models) {
|
||||
existingModels[m.id] = m;
|
||||
}
|
||||
|
||||
// Then filter out unwanted models
|
||||
if (filter) {
|
||||
for (const [id, m] of Object.entries(existingModels)) {
|
||||
if (!filter(m)) {
|
||||
delete existingModels[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[model]: existingModels,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Filter out headers that match the hardcoded defaults (User-Agent: yaak, Accept: */*),
|
||||
-- keeping any other custom headers the user may have added.
|
||||
UPDATE workspaces
|
||||
SET headers = (
|
||||
SELECT json_group_array(json(value))
|
||||
FROM json_each(headers)
|
||||
WHERE NOT (
|
||||
(LOWER(json_extract(value, '$.name')) = 'user-agent' AND json_extract(value, '$.value') = 'yaak')
|
||||
OR (LOWER(json_extract(value, '$.name')) = 'accept' AND json_extract(value, '$.value') = '*/*')
|
||||
)
|
||||
)
|
||||
WHERE json_array_length(headers) > 0;
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::dedupe_headers;
|
||||
use crate::db_context::DbContext;
|
||||
use crate::error::Result;
|
||||
use crate::models::{GrpcRequest, GrpcRequestIden, HttpRequestHeader};
|
||||
@@ -87,6 +88,6 @@ impl<'a> DbContext<'a> {
|
||||
|
||||
metadata.append(&mut grpc_request.metadata.clone());
|
||||
|
||||
Ok(metadata)
|
||||
Ok(dedupe_headers(metadata))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::dedupe_headers;
|
||||
use crate::db_context::DbContext;
|
||||
use crate::error::Result;
|
||||
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
|
||||
@@ -87,7 +88,7 @@ impl<'a> DbContext<'a> {
|
||||
|
||||
headers.append(&mut http_request.headers.clone());
|
||||
|
||||
Ok(headers)
|
||||
Ok(dedupe_headers(headers))
|
||||
}
|
||||
|
||||
pub fn list_http_requests_for_folder_recursive(
|
||||
|
||||
@@ -19,6 +19,26 @@ mod websocket_connections;
|
||||
mod websocket_events;
|
||||
mod websocket_requests;
|
||||
mod workspace_metas;
|
||||
mod workspaces;
|
||||
pub mod workspaces;
|
||||
|
||||
const MAX_HISTORY_ITEMS: usize = 20;
|
||||
|
||||
use crate::models::HttpRequestHeader;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value.
|
||||
/// Preserves the order of first occurrence for each header name.
|
||||
pub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> {
|
||||
let mut index_by_name: HashMap<String, usize> = HashMap::new();
|
||||
let mut deduped: Vec<HttpRequestHeader> = Vec::new();
|
||||
for header in headers {
|
||||
let key = header.name.to_lowercase();
|
||||
if let Some(&idx) = index_by_name.get(&key) {
|
||||
deduped[idx] = header;
|
||||
} else {
|
||||
index_by_name.insert(key, deduped.len());
|
||||
deduped.push(header);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::dedupe_headers;
|
||||
use crate::db_context::DbContext;
|
||||
use crate::error::Result;
|
||||
use crate::models::{HttpRequestHeader, WebsocketRequest, WebsocketRequestIden};
|
||||
@@ -95,6 +96,6 @@ impl<'a> DbContext<'a> {
|
||||
|
||||
headers.append(&mut websocket_request.headers.clone());
|
||||
|
||||
Ok(headers)
|
||||
Ok(dedupe_headers(headers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,28 +65,7 @@ impl<'a> DbContext<'a> {
|
||||
}
|
||||
|
||||
pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {
|
||||
let mut workspace = w.clone();
|
||||
|
||||
// Add default headers only for NEW workspaces (empty ID means insert, not update)
|
||||
// This prevents re-adding headers if a user intentionally removes all headers
|
||||
if workspace.id.is_empty() && workspace.headers.is_empty() {
|
||||
workspace.headers = vec![
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "User-Agent".to_string(),
|
||||
value: "yaak".to_string(),
|
||||
id: None,
|
||||
},
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "Accept".to_string(),
|
||||
value: "*/*".to_string(),
|
||||
id: None,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
self.upsert(&workspace, source)
|
||||
self.upsert(w, source)
|
||||
}
|
||||
|
||||
pub fn resolve_auth_for_workspace(
|
||||
@@ -101,6 +80,28 @@ impl<'a> DbContext<'a> {
|
||||
}
|
||||
|
||||
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
|
||||
workspace.headers.clone()
|
||||
let mut headers = default_headers();
|
||||
headers.extend(workspace.headers.clone());
|
||||
headers
|
||||
}
|
||||
}
|
||||
|
||||
/// Global default headers that are always sent with requests unless overridden.
|
||||
/// These are prepended to the inheritance chain so workspace/folder/request headers
|
||||
/// can override or disable them.
|
||||
pub fn default_headers() -> Vec<HttpRequestHeader> {
|
||||
vec![
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "User-Agent".to_string(),
|
||||
value: "yaak".to_string(),
|
||||
id: None,
|
||||
},
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "Accept".to_string(),
|
||||
value: "*/*".to_string(),
|
||||
id: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -7811,9 +7811,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.3",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz",
|
||||
"integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==",
|
||||
"version": "4.11.4",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
||||
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -15743,7 +15743,7 @@
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"hono": "^4.11.3",
|
||||
"hono": "^4.11.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -17,7 +17,7 @@ npx @yaakapp/cli generate
|
||||
```
|
||||
|
||||
For more details on creating plugins, check out
|
||||
the [Quick Start Guide](https://feedback.yaak.app/help/articles/6911763-plugins-quick-start)
|
||||
the [Quick Start Guide](https://yaak.app/docs/plugin-development/plugins-quick-start)
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
|
||||
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||
|
||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||
|
||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||
|
||||
export type EncryptedKey = { encryptedKey: string, };
|
||||
@@ -77,6 +79,6 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping"
|
||||
|
||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
|
||||
|
||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
TemplateRenderRequest,
|
||||
WorkspaceInfo,
|
||||
} from '../bindings/gen_events.ts';
|
||||
import type { HttpRequest } from '../bindings/gen_models.ts';
|
||||
import type { Folder, HttpRequest } from '../bindings/gen_models.ts';
|
||||
import type { JsonValue } from '../bindings/serde_json/JsonValue';
|
||||
|
||||
export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>;
|
||||
@@ -82,6 +82,15 @@ export interface Context {
|
||||
};
|
||||
folder: {
|
||||
list(args?: ListFoldersRequest): Promise<ListFoldersResponse['folders']>;
|
||||
getById(args: { id: string }): Promise<Folder | null>;
|
||||
create(
|
||||
args: Omit<Partial<Folder>, 'id' | 'model' | 'createdAt' | 'updatedAt'> &
|
||||
Pick<Folder, 'workspaceId' | 'name'>,
|
||||
): Promise<Folder>;
|
||||
update(
|
||||
args: Omit<Partial<Folder>, 'model' | 'createdAt' | 'updatedAt'> & Pick<Folder, 'id'>,
|
||||
): Promise<Folder>;
|
||||
delete(args: { id: string }): Promise<Folder>;
|
||||
};
|
||||
httpResponse: {
|
||||
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse['httpResponses']>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DeleteKeyValueResponse,
|
||||
DeleteModelResponse,
|
||||
FindHttpResponsesResponse,
|
||||
Folder,
|
||||
GetCookieValueRequest,
|
||||
GetCookieValueResponse,
|
||||
GetHttpRequestByIdResponse,
|
||||
@@ -782,6 +783,44 @@ export class PluginInstance {
|
||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
||||
return folders;
|
||||
},
|
||||
getById: async (args: { id: string }) => {
|
||||
const payload = { type: 'list_folders_request' } as const;
|
||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
||||
return folders.find((f) => f.id === args.id) ?? null;
|
||||
},
|
||||
create: async (args) => {
|
||||
const payload = {
|
||||
type: 'upsert_model_request',
|
||||
model: {
|
||||
name: '',
|
||||
...args,
|
||||
id: '',
|
||||
model: 'folder',
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
update: async (args) => {
|
||||
const payload = {
|
||||
type: 'upsert_model_request',
|
||||
model: {
|
||||
model: 'folder',
|
||||
...args,
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
delete: async (args: { id: string }) => {
|
||||
const payload = {
|
||||
type: 'delete_model_request',
|
||||
model: 'folder',
|
||||
id: args.id,
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
getValue: async (args: GetCookieValueRequest) => {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"hono": "^4.11.3",
|
||||
"hono": "^4.11.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import * as z from 'zod';
|
||||
import type { McpServerContext } from '../types.js';
|
||||
import { getWorkspaceContext } from './helpers.js';
|
||||
import {
|
||||
authenticationSchema,
|
||||
authenticationTypeSchema,
|
||||
headersSchema,
|
||||
workspaceIdSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
|
||||
server.registerTool(
|
||||
@@ -10,10 +16,7 @@ export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
|
||||
title: 'List Folders',
|
||||
description: 'List all folders in a workspace',
|
||||
inputSchema: {
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
workspaceId: workspaceIdSchema,
|
||||
},
|
||||
},
|
||||
async ({ workspaceId }) => {
|
||||
@@ -30,4 +33,116 @@ export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_folder',
|
||||
{
|
||||
title: 'Get Folder',
|
||||
description: 'Get details of a specific folder by ID',
|
||||
inputSchema: {
|
||||
id: z.string().describe('The folder ID'),
|
||||
workspaceId: workspaceIdSchema,
|
||||
},
|
||||
},
|
||||
async ({ id, workspaceId }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
|
||||
const folder = await workspaceCtx.yaak.folder.getById({ id });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(folder, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_folder',
|
||||
{
|
||||
title: 'Create Folder',
|
||||
description: 'Create a new folder in a workspace',
|
||||
inputSchema: {
|
||||
workspaceId: workspaceIdSchema,
|
||||
name: z.string().describe('Folder name'),
|
||||
folderId: z.string().optional().describe('Parent folder ID (for nested folders)'),
|
||||
description: z.string().optional().describe('Folder description'),
|
||||
sortPriority: z.number().optional().describe('Sort priority for ordering'),
|
||||
headers: headersSchema.describe('Default headers to apply to requests in this folder'),
|
||||
authenticationType: authenticationTypeSchema,
|
||||
authentication: authenticationSchema,
|
||||
},
|
||||
},
|
||||
async ({ workspaceId: ogWorkspaceId, ...args }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);
|
||||
const workspaceId = await workspaceCtx.yaak.window.workspaceId();
|
||||
if (!workspaceId) {
|
||||
throw new Error('No workspace is open');
|
||||
}
|
||||
|
||||
const folder = await workspaceCtx.yaak.folder.create({
|
||||
workspaceId: workspaceId,
|
||||
...args,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_folder',
|
||||
{
|
||||
title: 'Update Folder',
|
||||
description: 'Update an existing folder',
|
||||
inputSchema: {
|
||||
id: z.string().describe('Folder ID to update'),
|
||||
workspaceId: workspaceIdSchema,
|
||||
name: z.string().optional().describe('Folder name'),
|
||||
folderId: z.string().optional().describe('Parent folder ID (for nested folders)'),
|
||||
description: z.string().optional().describe('Folder description'),
|
||||
sortPriority: z.number().optional().describe('Sort priority for ordering'),
|
||||
headers: headersSchema.describe('Default headers to apply to requests in this folder'),
|
||||
authenticationType: authenticationTypeSchema,
|
||||
authentication: authenticationSchema,
|
||||
},
|
||||
},
|
||||
async ({ id, workspaceId, ...updates }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
|
||||
// Fetch existing folder to merge with updates
|
||||
const existing = await workspaceCtx.yaak.folder.getById({ id });
|
||||
if (!existing) {
|
||||
throw new Error(`Folder with ID ${id} not found`);
|
||||
}
|
||||
// Merge existing fields with updates
|
||||
const folder = await workspaceCtx.yaak.folder.update({
|
||||
...existing,
|
||||
...updates,
|
||||
id,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_folder',
|
||||
{
|
||||
title: 'Delete Folder',
|
||||
description: 'Delete a folder by ID',
|
||||
inputSchema: {
|
||||
id: z.string().describe('Folder ID to delete'),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
const folder = await ctx.yaak.folder.delete({ id });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Deleted: ${folder.name} (${folder.id})` }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import * as z from 'zod';
|
||||
import type { McpServerContext } from '../types.js';
|
||||
import { getWorkspaceContext } from './helpers.js';
|
||||
import {
|
||||
authenticationSchema,
|
||||
authenticationTypeSchema,
|
||||
bodySchema,
|
||||
bodyTypeSchema,
|
||||
headersSchema,
|
||||
urlParametersSchema,
|
||||
workspaceIdSchema,
|
||||
} from './schemas.js';
|
||||
|
||||
export function registerHttpRequestTools(server: McpServer, ctx: McpServerContext) {
|
||||
server.registerTool(
|
||||
@@ -10,10 +19,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
|
||||
title: 'List HTTP Requests',
|
||||
description: 'List all HTTP requests in a workspace',
|
||||
inputSchema: {
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
workspaceId: workspaceIdSchema,
|
||||
},
|
||||
},
|
||||
async ({ workspaceId }) => {
|
||||
@@ -38,10 +44,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
|
||||
description: 'Get details of a specific HTTP request by ID',
|
||||
inputSchema: {
|
||||
id: z.string().describe('The HTTP request ID'),
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
workspaceId: workspaceIdSchema,
|
||||
},
|
||||
},
|
||||
async ({ id, workspaceId }) => {
|
||||
@@ -67,10 +70,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
|
||||
inputSchema: {
|
||||
id: z.string().describe('The HTTP request ID to send'),
|
||||
environmentId: z.string().optional().describe('Optional environment ID to use'),
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
workspaceId: workspaceIdSchema,
|
||||
},
|
||||
},
|
||||
async ({ id, workspaceId }) => {
|
||||
@@ -99,10 +99,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
|
||||
title: 'Create HTTP Request',
|
||||
description: 'Create a new HTTP request',
|
||||
inputSchema: {
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
workspaceId: workspaceIdSchema,
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -111,62 +108,12 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
|
||||
method: z.string().optional().describe('HTTP method (defaults to GET)'),
|
||||
folderId: z.string().optional().describe('Parent folder ID'),
|
||||
description: z.string().optional().describe('Request description'),
|
||||
headers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('Request headers'),
|
||||
urlParameters: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('URL query parameters'),
|
||||
bodyType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
|
||||
),
|
||||
body: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe(
|
||||
'Body content object. Structure varies by bodyType:\n' +
|
||||
'- "binary": { filePath: "/path/to/file" }\n' +
|
||||
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
|
||||
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
|
||||
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
|
||||
'- text-based (application/json, etc.): { text: "raw body content" }',
|
||||
),
|
||||
authenticationType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent folder/workspace.',
|
||||
),
|
||||
authentication: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe(
|
||||
'Authentication configuration object. Structure varies by authenticationType:\n' +
|
||||
'- "basic": { username: "user", password: "pass" }\n' +
|
||||
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
|
||||
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
|
||||
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
|
||||
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
|
||||
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
|
||||
'- "none": {}',
|
||||
),
|
||||
headers: headersSchema.describe('Request headers'),
|
||||
urlParameters: urlParametersSchema,
|
||||
bodyType: bodyTypeSchema,
|
||||
body: bodySchema,
|
||||
authenticationType: authenticationTypeSchema,
|
||||
authentication: authenticationSchema,
|
||||
},
|
||||
},
|
||||
async ({ workspaceId: ogWorkspaceId, ...args }) => {
|
||||
@@ -194,68 +141,18 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
|
||||
description: 'Update an existing HTTP request',
|
||||
inputSchema: {
|
||||
id: z.string().describe('HTTP request ID to update'),
|
||||
workspaceId: z.string().describe('Workspace ID'),
|
||||
workspaceId: workspaceIdSchema,
|
||||
name: z.string().optional().describe('Request name'),
|
||||
url: z.string().optional().describe('Request URL'),
|
||||
method: z.string().optional().describe('HTTP method'),
|
||||
folderId: z.string().optional().describe('Parent folder ID'),
|
||||
description: z.string().optional().describe('Request description'),
|
||||
headers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('Request headers'),
|
||||
urlParameters: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('URL query parameters'),
|
||||
bodyType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
|
||||
),
|
||||
body: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe(
|
||||
'Body content object. Structure varies by bodyType:\n' +
|
||||
'- "binary": { filePath: "/path/to/file" }\n' +
|
||||
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
|
||||
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
|
||||
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
|
||||
'- text-based (application/json, etc.): { text: "raw body content" }',
|
||||
),
|
||||
authenticationType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent folder/workspace.',
|
||||
),
|
||||
authentication: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe(
|
||||
'Authentication configuration object. Structure varies by authenticationType:\n' +
|
||||
'- "basic": { username: "user", password: "pass" }\n' +
|
||||
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
|
||||
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
|
||||
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
|
||||
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
|
||||
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
|
||||
'- "none": {}',
|
||||
),
|
||||
headers: headersSchema.describe('Request headers'),
|
||||
urlParameters: urlParametersSchema,
|
||||
bodyType: bodyTypeSchema,
|
||||
body: bodySchema,
|
||||
authenticationType: authenticationTypeSchema,
|
||||
authentication: authenticationSchema,
|
||||
},
|
||||
},
|
||||
async ({ id, workspaceId, ...updates }) => {
|
||||
|
||||
67
plugins-external/mcp-server/src/tools/schemas.ts
Normal file
67
plugins-external/mcp-server/src/tools/schemas.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const workspaceIdSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)');
|
||||
|
||||
export const headersSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional();
|
||||
|
||||
export const urlParametersSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('URL query parameters');
|
||||
|
||||
export const bodyTypeSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Body type. Supported values: "binary", "graphql", "application/x-www-form-urlencoded", "multipart/form-data", or any text-based type (e.g., "application/json", "text/plain")',
|
||||
);
|
||||
|
||||
export const bodySchema = z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe(
|
||||
'Body content object. Structure varies by bodyType:\n' +
|
||||
'- "binary": { filePath: "/path/to/file" }\n' +
|
||||
'- "graphql": { query: "{ users { name } }", variables: "{\\"id\\": \\"123\\"}" }\n' +
|
||||
'- "application/x-www-form-urlencoded": { form: [{ name: "key", value: "val", enabled: true }] }\n' +
|
||||
'- "multipart/form-data": { form: [{ name: "field", value: "text", file: "/path/to/file", enabled: true }] }\n' +
|
||||
'- text-based (application/json, etc.): { text: "raw body content" }',
|
||||
);
|
||||
|
||||
export const authenticationTypeSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Authentication type. Common values: "basic", "bearer", "oauth2", "apikey", "jwt", "awsv4", "oauth1", "ntlm", "none". Use null to inherit from parent.',
|
||||
);
|
||||
|
||||
export const authenticationSchema = z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.describe(
|
||||
'Authentication configuration object. Structure varies by authenticationType:\n' +
|
||||
'- "basic": { username: "user", password: "pass" }\n' +
|
||||
'- "bearer": { token: "abc123", prefix: "Bearer" }\n' +
|
||||
'- "oauth2": { clientId: "...", clientSecret: "...", grantType: "authorization_code", authorizationUrl: "...", accessTokenUrl: "...", scope: "...", ... }\n' +
|
||||
'- "apikey": { location: "header" | "query", key: "X-API-Key", value: "..." }\n' +
|
||||
'- "jwt": { algorithm: "HS256", secret: "...", payload: "{ ... }" }\n' +
|
||||
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
|
||||
'- "none": {}',
|
||||
);
|
||||
161
src-web/components/CloneGitRepositoryDialog.tsx
Normal file
161
src-web/components/CloneGitRepositoryDialog.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { gitClone } from '@yaakapp-internal/git';
|
||||
import { useState } from 'react';
|
||||
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import { showErrorToast } from '../lib/toast';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { promptCredentials } from './git/credentials';
|
||||
|
||||
interface Props {
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
// Detect path separator from an existing path (defaults to /)
|
||||
function getPathSeparator(path: string): string {
|
||||
return path.includes('\\') ? '\\' : '/';
|
||||
}
|
||||
|
||||
export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
const [url, setUrl] = useState<string>('');
|
||||
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
|
||||
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
|
||||
const [hasSubdirectory, setHasSubdirectory] = useState(false);
|
||||
const [subdirectory, setSubdirectory] = useState<string>('');
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const repoName = extractRepoName(url);
|
||||
const sep = getPathSeparator(baseDirectory);
|
||||
const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
|
||||
const directory = directoryOverride ?? computedDirectory;
|
||||
const workspaceDirectory =
|
||||
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
const dir = await open({
|
||||
title: 'Select Directory',
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (dir != null) {
|
||||
setBaseDirectory(dir);
|
||||
setDirectoryOverride(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClone = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!url || !directory) return;
|
||||
|
||||
setIsCloning(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await gitClone(url, directory, promptCredentials);
|
||||
|
||||
if (result.type === 'needs_credentials') {
|
||||
setError(
|
||||
result.error ?? 'Authentication failed. Please check your credentials and try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the workspace from the cloned directory (or subdirectory)
|
||||
await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
|
||||
|
||||
hide();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
showErrorToast({
|
||||
id: 'git-clone-error',
|
||||
title: 'Clone Failed',
|
||||
message: String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
|
||||
{error && (
|
||||
<Banner color="danger" className="w-full">
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<PlainInput
|
||||
required
|
||||
label="Repository URL"
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
defaultValue={url}
|
||||
onChange={setUrl}
|
||||
/>
|
||||
|
||||
<PlainInput
|
||||
label="Directory"
|
||||
placeholder={appInfo.defaultProjectDir}
|
||||
defaultValue={directory}
|
||||
onChange={setDirectoryOverride}
|
||||
rightSlot={
|
||||
<IconButton
|
||||
size="xs"
|
||||
className="mr-0.5 !h-auto my-0.5"
|
||||
icon="folder"
|
||||
title="Browse"
|
||||
onClick={handleSelectDirectory}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={hasSubdirectory}
|
||||
onChange={setHasSubdirectory}
|
||||
title="Workspace is in a subdirectory"
|
||||
help="Enable if the Yaak workspace files are not at the root of the repository"
|
||||
/>
|
||||
|
||||
{hasSubdirectory && (
|
||||
<PlainInput
|
||||
label="Subdirectory"
|
||||
placeholder="path/to/workspace"
|
||||
defaultValue={subdirectory}
|
||||
onChange={setSubdirectory}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="w-full mt-3"
|
||||
disabled={!url || !directory || isCloning}
|
||||
isLoading={isCloning}
|
||||
>
|
||||
{isCloning ? 'Cloning...' : 'Clone Repository'}
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function extractRepoName(url: string): string {
|
||||
// Handle various Git URL formats:
|
||||
// https://github.com/user/repo.git
|
||||
// git@github.com:user/repo.git
|
||||
// https://github.com/user/repo
|
||||
const match = url.match(/\/([^/]+?)(\.git)?$/);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
// Fallback for SSH-style URLs
|
||||
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
|
||||
if (sshMatch?.[1]) {
|
||||
return sshMatch[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createWorkspaceModel, foldersAtom, patchModel } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useAuthTab } from '../hooks/useAuthTab';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
@@ -37,7 +37,6 @@ export type FolderSettingsTab =
|
||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const folder = folders.find((f) => f.id === folderId) ?? null;
|
||||
const [activeTab, setActiveTab] = useState<string>(tab ?? TAB_GENERAL);
|
||||
const authTab = useAuthTab(TAB_AUTH, folder);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, folder);
|
||||
const inheritedHeaders = useInheritedHeaders(folder);
|
||||
@@ -69,8 +68,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChangeValue={setActiveTab}
|
||||
defaultValue={tab ?? TAB_GENERAL}
|
||||
label="Folder Settings"
|
||||
className="pt-2 pb-2 pl-3 pr-1"
|
||||
layout="horizontal"
|
||||
@@ -113,7 +111,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
<VStack alignItems="center" space={1.5}>
|
||||
<p>
|
||||
Override{' '}
|
||||
<Link href="https://feedback.yaak.app/help/articles/3284139-environments-and-variables">
|
||||
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
|
||||
Variables
|
||||
</Link>{' '}
|
||||
for requests within this folder.
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useContainerSize } from '../hooks/useContainerQuery';
|
||||
import type { ReflectResponseService } from '../hooks/useGrpc';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { Button } from './core/Button';
|
||||
@@ -69,11 +68,6 @@ export function GrpcRequestPane({
|
||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata');
|
||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
|
||||
namespace: 'no_sync',
|
||||
key: 'grpcRequestActiveTabs',
|
||||
fallback: {},
|
||||
});
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
|
||||
const urlContainerEl = useRef<HTMLDivElement>(null);
|
||||
@@ -145,14 +139,6 @@ export function GrpcRequestPane({
|
||||
[activeRequest.description, authTab, metadataTab],
|
||||
);
|
||||
|
||||
const activeTab = activeTabs?.[activeRequest.id];
|
||||
const setActiveTab = useCallback(
|
||||
async (tab: string) => {
|
||||
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
|
||||
},
|
||||
[activeRequest.id, setActiveTabs],
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
||||
[activeRequest],
|
||||
@@ -265,12 +251,11 @@ export function GrpcRequestPane({
|
||||
</HStack>
|
||||
</div>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
label="Request"
|
||||
onChangeValue={setActiveTab}
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 !mb-1.5"
|
||||
storageKey="grpc_request_tabs_order"
|
||||
storageKey="grpc_request_tabs"
|
||||
activeTabKey={activeRequest.id}
|
||||
>
|
||||
<TabContent value="message">
|
||||
<GrpcEditor
|
||||
|
||||
@@ -19,6 +19,7 @@ type Props = {
|
||||
forceUpdateKey: string;
|
||||
headers: HttpRequestHeader[];
|
||||
inheritedHeaders?: HttpRequestHeader[];
|
||||
inheritedHeadersLabel?: string;
|
||||
stateKey: string;
|
||||
onChange: (headers: HttpRequestHeader[]) => void;
|
||||
label?: string;
|
||||
@@ -28,11 +29,20 @@ export function HeadersEditor({
|
||||
stateKey,
|
||||
headers,
|
||||
inheritedHeaders,
|
||||
inheritedHeadersLabel = 'Inherited',
|
||||
onChange,
|
||||
forceUpdateKey,
|
||||
}: Props) {
|
||||
// Get header names defined at current level (case-insensitive)
|
||||
const currentHeaderNames = new Set(
|
||||
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
|
||||
);
|
||||
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
|
||||
const validInheritedHeaders =
|
||||
inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? [];
|
||||
inheritedHeaders?.filter(
|
||||
(pair) =>
|
||||
pair.enabled && (pair.name || pair.value) && !currentHeaderNames.has(pair.name.toLowerCase()),
|
||||
) ?? [];
|
||||
const hasInheritedHeaders = validInheritedHeaders.length > 0;
|
||||
return (
|
||||
<div
|
||||
@@ -48,7 +58,7 @@ export function HeadersEditor({
|
||||
className="text-sm"
|
||||
summary={
|
||||
<HStack>
|
||||
Inherited <CountBadge count={validInheritedHeaders.length} />
|
||||
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
<p>
|
||||
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
|
||||
</p>
|
||||
<Link href="https://feedback.yaak.app/help/articles/2112119-request-inheritance">
|
||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
|
||||
Documentation
|
||||
</Link>
|
||||
</EmptyStateText>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
|
||||
import { allRequestsAtom } from '../hooks/useAllRequests';
|
||||
import { useAuthTab } from '../hooks/useAuthTab';
|
||||
@@ -12,7 +12,6 @@ import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
import { useImportCurl } from '../hooks/useImportCurl';
|
||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
@@ -42,8 +41,8 @@ import { Editor } from './core/Editor/LazyEditor';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import type { Pair } from './core/PairEditor';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import type { TabItem, TabsRef } from './core/Tabs/Tabs';
|
||||
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { FormMultipartEditor } from './FormMultipartEditor';
|
||||
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
|
||||
@@ -70,6 +69,7 @@ const TAB_PARAMS = 'params';
|
||||
const TAB_HEADERS = 'headers';
|
||||
const TAB_AUTH = 'auth';
|
||||
const TAB_DESCRIPTION = 'description';
|
||||
const TABS_STORAGE_KEY = 'http_request_tabs';
|
||||
|
||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
||||
const activeRequestId = get(activeRequestIdAtom);
|
||||
@@ -83,19 +83,20 @@ const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
||||
|
||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
|
||||
namespace: 'no_sync',
|
||||
key: 'httpRequestActiveTabs',
|
||||
fallback: {},
|
||||
});
|
||||
const tabsRef = useRef<TabsRef>(null);
|
||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
||||
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
||||
const contentType = getContentTypeFromHeaders(activeRequest.headers);
|
||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||
|
||||
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
|
||||
useRequestEditorEvent('request_pane.focus_tab', () => {
|
||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
||||
}, []);
|
||||
|
||||
const handleContentTypeChange = useCallback(
|
||||
async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => {
|
||||
if (activeRequest == null) {
|
||||
@@ -260,18 +261,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const activeTab = activeTabs?.[activeRequestId];
|
||||
const setActiveTab = useCallback(
|
||||
async (tab: string) => {
|
||||
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
|
||||
},
|
||||
[activeRequest.id, setActiveTabs],
|
||||
);
|
||||
|
||||
useRequestEditorEvent('request_pane.focus_tab', async () => {
|
||||
await setActiveTab(TAB_PARAMS);
|
||||
});
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
@@ -298,7 +287,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
e.preventDefault(); // Prevent input onChange
|
||||
|
||||
await patchModel(activeRequest, patch);
|
||||
focusParamsTab();
|
||||
await setActiveTab({
|
||||
storageKey: TABS_STORAGE_KEY,
|
||||
activeTabKey: activeRequestId,
|
||||
value: TAB_PARAMS,
|
||||
});
|
||||
|
||||
// Wait for request to update, then refresh the UI
|
||||
// TODO: Somehow make this deterministic
|
||||
@@ -309,14 +302,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeRequest,
|
||||
activeRequestId,
|
||||
focusParamsTab,
|
||||
forceParamsRefresh,
|
||||
forceUrlRefresh,
|
||||
importCurl,
|
||||
],
|
||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
|
||||
);
|
||||
const handleSend = useCallback(
|
||||
() => sendRequest(activeRequest.id ?? null),
|
||||
@@ -354,12 +340,12 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
isLoading={activeResponse != null && activeResponse.state !== 'closed'}
|
||||
/>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
onChangeValue={setActiveTab}
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 -mb-1.5"
|
||||
storageKey="http_request_tabs_order"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { ComponentType, CSSProperties } from 'react';
|
||||
import { lazy, Suspense, useCallback, useMemo } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
|
||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||
import { useTimelineViewMode } from '../hooks/useTimelineViewMode';
|
||||
import { getMimeTypeFromContentType } from '../lib/contentType';
|
||||
import { getCookieCounts, getContentTypeFromHeaders } from '../lib/model_util';
|
||||
import { getContentTypeFromHeaders, getCookieCounts } from '../lib/model_util';
|
||||
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
||||
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
||||
import { Banner } from './core/Banner';
|
||||
@@ -55,22 +55,18 @@ const TAB_HEADERS = 'headers';
|
||||
const TAB_COOKIES = 'cookies';
|
||||
const TAB_TIMELINE = 'timeline';
|
||||
|
||||
export type TimelineViewMode = 'timeline' | 'text';
|
||||
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [activeTabs, setActiveTabs] = useLocalStorage<Record<string, string>>(
|
||||
'responsePaneActiveTabs',
|
||||
{},
|
||||
);
|
||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||
|
||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||
|
||||
const cookieCounts = useMemo(
|
||||
() => getCookieCounts(responseEvents.data),
|
||||
[responseEvents.data],
|
||||
);
|
||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
@@ -82,7 +78,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
onChange: setViewMode,
|
||||
items: [
|
||||
{ label: 'Response', value: 'pretty' },
|
||||
...(mimeType?.startsWith('image') ? [] : [{ label: 'Raw', value: 'raw' }]),
|
||||
...(mimeType?.startsWith('image')
|
||||
? []
|
||||
: [{ label: 'Response (Raw)', shortLabel: 'Raw', value: 'raw' }]),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -113,8 +111,15 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
},
|
||||
{
|
||||
value: TAB_TIMELINE,
|
||||
label: 'Timeline',
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
options: {
|
||||
value: timelineViewMode,
|
||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? 'timeline'),
|
||||
items: [
|
||||
{ label: 'Timeline', value: 'timeline' },
|
||||
{ label: 'Timeline (Text)', shortLabel: 'Timeline', value: 'text' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -127,15 +132,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
responseEvents.data?.length,
|
||||
setViewMode,
|
||||
viewMode,
|
||||
timelineViewMode,
|
||||
setTimelineViewMode,
|
||||
],
|
||||
);
|
||||
const activeTab = activeTabs?.[activeRequestId];
|
||||
const setActiveTab = useCallback(
|
||||
(tab: string) => {
|
||||
setActiveTabs((r) => ({ ...r, [activeRequestId]: tab }));
|
||||
},
|
||||
[activeRequestId, setActiveTabs],
|
||||
);
|
||||
|
||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
|
||||
@@ -199,14 +199,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
)}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
key={activeRequestId} // Freshen tabs on request change
|
||||
value={activeTab}
|
||||
onChangeValue={setActiveTab}
|
||||
tabs={tabs}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey="http_response_tabs_order"
|
||||
storageKey="http_response_tabs"
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ErrorBoundary name="Http Response Viewer">
|
||||
@@ -266,7 +264,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
<ResponseCookies response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_TIMELINE}>
|
||||
<HttpResponseTimeline response={activeResponse} />
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -3,28 +3,51 @@ import type {
|
||||
HttpResponseEvent,
|
||||
HttpResponseEventData,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
|
||||
import { EventViewerRow } from './core/EventViewerRow';
|
||||
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
||||
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
||||
import { Icon, type IconProps } from './core/Icon';
|
||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||
import type { TimelineViewMode } from './HttpResponsePane';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
viewMode: TimelineViewMode;
|
||||
}
|
||||
|
||||
export function HttpResponseTimeline({ response }: Props) {
|
||||
return <Inner key={response.id} response={response} />;
|
||||
export function HttpResponseTimeline({ response, viewMode }: Props) {
|
||||
return <Inner key={response.id} response={response} viewMode={viewMode} />;
|
||||
}
|
||||
|
||||
function Inner({ response }: Props) {
|
||||
function Inner({ response, viewMode }: Props) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
||||
|
||||
// Generate plain text representation of all events (with prefixes for timeline view)
|
||||
const plainText = useMemo(() => {
|
||||
if (!events || events.length === 0) return '';
|
||||
return events.map((event) => formatEventText(event.event, true)).join('\n');
|
||||
}, [events]);
|
||||
|
||||
// Plain text view - show all events as text in an editor
|
||||
if (viewMode === 'text') {
|
||||
if (isLoading) {
|
||||
return <div className="p-4 text-text-subtlest">Loading events...</div>;
|
||||
} else if (error) {
|
||||
return <div className="p-4 text-danger">{String(error)}</div>;
|
||||
} else if (!events || events.length === 0) {
|
||||
return <div className="p-4 text-text-subtlest">No events recorded</div>;
|
||||
} else {
|
||||
return (
|
||||
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EventViewer
|
||||
events={events ?? []}
|
||||
@@ -110,10 +133,10 @@ function EventDetails({
|
||||
|
||||
// Render content based on view mode and event type
|
||||
const renderContent = () => {
|
||||
// Raw view - show plaintext representation
|
||||
// Raw view - show plaintext representation (without prefix)
|
||||
if (showRaw) {
|
||||
const rawText = formatEventRaw(event.event);
|
||||
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} />;
|
||||
const rawText = formatEventText(event.event, false);
|
||||
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
|
||||
}
|
||||
|
||||
// Headers - show name and value
|
||||
@@ -204,43 +227,58 @@ function EventDetails({
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} onClose={onClose} />
|
||||
<EventDetailHeader
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
actions={actions}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format event as raw plaintext for debugging */
|
||||
function formatEventRaw(event: HttpResponseEventData): string {
|
||||
type EventTextParts = { prefix: '>' | '<' | '*'; text: string };
|
||||
|
||||
/** Get the prefix and text for an event */
|
||||
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
||||
switch (event.type) {
|
||||
case 'send_url':
|
||||
return `${event.method} ${event.path}`;
|
||||
return { prefix: '>', text: `${event.method} ${event.path}` };
|
||||
case 'receive_url':
|
||||
return `${event.version} ${event.status}`;
|
||||
return { prefix: '<', text: `${event.version} ${event.status}` };
|
||||
case 'header_up':
|
||||
return `${event.name}: ${event.value}`;
|
||||
return { prefix: '>', text: `${event.name}: ${event.value}` };
|
||||
case 'header_down':
|
||||
return `${event.name}: ${event.value}`;
|
||||
case 'redirect':
|
||||
return `${event.status} Redirect: ${event.url}`;
|
||||
return { prefix: '<', text: `${event.name}: ${event.value}` };
|
||||
case 'redirect': {
|
||||
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
|
||||
return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` };
|
||||
}
|
||||
case 'setting':
|
||||
return `${event.name} = ${event.value}`;
|
||||
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
|
||||
case 'info':
|
||||
return `${event.message}`;
|
||||
return { prefix: '*', text: event.message };
|
||||
case 'chunk_sent':
|
||||
return `[${formatBytes(event.bytes)} sent]`;
|
||||
return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` };
|
||||
case 'chunk_received':
|
||||
return `[${formatBytes(event.bytes)} received]`;
|
||||
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` };
|
||||
case 'dns_resolved':
|
||||
if (event.overridden) {
|
||||
return `DNS override ${event.hostname} → ${event.addresses.join(', ')}`;
|
||||
return { prefix: '*', text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}` };
|
||||
}
|
||||
return `DNS resolved ${event.hostname} → ${event.addresses.join(', ')} (${event.duration}ms)`;
|
||||
return { prefix: '*', text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)` };
|
||||
default:
|
||||
return '[unknown event]';
|
||||
return { prefix: '*', text: '[unknown event]' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
|
||||
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
|
||||
const { prefix, text } = getEventTextParts(event);
|
||||
return includePrefix ? `${prefix} ${text}` : text;
|
||||
}
|
||||
|
||||
type EventDisplay = {
|
||||
icon: IconProps['icon'];
|
||||
color: IconProps['color'];
|
||||
|
||||
@@ -71,7 +71,7 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}>
|
||||
<HttpMethodTag request={request} />
|
||||
<HttpMethodTag request={request} noAlias />
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useLicense } from '@yaakapp-internal/license';
|
||||
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { appInfo } from '../../lib/appInfo';
|
||||
import { capitalize } from '../../lib/capitalize';
|
||||
@@ -51,7 +50,6 @@ export default function Settings({ hide }: Props) {
|
||||
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tabFromQuery?.split(':') ?? [];
|
||||
const [tab, setTab] = useState<string | undefined>(mainTab || tabFromQuery);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
@@ -91,11 +89,10 @@ export default function Settings({ hide }: Props) {
|
||||
)}
|
||||
<Tabs
|
||||
layout="horizontal"
|
||||
value={tab}
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
addBorders
|
||||
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
label="Settings"
|
||||
onChangeValue={setTab}
|
||||
tabs={tabs.map(
|
||||
(value): TabItem => ({
|
||||
value,
|
||||
@@ -145,7 +142,7 @@ export default function Settings({ hide }: Props) {
|
||||
<SettingsHotkeys />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
|
||||
<SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
|
||||
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<SettingsProxy />
|
||||
|
||||
@@ -54,13 +54,11 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
||||
const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir));
|
||||
const createPlugin = useInstallPlugin();
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
const [tab, setTab] = useState<string | undefined>(defaultSubtab);
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Tabs
|
||||
value={tab}
|
||||
defaultValue={defaultSubtab}
|
||||
label="Plugins"
|
||||
onChangeValue={setTab}
|
||||
addBorders
|
||||
tabs={[
|
||||
{ label: 'Discover', value: 'search' },
|
||||
@@ -117,7 +115,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
||||
icon="help"
|
||||
title="View documentation"
|
||||
onClick={() =>
|
||||
openUrl('https://feedback.yaak.app/help/articles/6911763-quick-start')
|
||||
openUrl('https://yaak.app/docs/plugin-development/plugins-quick-start')
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function SettingsTheme() {
|
||||
<Heading>Theme</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Make Yaak your own by selecting a theme, or{' '}
|
||||
<Link href="https://feedback.yaak.app/help/articles/6911763-plugins-quick-start">
|
||||
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
|
||||
Create Your Own
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { closeWebsocket, connectWebsocket, sendWebsocket } from '@yaakapp-intern
|
||||
import classNames from 'classnames';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { getActiveCookieJar } from '../hooks/useActiveCookieJar';
|
||||
import { getActiveEnvironment } from '../hooks/useActiveEnvironment';
|
||||
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
|
||||
@@ -14,7 +14,6 @@ import { useAuthTab } from '../hooks/useAuthTab';
|
||||
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
import { activeWebsocketConnectionAtom } from '../hooks/usePinnedWebsocketConnection';
|
||||
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
|
||||
@@ -30,8 +29,8 @@ import { Editor } from './core/Editor/LazyEditor';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import type { Pair } from './core/PairEditor';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import type { TabItem, TabsRef } from './core/Tabs/Tabs';
|
||||
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { HeadersEditor } from './HeadersEditor';
|
||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||
import { MarkdownEditor } from './MarkdownEditor';
|
||||
@@ -50,6 +49,7 @@ const TAB_PARAMS = 'params';
|
||||
const TAB_HEADERS = 'headers';
|
||||
const TAB_AUTH = 'auth';
|
||||
const TAB_DESCRIPTION = 'description';
|
||||
const TABS_STORAGE_KEY = 'websocket_request_tabs';
|
||||
|
||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
||||
const activeRequestId = get(activeRequestIdAtom);
|
||||
@@ -63,17 +63,18 @@ const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
||||
|
||||
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
const { value: activeTabs, set: setActiveTabs } = useKeyValue<Record<string, string>>({
|
||||
namespace: 'no_sync',
|
||||
key: 'websocketRequestActiveTabs',
|
||||
fallback: {},
|
||||
});
|
||||
const tabsRef = useRef<TabsRef>(null);
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id);
|
||||
const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
||||
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||
|
||||
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
|
||||
useRequestEditorEvent('request_pane.focus_tab', () => {
|
||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
||||
}, []);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
||||
(m) => m[1] ?? '',
|
||||
@@ -115,18 +116,6 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
||||
|
||||
const activeTab = activeTabs?.[activeRequestId];
|
||||
const setActiveTab = useCallback(
|
||||
async (tab: string) => {
|
||||
await setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
|
||||
},
|
||||
[activeRequest.id, setActiveTabs],
|
||||
);
|
||||
|
||||
useRequestEditorEvent('request_pane.focus_tab', async () => {
|
||||
await setActiveTab(TAB_PARAMS);
|
||||
});
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
@@ -176,7 +165,11 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
e.preventDefault(); // Prevent input onChange
|
||||
|
||||
await patchModel(activeRequest, patch);
|
||||
focusParamsTab();
|
||||
await setActiveTab({
|
||||
storageKey: TABS_STORAGE_KEY,
|
||||
activeTabKey: activeRequestId,
|
||||
value: TAB_PARAMS,
|
||||
});
|
||||
|
||||
// Wait for request to update, then refresh the UI
|
||||
// TODO: Somehow make this deterministic
|
||||
@@ -186,7 +179,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[activeRequest, focusParamsTab, forceParamsRefresh, forceUrlRefresh],
|
||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
|
||||
);
|
||||
|
||||
const messageLanguage = languageFromContentType(null, activeRequest.message);
|
||||
@@ -229,12 +222,12 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
onChangeValue={setActiveTab}
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 !mb-1.5"
|
||||
storageKey="websocket_request_tabs_order"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useWorkspaceActions } from '../hooks/useWorkspaceActions';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { revealInFinderText } from '../lib/reveal';
|
||||
import { CloneGitRepositoryDialog } from './CloneGitRepositoryDialog';
|
||||
import type { ButtonProps } from './core/Button';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
@@ -39,9 +40,19 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
const { mutate: deleteSendHistory } = useDeleteSendHistory();
|
||||
const workspaceActions = useWorkspaceActions();
|
||||
|
||||
const { workspaceItems, itemsAfter } = useMemo<{
|
||||
const openCloneGitRepositoryDialog = useCallback(() => {
|
||||
showDialog({
|
||||
id: 'clone-git-repository',
|
||||
size: 'md',
|
||||
title: 'Clone Git Repository',
|
||||
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{
|
||||
workspaceItems: RadioDropdownItem[];
|
||||
itemsAfter: DropdownItem[];
|
||||
itemsBefore: DropdownItem[];
|
||||
}>(() => {
|
||||
const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
|
||||
key: w.id,
|
||||
@@ -50,6 +61,38 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
}));
|
||||
|
||||
const itemsBefore: DropdownItem[] = [
|
||||
{
|
||||
label: 'New Workspace',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
submenu: [
|
||||
{
|
||||
label: 'Create Empty',
|
||||
leftSlot: <Icon icon="plus_circle" />,
|
||||
onSelect: createWorkspace,
|
||||
},
|
||||
{
|
||||
label: 'Open Folder',
|
||||
leftSlot: <Icon icon="folder_open" />,
|
||||
onSelect: async () => {
|
||||
const dir = await open({
|
||||
title: 'Select Workspace Directory',
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (dir == null) return;
|
||||
openWorkspaceFromSyncDir.mutate(dir);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Clone Git Repository',
|
||||
leftSlot: <Icon icon="hard_drive_download" />,
|
||||
onSelect: openCloneGitRepositoryDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const itemsAfter: DropdownItem[] = [
|
||||
...workspaceActions.map((a) => ({
|
||||
label: a.label,
|
||||
@@ -80,34 +123,15 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
leftSlot: <Icon icon="history" />,
|
||||
onSelect: deleteSendHistory,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'New Workspace',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: createWorkspace,
|
||||
},
|
||||
{
|
||||
label: 'Open Existing Workspace',
|
||||
leftSlot: <Icon icon="folder_open" />,
|
||||
onSelect: async () => {
|
||||
const dir = await open({
|
||||
title: 'Select Workspace Directory',
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (dir == null) return;
|
||||
openWorkspaceFromSyncDir.mutate(dir);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return { workspaceItems, itemsAfter };
|
||||
return { workspaceItems, itemsAfter, itemsBefore };
|
||||
}, [
|
||||
workspaces,
|
||||
workspaceMeta,
|
||||
deleteSendHistory,
|
||||
createWorkspace,
|
||||
openCloneGitRepositoryDialog,
|
||||
workspace?.id,
|
||||
workspace,
|
||||
workspaceActions.map,
|
||||
@@ -144,6 +168,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
<RadioDropdown
|
||||
items={workspaceItems}
|
||||
itemsAfter={itemsAfter}
|
||||
itemsBefore={itemsBefore}
|
||||
onChange={handleSwitchWorkspace}
|
||||
value={workspace?.id ?? null}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { enableEncryption, revealWorkspaceKey, setWorkspaceKey } from '@yaakapp-internal/crypto';
|
||||
import {
|
||||
disableEncryption,
|
||||
enableEncryption,
|
||||
revealWorkspaceKey,
|
||||
setWorkspaceKey,
|
||||
} from '@yaakapp-internal/crypto';
|
||||
import type { WorkspaceMeta } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
@@ -6,6 +11,7 @@ import { useEffect, useState } from 'react';
|
||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||
import { showConfirm } from '../lib/confirm';
|
||||
import { CopyIconButton } from './CopyIconButton';
|
||||
import { Banner } from './core/Banner';
|
||||
import type { ButtonProps } from './core/Button';
|
||||
@@ -69,6 +75,9 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
onDone?.();
|
||||
onEnabledEncryption?.();
|
||||
}}
|
||||
onDisabled={() => {
|
||||
onDone?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +118,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
return (
|
||||
<div className="mb-auto flex flex-col-reverse">
|
||||
<Button
|
||||
className="mt-3"
|
||||
color={expanded ? 'info' : 'secondary'}
|
||||
size={size}
|
||||
onClick={async () => {
|
||||
@@ -149,13 +159,39 @@ const setWorkspaceKeyMut = createFastMutation({
|
||||
function EnterWorkspaceKey({
|
||||
workspaceMeta,
|
||||
onEnabled,
|
||||
onDisabled,
|
||||
error,
|
||||
}: {
|
||||
workspaceMeta: WorkspaceMeta;
|
||||
onEnabled?: () => void;
|
||||
onDisabled?: () => void;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const [key, setKey] = useState<string>('');
|
||||
|
||||
const handleForgotKey = async () => {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'disable-encryption',
|
||||
title: 'Disable Encryption',
|
||||
color: 'danger',
|
||||
confirmText: 'Disable Encryption',
|
||||
description: (
|
||||
<>
|
||||
This will disable encryption for this workspace. Any previously encrypted values will fail
|
||||
to decrypt and will need to be re-entered manually.
|
||||
<br />
|
||||
<br />
|
||||
This action cannot be undone.
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
await disableEncryption(workspaceMeta.workspaceId);
|
||||
onDisabled?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={4} className="w-full">
|
||||
{error ? (
|
||||
@@ -192,6 +228,13 @@ function EnterWorkspaceKey({
|
||||
Submit
|
||||
</Button>
|
||||
</HStack>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForgotKey}
|
||||
className="text-text-subtlest text-sm hover:text-text-subtle"
|
||||
>
|
||||
Forgot your key?
|
||||
</button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { patchModel, workspaceMetasAtom, workspacesAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
import { useAuthTab } from '../hooks/useAuthTab';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||
@@ -45,7 +44,6 @@ const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
||||
export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
|
||||
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
|
||||
const [activeTab, setActiveTab] = useState<string>(tab ?? DEFAULT_TAB);
|
||||
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
|
||||
const inheritedHeaders = useInheritedHeaders(workspace ?? null);
|
||||
@@ -67,8 +65,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChangeValue={setActiveTab}
|
||||
defaultValue={tab ?? DEFAULT_TAB}
|
||||
label="Folder Settings"
|
||||
className="pt-4 pb-2 px-3"
|
||||
tabListClassName="pl-4"
|
||||
@@ -90,7 +87,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
storageKey="workspace_settings_tabs_order"
|
||||
storageKey="workspace_settings_tabs"
|
||||
>
|
||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
||||
<HttpAuthenticationEditor model={workspace} />
|
||||
@@ -98,6 +95,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
inheritedHeadersLabel="Defaults"
|
||||
forceUpdateKey={workspace.id}
|
||||
headers={workspace.headers}
|
||||
onChange={(headers) => patchModel(workspace, { headers })}
|
||||
|
||||
@@ -66,6 +66,8 @@ export type DropdownItemDefault = {
|
||||
keepOpenOnSelect?: boolean;
|
||||
onSelect?: () => void | Promise<void>;
|
||||
submenu?: DropdownItem[];
|
||||
/** If true, submenu opens on click instead of hover */
|
||||
submenuOpenOnClick?: boolean;
|
||||
icon?: IconProps['icon'];
|
||||
};
|
||||
|
||||
@@ -272,6 +274,7 @@ interface MenuProps {
|
||||
defaultSelectedIndex: number | null;
|
||||
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
|
||||
onClose: () => void;
|
||||
onCloseAll?: () => void;
|
||||
showTriangle?: boolean;
|
||||
fullWidth?: boolean;
|
||||
isOpen: boolean;
|
||||
@@ -288,6 +291,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
items,
|
||||
fullWidth,
|
||||
onClose,
|
||||
onCloseAll,
|
||||
triggerShape,
|
||||
defaultSelectedIndex,
|
||||
showTriangle,
|
||||
@@ -300,7 +304,16 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
defaultSelectedIndex ?? -1,
|
||||
[defaultSelectedIndex],
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
// Clear filter when menu opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFilter('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<{
|
||||
item: DropdownItemDefault;
|
||||
parent: HTMLButtonElement;
|
||||
@@ -320,10 +333,18 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
setFilter('');
|
||||
setActiveSubmenu(null);
|
||||
}, [onClose]);
|
||||
|
||||
// Close the entire menu hierarchy (used when selecting an item)
|
||||
const handleCloseAll = useCallback(() => {
|
||||
if (onCloseAll) {
|
||||
onCloseAll();
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}, [onCloseAll, handleClose]);
|
||||
|
||||
// Handle type-ahead filtering (only for the deepest open menu)
|
||||
const handleMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
// Skip if this menu has a submenu open - let the submenu handle typing
|
||||
@@ -393,6 +414,14 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
[items, setSelectedIndex],
|
||||
);
|
||||
|
||||
// Ensure selection is on a valid item (not hidden/separator/content)
|
||||
useEffect(() => {
|
||||
const item = items[selectedIndex ?? -1];
|
||||
if (item?.hidden || item?.type === 'separator' || item?.type === 'content') {
|
||||
handleNext();
|
||||
}
|
||||
}, [selectedIndex, items, handleNext]);
|
||||
|
||||
useKey(
|
||||
'ArrowUp',
|
||||
(e) => {
|
||||
@@ -433,7 +462,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: DropdownItem) => {
|
||||
async (item: DropdownItem, parentEl?: HTMLButtonElement) => {
|
||||
// Handle click-to-open submenu
|
||||
if ('submenu' in item && item.submenu && item.submenuOpenOnClick && parentEl) {
|
||||
setActiveSubmenu({ item, parent: parentEl });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('onSelect' in item) || !item.onSelect) return;
|
||||
setSelectedIndex(null);
|
||||
|
||||
@@ -446,9 +481,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.keepOpenOnSelect) handleClose();
|
||||
if (!item.keepOpenOnSelect) handleCloseAll();
|
||||
},
|
||||
[handleClose, setSelectedIndex],
|
||||
[handleCloseAll, setSelectedIndex],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
@@ -476,17 +511,23 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
const parentRect = triggerShape;
|
||||
const docRect = document.documentElement.getBoundingClientRect();
|
||||
const spaceRight = docRect.width - parentRect.right;
|
||||
const spaceBelow = docRect.height - parentRect.top;
|
||||
const spaceAbove = parentRect.bottom;
|
||||
const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
|
||||
// Estimate submenu height (items * ~28px + padding), flip if not enough space below
|
||||
const estimatedHeight = items.length * 28 + 20;
|
||||
const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
|
||||
|
||||
return {
|
||||
upsideDown: false,
|
||||
upsideDown: openUpward,
|
||||
container: {
|
||||
top: parentRect.top,
|
||||
top: openUpward ? undefined : parentRect.top,
|
||||
bottom: openUpward ? docRect.height - parentRect.bottom : undefined,
|
||||
left: openLeft ? undefined : parentRect.right,
|
||||
right: openLeft ? docRect.width - parentRect.left : undefined,
|
||||
},
|
||||
menu: {
|
||||
maxHeight: `${docRect.height - parentRect.top - 20}px`,
|
||||
maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`,
|
||||
},
|
||||
triangle: {}, // No triangle for submenus
|
||||
};
|
||||
@@ -586,7 +627,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
clearTimeout(submenuTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (item.submenu) {
|
||||
if (item.submenu && !item.submenuOpenOnClick) {
|
||||
setActiveSubmenu({ item, parent });
|
||||
} else if (activeSubmenu) {
|
||||
submenuTimeoutRef.current = window.setTimeout(() => {
|
||||
@@ -759,6 +800,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
items={activeSubmenu.item.submenu ?? []}
|
||||
defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null}
|
||||
onClose={() => setActiveSubmenu(null)}
|
||||
onCloseAll={handleCloseAll}
|
||||
triggerShape={submenuTriggerShape}
|
||||
/>
|
||||
</div>
|
||||
@@ -804,7 +846,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
interface MenuItemProps {
|
||||
className?: string;
|
||||
item: DropdownItemDefault;
|
||||
onSelect: (item: DropdownItemDefault) => Promise<void>;
|
||||
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
||||
onFocus: (item: DropdownItemDefault) => void;
|
||||
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||
focused: boolean;
|
||||
@@ -824,7 +866,7 @@ function MenuItem({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleClick = useCallback(async () => {
|
||||
if (item.waitForOnSelect) setIsLoading(true);
|
||||
await onSelect?.(item);
|
||||
await onSelect?.(item, buttonRef.current ?? undefined);
|
||||
if (item.waitForOnSelect) setIsLoading(false);
|
||||
}, [item, onSelect]);
|
||||
|
||||
@@ -854,7 +896,7 @@ function MenuItem({
|
||||
};
|
||||
|
||||
const rightSlot = item.submenu ? (
|
||||
<Icon icon="chevron_right" />
|
||||
<Icon icon="chevron_right" color='secondary' />
|
||||
) : (
|
||||
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />)
|
||||
);
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
.template-tag {
|
||||
/* Colors */
|
||||
@apply bg-surface text-text border-border-subtle whitespace-nowrap;
|
||||
@apply bg-surface text-text border-border-subtle whitespace-nowrap cursor-default;
|
||||
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
|
||||
|
||||
@apply inline border px-1 mx-[0.5px] rounded dark:shadow;
|
||||
|
||||
@@ -77,7 +77,7 @@ export interface EditorProps {
|
||||
heightMode?: 'auto' | 'full';
|
||||
hideGutter?: boolean;
|
||||
id?: string;
|
||||
language?: EditorLanguage | 'pairs' | 'url' | null;
|
||||
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
|
||||
graphQLSchema?: GraphQLSchema | null;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
|
||||
@@ -48,6 +48,7 @@ import type { EditorProps } from './Editor';
|
||||
import { jsonParseLinter } from './json-lint';
|
||||
import { pairs } from './pairs/extension';
|
||||
import { text } from './text/extension';
|
||||
import { timeline } from './timeline/extension';
|
||||
import type { TwigCompletionOption } from './twig/completion';
|
||||
import { twig } from './twig/extension';
|
||||
import { pathParametersPlugin } from './twig/pathParameters';
|
||||
@@ -95,6 +96,7 @@ const syntaxExtensions: Record<
|
||||
url: url,
|
||||
pairs: pairs,
|
||||
text: text,
|
||||
timeline: timeline,
|
||||
markdown: markdown,
|
||||
};
|
||||
|
||||
|
||||
12
src-web/components/core/Editor/timeline/extension.ts
Normal file
12
src-web/components/core/Editor/timeline/extension.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parser } from './timeline';
|
||||
|
||||
export const timelineLanguage = LRLanguage.define({
|
||||
name: 'timeline',
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
export function timeline() {
|
||||
return new LanguageSupport(timelineLanguage);
|
||||
}
|
||||
7
src-web/components/core/Editor/timeline/highlight.ts
Normal file
7
src-web/components/core/Editor/timeline/highlight.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)
|
||||
IncomingText: t.tagName, // < lines - info color (matches timeline icons)
|
||||
InfoText: t.comment, // * lines - subtle color (matches timeline icons)
|
||||
});
|
||||
21
src-web/components/core/Editor/timeline/timeline.grammar
Normal file
21
src-web/components/core/Editor/timeline/timeline.grammar
Normal file
@@ -0,0 +1,21 @@
|
||||
@top Timeline { line* }
|
||||
|
||||
line { OutgoingLine | IncomingLine | InfoLine | PlainLine }
|
||||
|
||||
@skip {} {
|
||||
OutgoingLine { OutgoingText Newline }
|
||||
IncomingLine { IncomingText Newline }
|
||||
InfoLine { InfoText Newline }
|
||||
PlainLine { PlainText Newline }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
OutgoingText { "> " ![\n]* }
|
||||
IncomingText { "< " ![\n]* }
|
||||
InfoText { "* " ![\n]* }
|
||||
PlainText { ![\n]+ }
|
||||
Newline { "\n" }
|
||||
@precedence { OutgoingText, IncomingText, InfoText, PlainText }
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
12
src-web/components/core/Editor/timeline/timeline.terms.ts
Normal file
12
src-web/components/core/Editor/timeline/timeline.terms.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Timeline = 1,
|
||||
OutgoingLine = 2,
|
||||
OutgoingText = 3,
|
||||
Newline = 4,
|
||||
IncomingLine = 5,
|
||||
IncomingText = 6,
|
||||
InfoLine = 7,
|
||||
InfoText = 8,
|
||||
PlainLine = 9,
|
||||
PlainText = 10
|
||||
18
src-web/components/core/Editor/timeline/timeline.ts
Normal file
18
src-web/components/core/Editor/timeline/timeline.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
|
||||
stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~",
|
||||
goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV",
|
||||
nodeNames: "⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
|
||||
maxTerm: 13,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData: "%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
|
||||
tokenizers: [0],
|
||||
topRules: {"Timeline":[0,1]},
|
||||
tokenPrec: 36
|
||||
})
|
||||
@@ -8,6 +8,7 @@ interface Props {
|
||||
request: HttpRequest | GrpcRequest | WebsocketRequest;
|
||||
className?: string;
|
||||
short?: boolean;
|
||||
noAlias?: boolean;
|
||||
}
|
||||
|
||||
const methodNames: Record<string, string> = {
|
||||
@@ -24,9 +25,9 @@ const methodNames: Record<string, string> = {
|
||||
websocket: 'WS',
|
||||
};
|
||||
|
||||
export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short }: Props) {
|
||||
export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short, noAlias }: Props) {
|
||||
const method =
|
||||
request.model === 'http_request' && request.bodyType === 'graphql'
|
||||
request.model === 'http_request' && (request.bodyType === 'graphql' && !noAlias)
|
||||
? 'graphql'
|
||||
: request.model === 'grpc_request'
|
||||
? 'grpc'
|
||||
|
||||
@@ -136,8 +136,8 @@ export function PairEditor({
|
||||
rowsRef.current[id] = n;
|
||||
const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
|
||||
|
||||
// NOTE: Ignore the last placeholder pair
|
||||
const ready = validHandles.length === pairs.length - 1;
|
||||
// Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke)
|
||||
const ready = validHandles.length >= pairs.length - 1;
|
||||
if (ready) {
|
||||
setRef?.(handle);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,17 @@ import {
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode, Ref } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKeyValue } from '../../../hooks/useKeyValue';
|
||||
import { computeSideForDragMove } from '../../../lib/dnd';
|
||||
import { DropMarker } from '../../DropMarker';
|
||||
@@ -37,41 +46,120 @@ export type TabItem =
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
interface TabsStorage {
|
||||
order: string[];
|
||||
activeTabs: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TabsRef {
|
||||
/** Programmatically set the active tab */
|
||||
setActiveTab: (value: string) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value?: string;
|
||||
onChangeValue: (value: string) => void;
|
||||
/** Default tab value. If not provided, defaults to first tab. */
|
||||
defaultValue?: string;
|
||||
/** Called when active tab changes */
|
||||
onChangeValue?: (value: string) => void;
|
||||
tabs: TabItem[];
|
||||
tabListClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
addBorders?: boolean;
|
||||
layout?: 'horizontal' | 'vertical';
|
||||
/** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */
|
||||
storageKey?: string | string[];
|
||||
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
|
||||
activeTabKey?: string;
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
value,
|
||||
onChangeValue,
|
||||
label,
|
||||
children,
|
||||
tabs: originalTabs,
|
||||
className,
|
||||
tabListClassName,
|
||||
addBorders,
|
||||
layout = 'vertical',
|
||||
storageKey,
|
||||
}: Props) {
|
||||
export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
{
|
||||
defaultValue,
|
||||
onChangeValue: onChangeValueProp,
|
||||
label,
|
||||
children,
|
||||
tabs: originalTabs,
|
||||
className,
|
||||
tabListClassName,
|
||||
addBorders,
|
||||
layout = 'vertical',
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
}: Props,
|
||||
forwardedRef: Ref<TabsRef>,
|
||||
) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const reorderable = !!storageKey;
|
||||
|
||||
// Use key-value storage for persistence if storageKey is provided
|
||||
const { value: savedOrder, set: setSavedOrder } = useKeyValue<string[]>({
|
||||
namespace: 'global',
|
||||
key: storageKey ?? ['tabs_order', 'default'],
|
||||
fallback: [],
|
||||
// Handle migration from old format (string[]) to new format (TabsStorage)
|
||||
const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({
|
||||
namespace: 'no_sync',
|
||||
key: storageKey ?? ['tabs', 'default'],
|
||||
fallback: { order: [], activeTabs: {} },
|
||||
});
|
||||
|
||||
// Migrate old format (string[]) to new format (TabsStorage)
|
||||
const storage: TabsStorage = Array.isArray(rawStorage)
|
||||
? { order: rawStorage, activeTabs: {} }
|
||||
: (rawStorage ?? { order: [], activeTabs: {} });
|
||||
|
||||
const savedOrder = storage.order;
|
||||
|
||||
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
|
||||
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
|
||||
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
|
||||
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
||||
|
||||
// Helper to normalize storage (handle migration from old format)
|
||||
const normalizeStorage = useCallback(
|
||||
(s: TabsStorage | string[]): TabsStorage =>
|
||||
Array.isArray(s) ? { order: s, activeTabs: {} } : s,
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle tab change - update internal state, storage if we have a key, and call prop callback
|
||||
const onChangeValue = useCallback(
|
||||
async (newValue: string) => {
|
||||
setInternalValue(newValue);
|
||||
if (storageKey && activeTabKey) {
|
||||
await setStorage((s) => {
|
||||
const normalized = normalizeStorage(s);
|
||||
return {
|
||||
...normalized,
|
||||
activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue },
|
||||
};
|
||||
});
|
||||
}
|
||||
onChangeValueProp?.(newValue);
|
||||
},
|
||||
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
|
||||
);
|
||||
|
||||
// Expose imperative methods via ref
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
setActiveTab: (value: string) => {
|
||||
onChangeValue(value);
|
||||
},
|
||||
}),
|
||||
[onChangeValue],
|
||||
);
|
||||
|
||||
// Helper to save order
|
||||
const setSavedOrder = useCallback(
|
||||
async (order: string[]) => {
|
||||
await setStorage((s) => {
|
||||
const normalized = normalizeStorage(s);
|
||||
return { ...normalized, order };
|
||||
});
|
||||
},
|
||||
[setStorage, normalizeStorage],
|
||||
);
|
||||
|
||||
// State for ordered tabs
|
||||
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
|
||||
const [isDragging, setIsDragging] = useState<TabItem | null>(null);
|
||||
@@ -112,8 +200,6 @@ export function Tabs({
|
||||
|
||||
const tabs = storageKey ? orderedTabs : originalTabs;
|
||||
|
||||
value = value ?? tabs[0]?.value;
|
||||
|
||||
// Update tabs when value changes
|
||||
useEffect(() => {
|
||||
const tabs = ref.current?.querySelectorAll<HTMLDivElement>('[data-tab]');
|
||||
@@ -215,13 +301,10 @@ export function Tabs({
|
||||
items.push(
|
||||
<div
|
||||
key={`marker-${t.value}`}
|
||||
className={classNames(
|
||||
'relative',
|
||||
layout === 'vertical' ? 'w-0' : 'h-0',
|
||||
)}
|
||||
className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}
|
||||
>
|
||||
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,7 +318,7 @@ export function Tabs({
|
||||
reorderable={reorderable}
|
||||
isDragging={isDragging?.value === t.value}
|
||||
onChangeValue={onChangeValue}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return items;
|
||||
@@ -264,12 +347,7 @@ export function Tabs({
|
||||
>
|
||||
{tabButtons}
|
||||
{hoveredIndex === tabs.length && (
|
||||
<div
|
||||
className={classNames(
|
||||
'relative',
|
||||
layout === 'vertical' ? 'w-0' : 'h-0',
|
||||
)}
|
||||
>
|
||||
<div className={classNames('relative', layout === 'vertical' ? 'w-0' : 'h-0')}>
|
||||
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
|
||||
</div>
|
||||
)}
|
||||
@@ -320,7 +398,7 @@ export function Tabs({
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface TabButtonProps {
|
||||
tab: TabItem;
|
||||
@@ -329,7 +407,7 @@ interface TabButtonProps {
|
||||
layout: 'horizontal' | 'vertical';
|
||||
reorderable: boolean;
|
||||
isDragging: boolean;
|
||||
onChangeValue: (value: string) => void;
|
||||
onChangeValue?: (value: string) => void;
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
@@ -350,6 +428,8 @@ function TabButton({
|
||||
} = useDraggable({
|
||||
id: tab.value,
|
||||
disabled: !reorderable,
|
||||
// The button inside handles focus
|
||||
attributes: { tabIndex: -1 },
|
||||
});
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({
|
||||
id: tab.value,
|
||||
@@ -373,7 +453,7 @@ function TabButton({
|
||||
? undefined
|
||||
: (e: React.MouseEvent) => {
|
||||
e.preventDefault(); // Prevent dropdown from opening on first click
|
||||
onChangeValue(tab.value);
|
||||
onChangeValue?.(tab.value);
|
||||
},
|
||||
className: classNames(
|
||||
'flex items-center rounded whitespace-nowrap',
|
||||
@@ -431,11 +511,7 @@ function TabButton({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
leftSlot={tab.leftSlot}
|
||||
rightSlot={tab.rightSlot}
|
||||
{...btnProps}
|
||||
>
|
||||
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
|
||||
{'label' in tab && tab.label ? tab.label : tab.value}
|
||||
</Button>
|
||||
);
|
||||
@@ -478,3 +554,32 @@ export const TabContent = memo(function TabContent({
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.
|
||||
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
|
||||
*/
|
||||
export async function setActiveTab({
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
value,
|
||||
}: {
|
||||
storageKey: string;
|
||||
activeTabKey: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
const { getKeyValue, setKeyValue } = await import('../../../lib/keyValueStore');
|
||||
const current = getKeyValue<TabsStorage>({
|
||||
namespace: 'no_sync',
|
||||
key: storageKey,
|
||||
fallback: { order: [], activeTabs: {} },
|
||||
});
|
||||
await setKeyValue({
|
||||
namespace: 'no_sync',
|
||||
key: storageKey,
|
||||
value: {
|
||||
...current,
|
||||
activeTabs: { ...current.activeTabs, [activeTabKey]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { DropdownItem } from '../core/Dropdown';
|
||||
import { Dropdown } from '../core/Dropdown';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { BranchSelectionDialog } from './BranchSelectionDialog';
|
||||
import { gitCallbacks } from './callbacks';
|
||||
import { GitCommitDialog } from './GitCommitDialog';
|
||||
import { GitRemotesDialog } from './GitRemotesDialog';
|
||||
@@ -39,7 +38,18 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const [
|
||||
{ status, log },
|
||||
{ branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init },
|
||||
{
|
||||
createBranch,
|
||||
deleteBranch,
|
||||
deleteRemoteBranch,
|
||||
renameBranch,
|
||||
fetchAll,
|
||||
mergeBranch,
|
||||
push,
|
||||
pull,
|
||||
checkout,
|
||||
init,
|
||||
},
|
||||
] = useGit(syncDir, gitCallbacks(syncDir));
|
||||
|
||||
const localBranches = status.data?.localBranches ?? [];
|
||||
@@ -47,8 +57,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
const remoteOnlyBranches = remoteBranches.filter(
|
||||
(b) => !localBranches.includes(b.replace(/^origin\//, '')),
|
||||
);
|
||||
const currentBranch = status.data?.headRefShorthand ?? 'UNKNOWN';
|
||||
|
||||
if (workspace == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -58,6 +66,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
|
||||
}
|
||||
|
||||
// Still loading
|
||||
if (status.data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentBranch = status.data.headRefShorthand;
|
||||
|
||||
const tryCheckout = (branch: string, force: boolean) => {
|
||||
checkout.mutate(
|
||||
{ branch, force },
|
||||
@@ -104,7 +119,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
label: 'View History',
|
||||
label: 'View History...',
|
||||
hidden: (log.data ?? []).length === 0,
|
||||
leftSlot: <Icon icon="history" />,
|
||||
onSelect: async () => {
|
||||
@@ -118,13 +133,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Manage Remotes',
|
||||
label: 'Manage Remotes...',
|
||||
leftSlot: <Icon icon="hard_drive_download" />,
|
||||
onSelect: () => GitRemotesDialog.show(syncDir),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'New Branch',
|
||||
label: 'New Branch...',
|
||||
leftSlot: <Icon icon="git_branch_plus" />,
|
||||
async onSelect() {
|
||||
const name = await showPrompt({
|
||||
@@ -134,7 +149,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
});
|
||||
if (!name) return;
|
||||
|
||||
await branch.mutateAsync(
|
||||
await createBranch.mutateAsync(
|
||||
{ branch: name },
|
||||
{
|
||||
disableToastError: true,
|
||||
@@ -150,95 +165,6 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
tryCheckout(name, false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Merge Branch',
|
||||
leftSlot: <Icon icon="merge" />,
|
||||
hidden: localBranches.length <= 1,
|
||||
async onSelect() {
|
||||
showDialog({
|
||||
id: 'git-merge',
|
||||
title: 'Merge Branch',
|
||||
size: 'sm',
|
||||
description: (
|
||||
<>
|
||||
Select a branch to merge into <InlineCode>{currentBranch}</InlineCode>
|
||||
</>
|
||||
),
|
||||
render: ({ hide }) => (
|
||||
<BranchSelectionDialog
|
||||
selectText="Merge"
|
||||
branches={localBranches.filter((b) => b !== currentBranch)}
|
||||
onCancel={hide}
|
||||
onSelect={async (branch) => {
|
||||
await mergeBranch.mutateAsync(
|
||||
{ branch, force: false },
|
||||
{
|
||||
disableToastError: true,
|
||||
onSettled: hide,
|
||||
onSuccess() {
|
||||
showToast({
|
||||
id: 'git-merged-branch',
|
||||
message: (
|
||||
<>
|
||||
Merged <InlineCode>{branch}</InlineCode> into{' '}
|
||||
<InlineCode>{currentBranch}</InlineCode>
|
||||
</>
|
||||
),
|
||||
});
|
||||
sync({ force: true });
|
||||
},
|
||||
onError(err) {
|
||||
showErrorToast({
|
||||
id: 'git-merged-branch-error',
|
||||
title: 'Error merging branch',
|
||||
message: String(err),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete Branch',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
hidden: localBranches.length <= 1,
|
||||
color: 'danger',
|
||||
async onSelect() {
|
||||
if (currentBranch == null) return;
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: 'git-delete-branch',
|
||||
title: 'Delete Branch',
|
||||
description: (
|
||||
<>
|
||||
Permanently delete <InlineCode>{currentBranch}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await deleteBranch.mutateAsync(
|
||||
{ branch: currentBranch },
|
||||
{
|
||||
disableToastError: true,
|
||||
onError(err) {
|
||||
showErrorToast({
|
||||
id: 'git-delete-branch-error',
|
||||
title: 'Error deleting branch',
|
||||
message: String(err),
|
||||
});
|
||||
},
|
||||
async onSuccess() {
|
||||
await sync({ force: true });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Push',
|
||||
@@ -278,7 +204,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
label: 'Commit...',
|
||||
leftSlot: <Icon icon="git_commit_vertical" />,
|
||||
onSelect() {
|
||||
showDialog({
|
||||
@@ -298,16 +224,239 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||
return {
|
||||
label: branch,
|
||||
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
|
||||
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
|
||||
};
|
||||
submenuOpenOnClick: true,
|
||||
submenu: [
|
||||
{
|
||||
label: 'Checkout',
|
||||
hidden: isCurrent,
|
||||
onSelect: () => tryCheckout(branch, false),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<>
|
||||
Merge into <InlineCode>{currentBranch}</InlineCode>
|
||||
</>
|
||||
),
|
||||
hidden: isCurrent,
|
||||
async onSelect() {
|
||||
await mergeBranch.mutateAsync(
|
||||
{ branch },
|
||||
{
|
||||
disableToastError: true,
|
||||
onSuccess() {
|
||||
showToast({
|
||||
id: 'git-merged-branch',
|
||||
message: (
|
||||
<>
|
||||
Merged <InlineCode>{branch}</InlineCode> into{' '}
|
||||
<InlineCode>{currentBranch}</InlineCode>
|
||||
</>
|
||||
),
|
||||
});
|
||||
sync({ force: true });
|
||||
},
|
||||
onError(err) {
|
||||
showErrorToast({
|
||||
id: 'git-merged-branch-error',
|
||||
title: 'Error merging branch',
|
||||
message: String(err),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'New Branch...',
|
||||
async onSelect() {
|
||||
const name = await showPrompt({
|
||||
id: 'git-new-branch-from',
|
||||
title: 'New Branch',
|
||||
description: (
|
||||
<>
|
||||
Create a new branch from <InlineCode>{branch}</InlineCode>
|
||||
</>
|
||||
),
|
||||
label: 'Branch Name',
|
||||
});
|
||||
if (!name) return;
|
||||
|
||||
await createBranch.mutateAsync(
|
||||
{ branch: name, base: branch },
|
||||
{
|
||||
disableToastError: true,
|
||||
onError: (err) => {
|
||||
showErrorToast({
|
||||
id: 'git-branch-error',
|
||||
title: 'Error creating branch',
|
||||
message: String(err),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
tryCheckout(name, false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Rename...',
|
||||
async onSelect() {
|
||||
const newName = await showPrompt({
|
||||
id: 'git-rename-branch',
|
||||
title: 'Rename Branch',
|
||||
label: 'New Branch Name',
|
||||
defaultValue: branch,
|
||||
});
|
||||
if (!newName || newName === branch) return;
|
||||
|
||||
await renameBranch.mutateAsync(
|
||||
{ oldName: branch, newName },
|
||||
{
|
||||
disableToastError: true,
|
||||
onSuccess() {
|
||||
showToast({
|
||||
id: 'git-rename-branch-success',
|
||||
message: (
|
||||
<>
|
||||
Renamed <InlineCode>{branch}</InlineCode> to{' '}
|
||||
<InlineCode>{newName}</InlineCode>
|
||||
</>
|
||||
),
|
||||
color: 'success',
|
||||
});
|
||||
},
|
||||
onError(err) {
|
||||
showErrorToast({
|
||||
id: 'git-rename-branch-error',
|
||||
title: 'Error renaming branch',
|
||||
message: String(err),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
{ type: 'separator', hidden: isCurrent },
|
||||
{
|
||||
label: 'Delete',
|
||||
color: 'danger',
|
||||
hidden: isCurrent,
|
||||
onSelect: async () => {
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: 'git-delete-branch',
|
||||
title: 'Delete Branch',
|
||||
description: (
|
||||
<>
|
||||
Permanently delete <InlineCode>{branch}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteBranch.mutateAsync(
|
||||
{ branch },
|
||||
{
|
||||
disableToastError: true,
|
||||
onError(err) {
|
||||
showErrorToast({
|
||||
id: 'git-delete-branch-error',
|
||||
title: 'Error deleting branch',
|
||||
message: String(err),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.type === 'not_fully_merged') {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'force-branch-delete',
|
||||
title: 'Branch not fully merged',
|
||||
description: (
|
||||
<>
|
||||
<p>
|
||||
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
|
||||
</p>
|
||||
<p>Do you want to delete it anyway?</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await deleteBranch.mutateAsync(
|
||||
{ branch, force: true },
|
||||
{
|
||||
disableToastError: true,
|
||||
onError(err) {
|
||||
showErrorToast({
|
||||
id: 'git-force-delete-branch-error',
|
||||
title: 'Error force deleting branch',
|
||||
message: String(err),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies DropdownItem;
|
||||
}),
|
||||
...remoteOnlyBranches.map((branch) => {
|
||||
const isCurrent = currentBranch === branch;
|
||||
return {
|
||||
label: branch,
|
||||
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
|
||||
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
|
||||
};
|
||||
submenuOpenOnClick: true,
|
||||
submenu: [
|
||||
{
|
||||
label: 'Checkout',
|
||||
hidden: isCurrent,
|
||||
onSelect: () => tryCheckout(branch, false),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
color: 'danger',
|
||||
async onSelect() {
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: 'git-delete-remote-branch',
|
||||
title: 'Delete Remote Branch',
|
||||
description: (
|
||||
<>
|
||||
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
await deleteRemoteBranch.mutateAsync(
|
||||
{ branch },
|
||||
{
|
||||
disableToastError: true,
|
||||
onSuccess() {
|
||||
showToast({
|
||||
id: 'git-delete-remote-branch-success',
|
||||
message: (
|
||||
<>
|
||||
Deleted remote branch <InlineCode>{branch}</InlineCode>
|
||||
</>
|
||||
),
|
||||
color: 'success',
|
||||
});
|
||||
},
|
||||
onError(err) {
|
||||
showErrorToast({
|
||||
id: 'git-delete-remote-branch-error',
|
||||
title: 'Error deleting remote branch',
|
||||
message: String(err),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies DropdownItem;
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { GitCallbacks } from '@yaakapp-internal/git';
|
||||
import { showPromptForm } from '../../lib/prompt-form';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { promptCredentials } from './credentials';
|
||||
import { addGitRemote } from './showAddRemoteDialog';
|
||||
|
||||
export function gitCallbacks(dir: string): GitCallbacks {
|
||||
@@ -9,40 +7,10 @@ export function gitCallbacks(dir: string): GitCallbacks {
|
||||
addRemote: async () => {
|
||||
return addGitRemote(dir);
|
||||
},
|
||||
promptCredentials: async ({ url: remoteUrl, error }) => {
|
||||
const isGitHub = /github\.com/i.test(remoteUrl);
|
||||
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
|
||||
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
|
||||
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
|
||||
const passDescription = isGitHub
|
||||
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
|
||||
: 'Enter your password or access token for this Git server.';
|
||||
const r = await showPromptForm({
|
||||
id: 'git-credentials',
|
||||
title: 'Credentials Required',
|
||||
description: error ? (
|
||||
<Banner color="danger">{error}</Banner>
|
||||
) : (
|
||||
<>
|
||||
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
|
||||
</>
|
||||
),
|
||||
inputs: [
|
||||
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
|
||||
{
|
||||
type: 'text',
|
||||
name: 'password',
|
||||
label: passLabel,
|
||||
description: passDescription,
|
||||
password: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (r == null) throw new Error('Cancelled credentials prompt');
|
||||
|
||||
const username = String(r.username || '');
|
||||
const password = String(r.password || '');
|
||||
return { username, password };
|
||||
promptCredentials: async ({ url, error }) => {
|
||||
const creds = await promptCredentials({ url, error });
|
||||
if (creds == null) throw new Error('Cancelled credentials prompt');
|
||||
return creds;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
50
src-web/components/git/credentials.tsx
Normal file
50
src-web/components/git/credentials.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { showPromptForm } from '../../lib/prompt-form';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
|
||||
export interface GitCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function promptCredentials({
|
||||
url: remoteUrl,
|
||||
error,
|
||||
}: {
|
||||
url: string;
|
||||
error: string | null;
|
||||
}): Promise<GitCredentials | null> {
|
||||
const isGitHub = /github\.com/i.test(remoteUrl);
|
||||
const userLabel = isGitHub ? 'GitHub Username' : 'Username';
|
||||
const passLabel = isGitHub ? 'GitHub Personal Access Token' : 'Password / Token';
|
||||
const userDescription = isGitHub ? 'Use your GitHub username (not your email).' : undefined;
|
||||
const passDescription = isGitHub
|
||||
? 'GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.'
|
||||
: 'Enter your password or access token for this Git server.';
|
||||
const r = await showPromptForm({
|
||||
id: 'git-credentials',
|
||||
title: 'Credentials Required',
|
||||
description: error ? (
|
||||
<Banner color="danger">{error}</Banner>
|
||||
) : (
|
||||
<>
|
||||
Enter credentials for <InlineCode>{remoteUrl}</InlineCode>
|
||||
</>
|
||||
),
|
||||
inputs: [
|
||||
{ type: 'text', name: 'username', label: userLabel, description: userDescription },
|
||||
{
|
||||
type: 'text',
|
||||
name: 'password',
|
||||
label: passLabel,
|
||||
description: passDescription,
|
||||
password: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (r == null) return null;
|
||||
|
||||
const username = String(r.username || '');
|
||||
const password = String(r.password || '');
|
||||
return { username, password };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type MultipartPart, parseMultipart } from '@mjackson/multipart-parser';
|
||||
import { lazy, Suspense, useMemo, useState } from 'react';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import { languageFromContentType } from '../../lib/contentType';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Icon } from '../core/Icon';
|
||||
@@ -22,8 +22,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) {
|
||||
const [tab, setTab] = useState<string>();
|
||||
|
||||
const parseResult = useMemo(() => {
|
||||
try {
|
||||
const maxFileSize = 1024 * 1024 * 10; // 10MB
|
||||
@@ -55,12 +53,10 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
addBorders
|
||||
label="Multipart"
|
||||
layout="horizontal"
|
||||
tabListClassName="border-r border-r-border"
|
||||
onChangeValue={setTab}
|
||||
tabs={parts.map((part) => ({
|
||||
label: part.name ?? '',
|
||||
value: part.name ?? '',
|
||||
|
||||
@@ -23,14 +23,17 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
|
||||
variables: TVariables,
|
||||
args?: CallbackMutationOptions<TData, TError, TVariables>,
|
||||
) => {
|
||||
const { mutationKey, mutationFn, onSuccess, onError, onSettled, disableToastError } = {
|
||||
const { mutationKey, mutationFn, disableToastError } = {
|
||||
...defaultArgs,
|
||||
...args,
|
||||
};
|
||||
try {
|
||||
const data = await mutationFn(variables);
|
||||
onSuccess?.(data);
|
||||
onSettled?.();
|
||||
// Run both default and custom onSuccess callbacks
|
||||
defaultArgs.onSuccess?.(data);
|
||||
args?.onSuccess?.(data);
|
||||
defaultArgs.onSettled?.();
|
||||
args?.onSettled?.();
|
||||
return data;
|
||||
} catch (err: unknown) {
|
||||
const stringKey = mutationKey.join('.');
|
||||
@@ -44,8 +47,11 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
onError?.(e);
|
||||
onSettled?.();
|
||||
// Run both default and custom onError callbacks
|
||||
defaultArgs.onError?.(e);
|
||||
args?.onError?.(e);
|
||||
defaultArgs.onSettled?.();
|
||||
args?.onSettled?.();
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useGrpc(
|
||||
queryFn: () => {
|
||||
const environmentId = jotaiStore.get(activeEnvironmentIdAtom);
|
||||
return minPromiseMillis<ReflectResponseService[]>(
|
||||
invokeCmd('cmd_grpc_reflect', { requestId, protoFiles, environmentId, skipCache: true }),
|
||||
invokeCmd('cmd_grpc_reflect', { requestId, protoFiles, environmentId }),
|
||||
300,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
replaceModelsInStore,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useHttpResponseEvents(response: HttpResponse | null) {
|
||||
const allEvents = useAtomValue(httpResponseEventsAtom);
|
||||
@@ -17,18 +17,13 @@ export function useHttpResponseEvents(response: HttpResponse | null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use merge instead of replace to preserve events that came in via model_write
|
||||
// while we were fetching from the database
|
||||
// Fetch events from database, filtering out events from other responses and merging atomically
|
||||
invoke<HttpResponseEvent[]>('cmd_get_http_response_events', { responseId: response.id }).then(
|
||||
(events) => mergeModelsInStore('http_response_event', events),
|
||||
(events) =>
|
||||
mergeModelsInStore('http_response_event', events, (e) => e.responseId === response.id),
|
||||
);
|
||||
}, [response?.id]);
|
||||
|
||||
// Filter events for the current response
|
||||
const events = useMemo(
|
||||
() => allEvents.filter((e) => e.responseId === response?.id),
|
||||
[allEvents, response?.id],
|
||||
);
|
||||
|
||||
const events = allEvents.filter((e) => e.responseId === response?.id);
|
||||
return { data: events, error: null, isLoading: false };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '@yaakapp-internal/models';
|
||||
import { foldersAtom, workspacesAtom } from '@yaakapp-internal/models';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { defaultHeaders } from '../lib/defaultHeaders';
|
||||
|
||||
const ancestorsAtom = atom((get) => [...get(foldersAtom), ...get(workspacesAtom)]);
|
||||
|
||||
@@ -17,12 +18,12 @@ export function useInheritedHeaders(baseModel: HeaderModel | null) {
|
||||
const parents = useAtomValue(ancestorsAtom);
|
||||
|
||||
if (baseModel == null) return [];
|
||||
if (baseModel.model === 'workspace') return [];
|
||||
if (baseModel.model === 'workspace') return defaultHeaders;
|
||||
|
||||
const next = (child: HeaderModel): HttpRequestHeader[] => {
|
||||
// Short-circuit
|
||||
// Short-circuit at workspace level - return global defaults + workspace headers
|
||||
if (child.model === 'workspace') {
|
||||
return [];
|
||||
return [...defaultHeaders, ...child.headers];
|
||||
}
|
||||
|
||||
// Recurse up the tree
|
||||
@@ -40,5 +41,13 @@ export function useInheritedHeaders(baseModel: HeaderModel | null) {
|
||||
return [...headers, ...parent.headers];
|
||||
};
|
||||
|
||||
return next(baseModel);
|
||||
const allHeaders = next(baseModel);
|
||||
|
||||
// Deduplicate by header name (case-insensitive), keeping the latest (most specific) value
|
||||
const headersByName = new Map<string, HttpRequestHeader>();
|
||||
for (const header of allHeaders) {
|
||||
headersByName.set(header.name.toLowerCase(), header);
|
||||
}
|
||||
|
||||
return Array.from(headersByName.values());
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
replaceModelsInStore,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||
import { activeRequestIdAtom } from './useActiveRequestId';
|
||||
|
||||
@@ -60,7 +60,7 @@ export const activeGrpcConnectionAtom = atom<GrpcConnection | null>((get) => {
|
||||
});
|
||||
|
||||
export function useGrpcEvents(connectionId: string | null) {
|
||||
const events = useAtomValue(grpcEventsAtom);
|
||||
const allEvents = useAtomValue(grpcEventsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionId == null) {
|
||||
@@ -68,12 +68,14 @@ export function useGrpcEvents(connectionId: string | null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use merge instead of replace to preserve events that came in via model_write
|
||||
// while we were fetching from the database
|
||||
invoke<GrpcEvent[]>('models_grpc_events', { connectionId }).then((events) => {
|
||||
mergeModelsInStore('grpc_event', events);
|
||||
});
|
||||
// Fetch events from database, filtering out events from other connections and merging atomically
|
||||
invoke<GrpcEvent[]>('models_grpc_events', { connectionId }).then((events) =>
|
||||
mergeModelsInStore('grpc_event', events, (e) => e.connectionId === connectionId),
|
||||
);
|
||||
}, [connectionId]);
|
||||
|
||||
return events;
|
||||
return useMemo(
|
||||
() => allEvents.filter((e) => e.connectionId === connectionId),
|
||||
[allEvents, connectionId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
websocketEventsAtom,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { activeRequestIdAtom } from './useActiveRequestId';
|
||||
@@ -47,7 +47,7 @@ export function setPinnedWebsocketConnectionId(id: string | null) {
|
||||
}
|
||||
|
||||
export function useWebsocketEvents(connectionId: string | null) {
|
||||
const events = useAtomValue(websocketEventsAtom);
|
||||
const allEvents = useAtomValue(websocketEventsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionId == null) {
|
||||
@@ -55,12 +55,14 @@ export function useWebsocketEvents(connectionId: string | null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use merge instead of replace to preserve events that came in via model_write
|
||||
// while we were fetching from the database
|
||||
invoke<WebsocketEvent[]>('models_websocket_events', { connectionId }).then(
|
||||
(events) => mergeModelsInStore('websocket_event', events),
|
||||
// Fetch events from database, filtering out events from other connections and merging atomically
|
||||
invoke<WebsocketEvent[]>('models_websocket_events', { connectionId }).then((events) =>
|
||||
mergeModelsInStore('websocket_event', events, (e) => e.connectionId === connectionId),
|
||||
);
|
||||
}, [connectionId]);
|
||||
|
||||
return events;
|
||||
return useMemo(
|
||||
() => allEvents.filter((e) => e.connectionId === connectionId),
|
||||
[allEvents, connectionId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useRequestEditor() {
|
||||
const focusParamValue = useCallback(
|
||||
(name: string) => {
|
||||
focusParamsTab();
|
||||
setTimeout(() => emitter.emit('request_params.focus_value', name), 50);
|
||||
requestAnimationFrame(() => emitter.emit('request_params.focus_value', name));
|
||||
},
|
||||
[focusParamsTab],
|
||||
);
|
||||
|
||||
14
src-web/hooks/useTimelineViewMode.ts
Normal file
14
src-web/hooks/useTimelineViewMode.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { TimelineViewMode } from '../components/HttpResponsePane';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
|
||||
const DEFAULT_VIEW_MODE: TimelineViewMode = 'timeline';
|
||||
|
||||
export function useTimelineViewMode() {
|
||||
const { set, value } = useKeyValue<TimelineViewMode>({
|
||||
namespace: 'no_sync',
|
||||
key: 'timeline_view_mode',
|
||||
fallback: DEFAULT_VIEW_MODE,
|
||||
});
|
||||
|
||||
return [value ?? DEFAULT_VIEW_MODE, set] as const;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface AppInfo {
|
||||
appDataDir: string;
|
||||
appLogDir: string;
|
||||
vendoredPluginDir: string;
|
||||
defaultProjectDir: string;
|
||||
identifier: string;
|
||||
featureLicense: boolean;
|
||||
featureUpdater: boolean;
|
||||
|
||||
8
src-web/lib/defaultHeaders.ts
Normal file
8
src-web/lib/defaultHeaders.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { HttpRequestHeader } from '@yaakapp-internal/models';
|
||||
import { invokeCmd } from './tauri';
|
||||
|
||||
/**
|
||||
* Global default headers fetched from the backend.
|
||||
* These are static and fetched once on module load.
|
||||
*/
|
||||
export const defaultHeaders: HttpRequestHeader[] = await invokeCmd('cmd_default_headers');
|
||||
@@ -12,6 +12,7 @@ type TauriCmd =
|
||||
| 'cmd_create_grpc_request'
|
||||
| 'cmd_curl_to_request'
|
||||
| 'cmd_decrypt_template'
|
||||
| 'cmd_default_headers'
|
||||
| 'cmd_delete_all_grpc_connections'
|
||||
| 'cmd_delete_all_http_responses'
|
||||
| 'cmd_delete_send_history'
|
||||
@@ -24,6 +25,8 @@ type TauriCmd =
|
||||
| 'cmd_get_sse_events'
|
||||
| 'cmd_get_themes'
|
||||
| 'cmd_get_workspace_meta'
|
||||
| 'cmd_git_add_credential'
|
||||
| 'cmd_git_clone'
|
||||
| 'cmd_grpc_go'
|
||||
| 'cmd_grpc_reflect'
|
||||
| 'cmd_grpc_request_actions'
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig(async () => {
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
sourcemap: true,
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
|
||||
Reference in New Issue
Block a user