Compare commits

..

37 Commits

Author SHA1 Message Date
Gregory Schier
1f8fa0f8c3 Separate app DB per Git worktree (#348) 2026-01-05 15:35:25 -08:00
Gregory Schier
dc51de2af1 Bump plugin manager channel sizes to account for more plugins 2026-01-05 15:10:54 -08:00
Gregory Schier
e818c349cc Add reorderable tabs with global persistence (#347) 2026-01-05 14:58:16 -08:00
Gregory Schier
412d7a7654 Add Cookies response pane tab (#346) 2026-01-05 13:41:39 -08:00
Gregory Schier
ab5c7f638b Fix protected branch push rejections not being detected (#345) 2026-01-05 13:28:41 -08:00
Gregory Schier
5bd8685175 Merge branch 'main' of github.com:mountain-loop/yaak 2026-01-05 06:53:33 -08:00
Gregory Schier
a9118bf55a Fix timeout on claude command 2026-01-05 06:53:19 -08:00
Gregory Schier
1828e2ec14 Fix cookie dialog rows not disappearing on delete (#344) 2026-01-04 20:10:11 -08:00
Gregory Schier
6c9791cf0b Fix multiple Set-Cookie headers not being preserved
Changed HttpResponse.headers from HashMap<String, String> to
Vec<(String, String)> to preserve duplicate headers. This fixes
the bug where only the last Set-Cookie header was stored when
servers sent multiple cookies.

Added test case for multiple Set-Cookie headers to prevent
regression.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-04 19:44:33 -08:00
Gregory Schier
a09437018e Update Biome schema version to 2.3.11
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-04 19:04:55 -08:00
Gregory Schier
4b54c22012 Fix weird type recursion in MCP plugin 2026-01-04 15:46:05 -08:00
Gregory Schier
4f7e67b106 Fix listing installed filesystem plugins 2026-01-04 14:00:33 -08:00
Gregory Schier
8b637d53c4 Add configurable timeouts for plugin events
- Add timeout parameter to send_to_plugins_and_wait and send_to_plugin_and_wait
- Use 5 second timeout for standard operations (themes, actions, configs, etc.)
- Use 5 minute timeout for user-interactive operations:
  - Authentication actions (OAuth login flows)
  - Authentication requests (token refresh, OAuth)
  - Template function calls (credential prompts, OAuth, etc.)
- Fixes issue where auth flows would timeout after 5 seconds
2026-01-04 09:57:58 -08:00
Gregory Schier
00bf5920e3 Add configurable hotkeys support (#343) 2026-01-04 08:36:22 -08:00
Gregory Schier
58bf55704a Preserve sidebar item active color when showing context menu 2026-01-03 15:07:29 -08:00
Gregory Schier
c75d6b815e Fix sidebar hidden state being updated too frequently 2026-01-03 14:29:18 -08:00
Gregory Schier
35a57bf7f5 Add plugin API to open URL in external browser (#340)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-03 13:53:07 -08:00
Gregory Schier
118b2faa76 Update checkout pr command with proper timeout 2026-01-03 13:52:20 -08:00
Gregory Schier
158164089f Update check-out-pr.md 2026-01-03 13:30:43 -08:00
Gregory Schier
4cd4cb5722 Add check-out-pr claude command 2026-01-03 09:41:19 -08:00
Gregory Schier
52f7447f85 Support running multiple Yaak instances via git worktrees (#341) 2026-01-03 09:31:35 -08:00
Gregory Schier
11694921e3 Better plugin error handling 2026-01-02 10:20:44 -08:00
Gregory Schier
0146ee586f Notify of plugin updates and add update UX (#339) 2026-01-02 10:03:08 -08:00
Gregory Schier
e751167dfc Bump mcp server plugin version 2026-01-02 07:32:36 -08:00
Gregory Schier
2ccee0dc70 Better MCP server lifecycle 2026-01-02 07:31:54 -08:00
Gregory Schier
04eec0ee05 Fix weird type errors 2026-01-02 07:10:48 -08:00
Gregory Schier
7e239c0dd1 Fix colon in path name 2026-01-01 16:57:28 -08:00
Gregory Schier
f1783feafc Fix installed and bundled plugin tabs 2026-01-01 16:55:30 -08:00
Gregory Schier
ef187373c5 Fix plugin install 2026-01-01 16:44:00 -08:00
Gregory Schier
8da3659be3 Restructure add plugin 2026-01-01 10:49:35 -08:00
Gregory Schier
4d2bf9304a Fix plugin installation from directory 2026-01-01 10:45:13 -08:00
Gregory Schier
d544899f39 Add body and auth support to MCP HTTP request tools
- Add body, bodyType, authentication, and authenticationType fields to create/update HTTP request MCP tools
- Include comprehensive documentation for body structures and auth types in Zod descriptions
- Fix MCP update_http_request to merge partial updates with existing data to prevent FK constraint violations
- Fix Zod imports from 'zod/v4' to 'zod' to match installed version
- Add uncaughtException handler to plugin runtime to prevent individual plugin crashes from crashing entire runtime
2026-01-01 10:33:28 -08:00
Gregory Schier
92a8da03af (feat) Add ability to disable plugins and show bundled plugins (#337) 2026-01-01 09:32:48 -08:00
Gregory Schier
07ea1ea7dc Fix lint errors 2025-12-31 16:17:24 -08:00
Gregory Schier
e435414c2e Update readme for mcp plugin 2025-12-31 11:15:53 -08:00
Gregory Schier
e4bd30eb01 Fix Nord themes 2025-12-31 11:01:40 -08:00
Gregory Schier
af3e672386 Claude command and add Nord Light 2025-12-31 10:55:28 -08:00
124 changed files with 5532 additions and 4098 deletions

View File

@@ -0,0 +1,51 @@
---
description: Review a PR in a new worktree
allowed-tools: Bash(git worktree:*), Bash(gh pr:*)
---
Review a GitHub pull request in a new git worktree.
## Usage
```
/review-pr <PR_NUMBER>
```
## What to do
1. List all open pull requests and ask the user to select one
2. Get PR information using `gh pr view <PR_NUMBER> --json number,headRefName`
3. Extract the branch name from the PR
4. Create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>` using `git worktree add` with a timeout of at least 300000ms (5 minutes) since the post-checkout hook runs a bootstrap script
5. Checkout the PR branch in the new worktree using `gh pr checkout <PR_NUMBER>`
6. The post-checkout hook will automatically:
- Create `.env.local` with unique ports
- Copy editor config folders
- Run `npm install && npm run bootstrap`
7. Inform the user:
- Where the worktree was created
- What ports were assigned
- How to access it (cd command)
- How to run the dev server
- How to remove the worktree when done
## Example Output
```
Created worktree for PR #123 at ../yaak-worktrees/pr-123
Branch: feature-auth
Ports: Vite (1421), MCP (64344)
To start working:
cd ../yaak-worktrees/pr-123
npm run app-dev
To remove when done:
git worktree remove ../yaak-worktrees/pr-123
```
## Error Handling
- If the PR doesn't exist, show a helpful error
- If the worktree already exists, inform the user and ask if they want to remove and recreate it
- If `gh` CLI is not available, inform the user to install it

View File

@@ -0,0 +1,39 @@
---
description: Generate formatted release notes for Yaak releases
allowed-tools: Bash(git tag:*)
---
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
## What to do
1. Identifies the version tag and previous version
2. Retrieves all commits between versions
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
3. Fetches PR descriptions for linked issues to find:
- Feedback URLs (feedback.yaak.app)
- Additional context and descriptions
- Installation links for plugins
4. Formats the release notes using the standard Yaak format:
- Changelog badge at the top
- Bulleted list of changes with PR links
- Feedback links where available
- Full changelog comparison link at the bottom
## Output Format
The skill generates markdown-formatted release notes following this structure:
```markdown
[![Changelog](https://img.shields.io/badge/Changelog-VERSION-blue)](https://yaak.app/changelog/VERSION)
- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)
- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)
- A simple item that doesn't have a feedback or PR link
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
```
**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

View File

@@ -1,50 +0,0 @@
---
name: Yaak:generate-release-notes
description: Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions
---
# Release Notes Generator
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
## Usage
You can invoke this skill by:
1. Providing a version number: "Generate release notes for 2025.10.0-beta.6"
2. Using "latest" to generate notes for the most recent tag
## What this skill does
1. Identifies the version tag and previous version
2. Retrieves all commits between versions
3. Fetches PR descriptions for linked issues to find:
- Feedback URLs (feedback.yaak.app)
- Additional context and descriptions
- Installation links for plugins
4. Formats the release notes using the standard Yaak format:
- Changelog badge at the top
- Bulleted list of changes with PR links
- Feedback links where available
- Full changelog comparison link at the bottom
## Output Format
The skill generates markdown-formatted release notes following this structure:
```markdown
[![Changelog](https://img.shields.io/badge/Changelog-VERSION-blue)](https://yaak.app/changelog/VERSION)
- Feature/fix description in [#123](https://github.com/mountain-loop/yaak/pull/123)
- Additional context if needed
- [Linked feedback item](https://feedback.yaak.app/p/item) in [#456](https://github.com/mountain-loop/yaak/pull/456)
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
```
**IMPORTANT**: Always add a blank line after the closing ``` markdown fence before any explanatory text.
## Requirements
- Git repository must be available
- GitHub CLI (`gh`) must be installed and authenticated
- Version tags should follow the format: `v2025.10.0-beta.X`

View File

@@ -0,0 +1,35 @@
# Worktree Management Skill
## Creating Worktrees
When creating git worktrees for this project, ALWAYS use the path format:
```
../yaak-worktrees/<NAME>
```
For example:
- `git worktree add ../yaak-worktrees/feature-auth`
- `git worktree add ../yaak-worktrees/bugfix-login`
- `git worktree add ../yaak-worktrees/refactor-api`
## What Happens Automatically
The post-checkout hook will automatically:
1. Create `.env.local` with unique ports (YAAK_DEV_PORT and YAAK_PLUGIN_MCP_SERVER_PORT)
2. Copy gitignored editor config folders (.zed, .idea, etc.)
3. Run `npm install && npm run bootstrap`
## Deleting Worktrees
```bash
git worktree remove ../yaak-worktrees/<NAME>
```
## Port Assignments
- Main worktree: 1420 (Vite), 64343 (MCP)
- First worktree: 1421, 64344
- Second worktree: 1422, 64345
- etc.
Each worktree can run `npm run app-dev` simultaneously without conflicts.

1
.gitignore vendored
View File

@@ -36,3 +36,4 @@ out
tmp
.zed
codebook.toml
target

1
.husky/post-checkout Executable file
View File

@@ -0,0 +1 @@
node scripts/git-hooks/post-checkout.mjs "$@"

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.7/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"linter": {
"enabled": true,
"rules": {
@@ -39,13 +39,13 @@
"!**/dist",
"!**/build",
"!scripts",
"!packages/plugin-runtime",
"!packages/plugin-runtime-types",
"!src-tauri",
"!src-web/tailwind.config.cjs",
"!src-web/postcss.config.cjs",
"!src-web/vite.config.ts",
"!src-web/routeTree.gen.ts"
"!src-web/routeTree.gen.ts",
"!packages/plugin-runtime-types/lib",
"!**/bindings"
]
}
}

5559
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"plugins-external/template-function-faker",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/action-send-folder",
"plugins/auth-apikey",
"plugins/auth-aws",
"plugins/auth-basic",
@@ -61,9 +62,11 @@
"src-web"
],
"scripts": {
"prepare": "husky",
"init": "npm install && npm run bootstrap",
"start": "npm run app-dev",
"app-build": "tauri build",
"app-dev": "tauri dev --no-watch --config ./src-tauri/tauri.development.conf.json",
"app-dev": "node scripts/run-dev.mjs",
"migration": "node scripts/create-migration.cjs",
"build": "npm run --workspaces --if-present build",
"build-plugins": "npm run --workspaces --if-present build",
@@ -89,9 +92,11 @@
"tauri-before-dev": "workspaces-run --parallel -- npm run --workspaces --if-present dev"
},
"devDependencies": {
"@biomejs/biome": "^2.3.7",
"@biomejs/biome": "^2.3.10",
"@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.3.4",
"dotenv-cli": "^11.0.0",
"husky": "^9.1.7",
"nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5",
"typescript": "^5.8.3",

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import {
import type {
CallHttpAuthenticationActionArgs,
CallHttpAuthenticationRequest,
CallHttpAuthenticationResponse,
@@ -6,8 +6,8 @@ import {
GetHttpAuthenticationSummaryResponse,
HttpAuthenticationAction,
} from '../bindings/gen_events';
import { MaybePromise } from '../helpers';
import { Context } from './Context';
import type { MaybePromise } from '../helpers';
import type { Context } from './Context';
type AddDynamicMethod<T> = {
dynamic?: (
@@ -16,6 +16,7 @@ type AddDynamicMethod<T> = {
) => MaybePromise<Partial<T> | null | undefined>;
};
// biome-ignore lint/suspicious/noExplicitAny: distributive conditional type pattern
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {

View File

@@ -1,27 +1,27 @@
import type {
FindHttpResponsesRequest,
FindHttpResponsesResponse,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse,
ListCookieNamesResponse,
ListFoldersRequest,
ListFoldersResponse,
ListHttpRequestsRequest,
ListHttpRequestsResponse,
OpenWindowRequest,
PromptTextRequest,
PromptTextResponse,
RenderGrpcRequestRequest,
RenderGrpcRequestResponse,
RenderHttpRequestRequest,
RenderHttpRequestResponse,
SendHttpRequestRequest,
SendHttpRequestResponse,
ShowToastRequest,
TemplateRenderRequest,
WorkspaceInfo,
FindHttpResponsesRequest,
FindHttpResponsesResponse,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse,
ListCookieNamesResponse,
ListFoldersRequest,
ListFoldersResponse,
ListHttpRequestsRequest,
ListHttpRequestsResponse,
OpenWindowRequest,
PromptTextRequest,
PromptTextResponse,
RenderGrpcRequestRequest,
RenderGrpcRequestResponse,
RenderHttpRequestRequest,
RenderHttpRequestResponse,
SendHttpRequestRequest,
SendHttpRequestResponse,
ShowToastRequest,
TemplateRenderRequest,
WorkspaceInfo,
} from '../bindings/gen_events.ts';
import type { HttpRequest } from '../bindings/gen_models.ts';
import type { JsonValue } from '../bindings/serde_json/JsonValue';
@@ -53,6 +53,7 @@ export interface Context {
onClose?: () => void;
},
): Promise<{ close: () => void }>;
openExternalUrl(url: string): Promise<void>;
};
cookies: {
listNames(): Promise<ListCookieNamesResponse['names']>;

View File

@@ -1,4 +1,4 @@
import { FilterResponse } from '../bindings/gen_events';
import type { FilterResponse } from '../bindings/gen_events';
import type { Context } from './Context';
export type FilterPlugin = {

View File

@@ -1,4 +1,4 @@
import { CallGrpcRequestActionArgs, GrpcRequestAction } from '../bindings/gen_events';
import type { CallGrpcRequestActionArgs, GrpcRequestAction } from '../bindings/gen_events';
import type { Context } from './Context';
export type GrpcRequestActionPlugin = GrpcRequestAction & {

View File

@@ -1,5 +1,5 @@
import { ImportResources } from '../bindings/gen_events';
import { AtLeast, MaybePromise } from '../helpers';
import type { ImportResources } from '../bindings/gen_events';
import type { AtLeast, MaybePromise } from '../helpers';
import type { Context } from './Context';
type RootFields = 'name' | 'id' | 'model';

View File

@@ -1,6 +1,6 @@
import { CallTemplateFunctionArgs, FormInput, TemplateFunction } from '../bindings/gen_events';
import { MaybePromise } from '../helpers';
import { Context } from './Context';
import type { CallTemplateFunctionArgs, FormInput, TemplateFunction } from '../bindings/gen_events';
import type { MaybePromise } from '../helpers';
import type { Context } from './Context';
type AddDynamicMethod<T> = {
dynamic?: (
@@ -9,6 +9,7 @@ type AddDynamicMethod<T> = {
) => MaybePromise<Partial<T> | null | undefined>;
};
// biome-ignore lint/suspicious/noExplicitAny: distributive conditional type pattern
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {

View File

@@ -1,3 +1,3 @@
import { Theme } from '../bindings/gen_events';
import type { Theme } from '../bindings/gen_events';
export type ThemePlugin = Theme;

View File

@@ -1,4 +1,7 @@
import type { CallWebsocketRequestActionArgs, WebsocketRequestAction } from '../bindings/gen_events';
import type {
CallWebsocketRequestActionArgs,
WebsocketRequestAction,
} from '../bindings/gen_events';
import type { Context } from './Context';
export type WebsocketRequestActionPlugin = WebsocketRequestAction & {

View File

@@ -1,8 +1,8 @@
import { AuthenticationPlugin } from './AuthenticationPlugin';
import type { AuthenticationPlugin } from './AuthenticationPlugin';
import type { Context } from './Context';
import type { FilterPlugin } from './FilterPlugin';
import { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin';
import type { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin';
import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin';
import type { WebsocketRequestActionPlugin } from './WebsocketRequestActionPlugin';
import type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';

View File

@@ -2,19 +2,12 @@
"compilerOptions": {
"module": "node16",
"target": "es6",
"lib": [
"es2021",
"dom"
],
"lib": ["es2021", "dom"],
"declaration": true,
"declarationDir": "./lib",
"outDir": "./lib",
"strict": true,
"types": [
"node"
]
"types": ["node"]
},
"files": [
"src/index.ts"
]
"files": ["src/index.ts"]
}

View File

@@ -1,7 +1,7 @@
import { PluginContext } from '@yaakapp-internal/plugins';
import type { BootRequest, InternalEvent } from '@yaakapp/api';
import type { PluginContext } from '@yaakapp-internal/plugins';
import type { EventChannel } from './EventChannel';
import { PluginInstance, PluginWorkerData } from './PluginInstance';
import { PluginInstance, type PluginWorkerData } from './PluginInstance';
export class PluginHandle {
#instance: PluginInstance;

View File

@@ -1,5 +1,12 @@
import { applyFormInputDefaults, validateTemplateFunctionArgs } from '@yaakapp-internal/lib/templateFunction';
import console from 'node:console';
import { type Stats, statSync, watch } from 'node:fs';
import path from 'node:path';
import type { Context, PluginDefinition } from '@yaakapp/api';
import {
applyFormInputDefaults,
validateTemplateFunctionArgs,
} from '@yaakapp-internal/lib/templateFunction';
import type {
BootRequest,
DeleteKeyValueResponse,
DeleteModelResponse,
@@ -12,9 +19,13 @@ import {
HttpAuthenticationAction,
HttpRequest,
HttpRequestAction,
ImportResources,
InternalEvent,
InternalEventPayload,
ListCookieNamesResponse,
ListFoldersResponse,
ListHttpRequestsRequest,
ListHttpRequestsResponse,
ListWorkspacesResponse,
PluginContext,
PromptTextResponse,
@@ -22,14 +33,11 @@ import {
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateFunction,
TemplateRenderRequest,
TemplateRenderResponse,
UpsertModelResponse,
WindowInfoResponse,
} from '@yaakapp-internal/plugins';
import { Context, PluginDefinition } from '@yaakapp/api';
import console from 'node:console';
import { type Stats, statSync, watch } from 'node:fs';
import path from 'node:path';
import { applyDynamicFormInput } from './common';
import { EventChannel } from './EventChannel';
import { migrateTemplateFunctionSelectOptions } from './migrations';
@@ -56,20 +64,30 @@ export class PluginInstance {
await this.#onMessage(event);
});
this.#mod = {} as any;
this.#mod = {};
const fileChangeCallback = async () => {
await this.#mod?.dispose?.();
this.#importModule();
await this.#mod?.init?.(this.#newCtx(workerData.context));
return this.#sendPayload(
workerData.context,
{
type: 'reload_response',
silent: false,
},
null,
);
const ctx = this.#newCtx(workerData.context);
try {
await this.#mod?.init?.(ctx);
this.#sendPayload(
workerData.context,
{
type: 'reload_response',
silent: false,
},
null,
);
} catch (err: unknown) {
ctx.toast.show({
message: `Failed to initialize plugin ${this.#workerData.bootRequest.dir.split('/').pop()}: ${err}`,
color: 'notice',
icon: 'alert_triangle',
timeout: 30000,
});
}
};
if (this.#workerData.bootRequest.watch) {
@@ -120,8 +138,7 @@ export class PluginInstance {
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: 'import_response',
// deno-lint-ignore no-explicit-any
resources: reply.resources as any,
resources: reply.resources as ImportResources,
};
this.#sendPayload(context, replyPayload, replyId);
return;
@@ -262,7 +279,7 @@ export class PluginInstance {
payload.type === 'get_template_function_config_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
let templateFunction = this.#mod.templateFunctions.find((f) => f.name === payload.name);
const templateFunction = this.#mod.templateFunctions.find((f) => f.name === payload.name);
if (templateFunction == null) {
this.#sendEmpty(context, replyId);
return;
@@ -381,10 +398,7 @@ export class PluginInstance {
}
}
if (
payload.type === 'call_folder_action_request' &&
Array.isArray(this.#mod.folderActions)
) {
if (payload.type === 'call_folder_action_request' && Array.isArray(this.#mod.folderActions)) {
const action = this.#mod.folderActions[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
@@ -632,6 +646,12 @@ export class PluginInstance {
},
};
},
openExternalUrl: async (url) => {
await this.#sendForReply(context, {
type: 'open_external_url_request',
url,
});
},
},
prompt: {
text: async (args) => {
@@ -703,12 +723,15 @@ export class PluginInstance {
return httpRequest;
},
list: async (args?: { folderId?: string }) => {
const payload = {
const payload: InternalEventPayload = {
type: 'list_http_requests_request',
folderId: args?.folderId,
} as any;
const { httpRequests } = await this.#sendForReply<any>(context, payload);
return httpRequests as any[];
} satisfies ListHttpRequestsRequest & { type: 'list_http_requests_request' };
const { httpRequests } = await this.#sendForReply<ListHttpRequestsResponse>(
context,
payload,
);
return httpRequests;
},
create: async (args) => {
const payload = {
@@ -747,11 +770,9 @@ export class PluginInstance {
},
folder: {
list: async () => {
const payload = {
type: 'list_folders_request',
} as any;
const { folders } = await this.#sendForReply<any>(context, payload);
return folders as any[];
const payload = { type: 'list_folders_request' } as const;
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders;
},
},
cookies: {
@@ -774,9 +795,10 @@ export class PluginInstance {
* Invoke Yaak's template engine to render a value. If the value is a nested type
* (eg. object), it will be recursively rendered.
*/
render: async (args) => {
render: async (args: TemplateRenderRequest) => {
const payload = { type: 'template_render_request', ...args } as const;
const result = await this.#sendForReply<TemplateRenderResponse>(context, payload);
// biome-ignore lint/suspicious/noExplicitAny: That's okay
return result.data as any;
},
},
@@ -809,15 +831,19 @@ export class PluginInstance {
workspace: {
list: async () => {
const payload = {
type: 'list_workspaces_request'
type: 'list_workspaces_request',
} as InternalEventPayload;
const response = await this.#sendForReply<ListWorkspacesResponse>(context, payload);
return response.workspaces.map((w) => ({
id: w.id,
name: w.name,
// Hide label from plugin authors, but keep it for internal routing
_label: (w as any).label as string,
}));
return response.workspaces.map((w) => {
// Internal workspace info includes label field not in public API
type WorkspaceInfoInternal = typeof w & { label?: string };
return {
id: w.id,
name: w.name,
// Hide label from plugin authors, but keep it for internal routing
_label: (w as WorkspaceInfoInternal).label as string,
};
});
},
withContext: (workspaceHandle: { id: string; name: string; _label?: string }) => {
// Create a new context with the workspace's window label

View File

@@ -1,5 +1,8 @@
import { CallHttpAuthenticationActionArgs, CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api';
import type { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api';
import type {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
} from '@yaakapp-internal/plugins';
export async function applyDynamicFormInput(
ctx: Context,
@@ -18,15 +21,28 @@ export async function applyDynamicFormInput(
args: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[],
callArgs: CallTemplateFunctionArgs | CallHttpAuthenticationActionArgs,
): Promise<(DynamicTemplateFunctionArg | DynamicAuthenticationArg)[]> {
const resolvedArgs: any[] = [];
const resolvedArgs: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[] = [];
for (const { dynamic, ...arg } of args) {
const newArg: any = {
const dynamicResult =
typeof dynamic === 'function'
? await dynamic(
ctx,
callArgs as CallTemplateFunctionArgs & CallHttpAuthenticationActionArgs,
)
: undefined;
const newArg = {
...arg,
...(typeof dynamic === 'function' ? await dynamic(ctx, callArgs as any) : undefined),
};
...dynamicResult,
} as DynamicTemplateFunctionArg | DynamicAuthenticationArg;
if ('inputs' in newArg && Array.isArray(newArg.inputs)) {
try {
newArg.inputs = await applyDynamicFormInput(ctx, newArg.inputs, callArgs as any);
newArg.inputs = await applyDynamicFormInput(
ctx,
newArg.inputs as DynamicTemplateFunctionArg[],
callArgs as CallTemplateFunctionArgs & CallHttpAuthenticationActionArgs,
);
} catch (e) {
console.error('Failed to apply dynamic form input', e);
}

View File

@@ -1,16 +1,16 @@
import type { InternalEvent } from '@yaakapp/api';
import WebSocket from 'ws';
import { EventChannel } from './EventChannel';
import { PluginHandle } from './PluginHandle';
import WebSocket from 'ws';
const port = process.env.PORT;
if (!port) {
throw new Error('Plugin runtime missing PORT')
throw new Error('Plugin runtime missing PORT');
}
const host = process.env.HOST;
if (!host) {
throw new Error('Plugin runtime missing HOST')
throw new Error('Plugin runtime missing HOST');
}
const pluginToAppEvents = new EventChannel();
@@ -26,7 +26,7 @@ ws.on('message', async (e: Buffer) => {
}
});
ws.on('open', () => console.log('Plugin runtime connected to websocket'));
ws.on('error', (err: any) => console.error('Plugin runtime websocket error', err));
ws.on('error', (err: unknown) => console.error('Plugin runtime websocket error', err));
ws.on('close', (code: number) => console.log('Plugin runtime websocket closed', code));
// Listen for incoming events from plugins
@@ -39,7 +39,12 @@ async function handleIncoming(msg: string) {
const pluginEvent: InternalEvent = JSON.parse(msg);
// Handle special event to bootstrap plugin
if (pluginEvent.payload.type === 'boot_request') {
const plugin = new PluginHandle(pluginEvent.pluginRefId, pluginEvent.context, pluginEvent.payload, pluginToAppEvents);
const plugin = new PluginHandle(
pluginEvent.pluginRefId,
pluginEvent.context,
pluginEvent.payload,
pluginToAppEvents,
);
plugins[pluginEvent.pluginRefId] = plugin;
}
@@ -62,3 +67,7 @@ async function handleIncoming(msg: string) {
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
});

View File

@@ -1,28 +1,20 @@
import process from "node:process";
import process from 'node:process';
export function interceptStdout(
intercept: (text: string) => string,
) {
export function interceptStdout(intercept: (text: string) => string) {
const old_stdout_write = process.stdout.write;
const old_stderr_write = process.stderr.write;
process.stdout.write = (function (write) {
return function (text: string) {
arguments[0] = interceptor(text, intercept);
// deno-lint-ignore no-explicit-any
write.apply(process.stdout, arguments as any);
process.stdout.write = ((write) =>
((text: string, ...args: never[]) => {
write.call(process.stdout, interceptor(text, intercept), ...args);
return true;
};
})(process.stdout.write);
}) as typeof process.stdout.write)(process.stdout.write);
process.stderr.write = (function (write) {
return function (text: string) {
arguments[0] = interceptor(text, intercept);
// deno-lint-ignore no-explicit-any
write.apply(process.stderr, arguments as any);
process.stderr.write = ((write) =>
((text: string, ...args: never[]) => {
write.call(process.stderr, interceptor(text, intercept), ...args);
return true;
};
})(process.stderr.write);
}) as typeof process.stderr.write)(process.stderr.write);
// puts back to original
return function unhook() {
@@ -32,6 +24,5 @@ export function interceptStdout(
}
function interceptor(text: string, fn: (text: string) => string) {
return fn(text).replace(/\n$/, "") +
(fn(text) && /\n$/.test(text) ? "\n" : "");
return fn(text).replace(/\n$/, '') + (fn(text) && /\n$/.test(text) ? '\n' : '');
}

View File

@@ -5,10 +5,15 @@ export function migrateTemplateFunctionSelectOptions(
): TemplateFunctionPlugin {
const migratedArgs = f.args.map((a) => {
if (a.type === 'select') {
a.options = a.options.map((o) => ({
...o,
label: o.label || (o as any).name,
}));
// Migrate old options that had 'name' instead of 'label'
type LegacyOption = { label?: string; value: string; name?: string };
a.options = a.options.map((o) => {
const legacy = o as LegacyOption;
return {
label: legacy.label ?? legacy.name ?? '',
value: legacy.value,
};
});
}
return a;
});

View File

@@ -1,6 +1,6 @@
import { applyFormInputDefaults } from '@yaakapp-internal/lib/templateFunction';
import { CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import { Context, DynamicTemplateFunctionArg } from '@yaakapp/api';
import type { CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import type { Context, DynamicTemplateFunctionArg } from '@yaakapp/api';
import { describe, expect, test } from 'vitest';
import { applyDynamicFormInput } from '../src/common';

View File

@@ -13,13 +13,8 @@
"outDir": "build",
"baseUrl": ".",
"paths": {
"*": [
"node_modules/*",
"src/types/*"
]
"*": ["node_modules/*", "src/types/*"]
}
},
"include": [
"src"
]
"include": ["src"]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@yaak/faker",
"private": true,
"version": "0.1.0",
"version": "1.1.1",
"displayName": "Faker",
"description": "Template functions for generating fake data using FakerJS",
"repository": {
@@ -11,7 +11,8 @@
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
"dev": "yaakcli dev",
"test": "vitest --run tests"
},
"dependencies": {
"@faker-js/faker": "^10.1.0"

View File

@@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker';
import type { PluginDefinition, TemplateFunctionArg } from '@yaakapp/api';
import type { DynamicTemplateFunctionArg, PluginDefinition } from '@yaakapp/api';
const modules = [
'airline',
@@ -33,7 +33,7 @@ function normalizeResult(result: unknown): string {
}
// Whatever Yaaks arg type shape is rough example
function args(modName: string, fnName: string): TemplateFunctionArg[] {
function args(modName: string, fnName: string): DynamicTemplateFunctionArg[] {
return [
{
type: 'banner',
@@ -58,15 +58,14 @@ function args(modName: string, fnName: string): TemplateFunctionArg[] {
export const plugin: PluginDefinition = {
templateFunctions: modules.flatMap((modName) => {
// @ts-expect-error - Dynamic access to faker modules
const mod = faker[modName];
const mod = faker[modName as keyof typeof faker];
return Object.keys(mod)
.filter((n) => n !== 'faker')
.map((fnName) => ({
name: ['faker', modName, fnName].join('.'),
args: args(modName, fnName),
async onRender(_ctx, args) {
const fn = mod[fnName] as (...a: unknown[]) => unknown;
const fn = mod[fnName as keyof typeof mod] as (...a: unknown[]) => unknown;
const options = args.values.options;
// No options supplied

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
describe('formatDatetime', () => {
it('returns formatted current date', async () => {
// Ensure the plugin imports properly
const faker = await import('../src/index');
expect(faker.plugin.templateFunctions?.length).toBe(226);
});
});

View File

@@ -1,54 +1,35 @@
# Yaak MCP Server Plugin
A Yaak plugin that exposes Yaak's functionality via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), allowing AI assistants and other tools to interact with Yaak programmatically.
Exposes Yaak's functionality via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/).
## Features
## Setup
This plugin starts an MCP server on `http://127.0.0.1:64343/mcp` that provides tools for:
Add this to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json
{
"mcpServers": {
"yaak": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://127.0.0.1:64343/mcp"]
}
}
}
```
Restart Claude Desktop and make sure Yaak is running.
## Available Tools
### HTTP Requests
- `list_http_requests` - List all HTTP requests in a workspace
- `get_http_request` - Get details of a specific HTTP request
- `send_http_request` - Send an HTTP request and get the response
- `create_http_request` - Create a new HTTP request
- `update_http_request` - Update an existing HTTP request
- `delete_http_request` - Delete an HTTP request
### Folders
- `list_folders` - List all folders in a workspace
### Workspaces
- `list_workspaces` - List all open workspaces in Yaak
### Clipboard
- `copy_to_clipboard` - Copy text to the system clipboard
### Window
- `list_workspaces` - List all open workspaces
- `get_workspace_id` - Get the current workspace ID
- `get_environment_id` - Get the current environment ID
### Toast Notifications
- `copy_to_clipboard` - Copy text to the system clipboard
- `show_toast` - Show a toast notification in Yaak
## Usage
Once the plugin is installed and Yaak is running, the MCP server will be available at:
```
http://127.0.0.1:64343/mcp
```
Configure your MCP client to connect to this endpoint to start interacting with Yaak.
## Development
```bash
# Install dependencies
npm install
# Build the plugin
npm run build
# Development mode with auto-rebuild
npm run dev
```

View File

@@ -1,7 +1,7 @@
{
"name": "@yaak/mcp-server",
"private": true,
"version": "0.1.0",
"version": "0.1.7",
"displayName": "MCP Server",
"description": "Expose Yaak functionality via Model Context Protocol",
"minYaakVersion": "2025.10.0-beta.6",

View File

@@ -1,7 +1,7 @@
import type { Context, PluginDefinition } from '@yaakapp/api';
import { createMcpServer } from './server.js';
const serverPort = 64343;
const serverPort = parseInt(process.env.YAAK_PLUGIN_MCP_SERVER_PORT ?? '64343', 10);
let mcpServer: Awaited<ReturnType<typeof createMcpServer>> | null = null;
@@ -9,8 +9,19 @@ export const plugin: PluginDefinition = {
async init(ctx: Context) {
// Start the server after waiting, so there's an active window open to do things
// like show the startup toast.
setTimeout(() => {
mcpServer = createMcpServer({ yaak: ctx }, serverPort);
console.log('Initializing MCP Server plugin');
setTimeout(async () => {
try {
mcpServer = createMcpServer({ yaak: ctx }, serverPort);
} catch (err) {
console.error('Failed to start MCP server:', err);
ctx.toast.show({
message: `Failed to start MCP Server: ${err instanceof Error ? err.message : String(err)}`,
icon: 'alert_triangle',
color: 'danger',
timeout: 10000,
});
}
}, 5000);
},

View File

@@ -1,6 +1,6 @@
import { StreamableHTTPTransport } from '@hono/mcp';
import { serve } from '@hono/node-server';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { Hono } from 'hono';
import { registerFolderTools } from './tools/folder.js';
import { registerHttpRequestTools } from './tools/httpRequest.js';
@@ -10,49 +10,63 @@ import { registerWorkspaceTools } from './tools/workspace.js';
import type { McpServerContext } from './types.js';
export function createMcpServer(ctx: McpServerContext, port: number) {
const server = new McpServer({
console.log('Creating MCP server on port', port);
const mcpServer = new McpServer({
name: 'yaak-mcp-server',
version: '0.1.0',
});
// Register all tools
registerToastTools(server, ctx);
registerHttpRequestTools(server, ctx);
registerFolderTools(server, ctx);
registerWindowTools(server, ctx);
registerWorkspaceTools(server, ctx);
registerToastTools(mcpServer, ctx);
registerHttpRequestTools(mcpServer, ctx);
registerFolderTools(mcpServer, ctx);
registerWindowTools(mcpServer, ctx);
registerWorkspaceTools(mcpServer, ctx);
// Create a stateless transport
const transport = new WebStandardStreamableHTTPServerTransport();
// Create Hono app
const app = new Hono();
const transport = new StreamableHTTPTransport();
// MCP endpoint - reuse the same transport for all requests
app.all('/mcp', (c) => transport.handleRequest(c.req.raw));
// Connect server to transport
server.connect(transport).then(() => {
console.log(`MCP Server running at http://127.0.0.1:${port}/mcp`);
ctx.yaak.toast.show({
message: `MCP Server running on port ${port}`,
icon: 'check_circle',
color: 'success',
});
app.all('/mcp', async (c) => {
if (!mcpServer.isConnected()) {
// Connect the mcp with the transport
await mcpServer.connect(transport);
ctx.yaak.toast.show({
message: `MCP Server connected`,
icon: 'info',
color: 'info',
timeout: 5000,
});
}
return transport.handleRequest(c);
});
// Start the HTTP server
const honoServer = serve({
fetch: app.fetch,
port,
hostname: '127.0.0.1',
});
const honoServer = serve(
{
port,
hostname: '127.0.0.1',
fetch: app.fetch,
},
(info) => {
console.log('Started MCP server on ', info.address);
ctx.yaak.toast.show({
message: `MCP Server running on http://127.0.0.1:${info.port}`,
icon: 'info',
color: 'secondary',
timeout: 10000,
});
},
);
return {
server,
server: mcpServer,
close: async () => {
honoServer.close();
await server.close();
await new Promise<void>((resolve, reject) => {
honoServer.close((err) => {
if (err) reject(err);
else resolve();
});
});
await mcpServer.close();
},
};
}

View File

@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import * as z from 'zod';
import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js';
@@ -9,12 +9,12 @@ export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
{
title: 'List Folders',
description: 'List all folders in a workspace',
inputSchema: z.object({
inputSchema: {
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}),
},
},
async ({ workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);

View File

@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import * as z from 'zod';
import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js';
@@ -9,12 +9,12 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
{
title: 'List HTTP Requests',
description: 'List all HTTP requests in a workspace',
inputSchema: z.object({
inputSchema: {
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}),
},
},
async ({ workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
@@ -36,13 +36,13 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
{
title: 'Get HTTP Request',
description: 'Get details of a specific HTTP request by ID',
inputSchema: z.object({
inputSchema: {
id: z.string().describe('The HTTP request ID'),
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}),
},
},
async ({ id, workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
@@ -64,14 +64,14 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
{
title: 'Send HTTP Request',
description: 'Send an HTTP request and get the response',
inputSchema: z.object({
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)'),
}),
},
},
async ({ id, workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
@@ -98,7 +98,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
{
title: 'Create HTTP Request',
description: 'Create a new HTTP request',
inputSchema: z.object({
inputSchema: {
workspaceId: z
.string()
.optional()
@@ -131,7 +131,43 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
)
.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": {}',
),
},
},
async ({ workspaceId: ogWorkspaceId, ...args }) => {
const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);
@@ -156,12 +192,9 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
{
title: 'Update HTTP Request',
description: 'Update an existing HTTP request',
inputSchema: z.object({
inputSchema: {
id: z.string().describe('HTTP request ID to update'),
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
workspaceId: z.string().describe('Workspace ID'),
name: z.string().optional().describe('Request name'),
url: z.string().optional().describe('Request URL'),
method: z.string().optional().describe('HTTP method'),
@@ -187,11 +220,57 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
)
.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": {}',
),
},
},
async ({ id, workspaceId, ...updates }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
const httpRequest = await workspaceCtx.yaak.httpRequest.update({ id, ...updates });
// Fetch existing request to merge with updates
const existing = await workspaceCtx.yaak.httpRequest.getById({ id });
if (!existing) {
throw new Error(`HTTP request with ID ${id} not found`);
}
// Merge existing fields with updates
const httpRequest = await workspaceCtx.yaak.httpRequest.update({
...existing,
...updates,
id,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(httpRequest, null, 2) }],
};
@@ -203,9 +282,9 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
{
title: 'Delete HTTP Request',
description: 'Delete an HTTP request by ID',
inputSchema: z.object({
inputSchema: {
id: z.string().describe('HTTP request ID to delete'),
}),
},
},
async ({ id }) => {
const httpRequest = await ctx.yaak.httpRequest.delete({ id });

View File

@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Color, Icon } from '@yaakapp/api';
import * as z from 'zod/v4';
import * as z from 'zod';
import type { McpServerContext } from '../types.js';
const ICON_VALUES = [
@@ -31,12 +31,12 @@ export function registerToastTools(server: McpServer, ctx: McpServerContext) {
{
title: 'Show Toast',
description: 'Show a toast notification in Yaak',
inputSchema: z.object({
inputSchema: {
message: z.string().describe('The message to display'),
icon: z.enum(ICON_VALUES).optional().describe('Icon name'),
color: z.enum(COLOR_VALUES).optional().describe('Toast color'),
timeout: z.number().optional().describe('Timeout in milliseconds'),
}),
},
},
async ({ message, icon, color, timeout }) => {
await ctx.yaak.toast.show({

View File

@@ -1,5 +1,4 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js';
@@ -9,7 +8,6 @@ export function registerWindowTools(server: McpServer, ctx: McpServerContext) {
{
title: 'Get Workspace ID',
description: 'Get the current workspace ID',
inputSchema: z.object({}),
},
async () => {
const workspaceCtx = await getWorkspaceContext(ctx);
@@ -31,7 +29,6 @@ export function registerWindowTools(server: McpServer, ctx: McpServerContext) {
{
title: 'Get Environment ID',
description: 'Get the current environment ID',
inputSchema: z.object({}),
},
async () => {
const workspaceCtx = await getWorkspaceContext(ctx);

View File

@@ -1,5 +1,4 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import type { McpServerContext } from '../types.js';
export function registerWorkspaceTools(server: McpServer, ctx: McpServerContext) {
@@ -8,7 +7,6 @@ export function registerWorkspaceTools(server: McpServer, ctx: McpServerContext)
{
title: 'List Workspaces',
description: 'List all open workspaces in Yaak',
inputSchema: z.object({}),
},
async () => {
const workspaces = await ctx.yaak.workspace.list();

View File

@@ -1,3 +1,7 @@
{
"extends": "../../tsconfig.json"
"extends": "../../tsconfig.json",
"compilerOptions": {
"skipLibCheck": true,
"moduleResolution": "Bundler"
}
}

View File

@@ -0,0 +1,16 @@
{
"name": "@yaak/action-send-folder",
"displayName": "Send All",
"description": "Send all HTTP requests in a folder sequentially",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/action-send-folder"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
}
}

View File

@@ -0,0 +1,74 @@
import type { PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
folderActions: [
{
label: 'Send All',
icon: 'send_horizontal',
async onSelect(ctx, args) {
const targetFolder = args.folder;
// Get all folders and HTTP requests
const [allFolders, allRequests] = await Promise.all([
ctx.folder.list(),
ctx.httpRequest.list(),
]);
// Build a set of all folder IDs that are descendants of the target folder
const folderIds = new Set<string>([targetFolder.id]);
const addDescendants = (parentId: string) => {
for (const folder of allFolders) {
if (folder.folderId === parentId && !folderIds.has(folder.id)) {
folderIds.add(folder.id);
addDescendants(folder.id);
}
}
};
addDescendants(targetFolder.id);
// Filter HTTP requests to those in the target folder or its descendants
const requestsToSend = allRequests.filter(
(req) => req.folderId != null && folderIds.has(req.folderId),
);
if (requestsToSend.length === 0) {
await ctx.toast.show({
message: 'No requests in folder',
icon: 'info',
color: 'info',
});
return;
}
// Send each request sequentially
let successCount = 0;
let errorCount = 0;
for (const request of requestsToSend) {
try {
await ctx.httpRequest.send({ httpRequest: request });
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to send request ${request.id}:`, error);
}
}
// Show summary toast
if (errorCount === 0) {
await ctx.toast.show({
message: `Sent ${successCount} request${successCount !== 1 ? 's' : ''}`,
icon: 'send_horizontal',
color: 'success',
});
} else {
await ctx.toast.show({
message: `Sent ${successCount}, failed ${errorCount}`,
icon: 'alert_triangle',
color: 'warning',
});
}
},
},
],
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -1,17 +1,28 @@
import type { PluginDefinition } from '@yaakapp/api';
// Yaak themes
import { highContrast, highContrastDark } from './themes/high-contrast';
import { andromeda } from './themes/andromeda';
import { atomOneDark } from './themes/atom-one-dark';
import { ayuDark, ayuLight, ayuMirage } from './themes/ayu';
import { blulocoDark, blulocoLight } from './themes/bluloco';
import {
catppuccinFrappe,
catppuccinLatte,
catppuccinMacchiato,
catppuccinMocha,
catppuccinLatte,
} from './themes/catppuccin';
import { cobalt2 } from './themes/cobalt2';
import { dracula } from './themes/dracula';
import { everforestDark, everforestLight } from './themes/everforest';
import { fleetDark, fleetDarkPurple, fleetLight } from './themes/fleet';
import { githubDark, githubLight } from './themes/github';
import { githubDarkDimmed } from './themes/github-dimmed';
import { gruvbox } from './themes/gruvbox';
// Yaak themes
import { highContrast, highContrastDark } from './themes/high-contrast';
import { horizon } from './themes/horizon';
import { hotdogStand } from './themes/hotdog-stand';
import { materialDarker } from './themes/material-darker';
import { materialOcean } from './themes/material-ocean';
import { materialPalenight } from './themes/material-palenight';
import {
monokaiPro,
monokaiProClassic,
@@ -21,35 +32,22 @@ import {
monokaiProSpectrum,
} from './themes/monokai-pro';
import { moonlight } from './themes/moonlight';
import { nord } from './themes/nord';
import { relaxing } from './themes/relaxing';
import { rosePine, rosePineMoon, rosePineDawn } from './themes/rose-pine';
import { triangle } from './themes/triangle';
import { lightOwl, nightOwl } from './themes/night-owl';
import { noctisAzureus } from './themes/noctis';
import { nord, nordLight, nordLightBrighter } from './themes/nord';
// VSCode themes
import { oneDarkPro } from './themes/one-dark-pro';
import { materialPalenight } from './themes/material-palenight';
import { materialOcean } from './themes/material-ocean';
import { materialDarker } from './themes/material-darker';
import { nightOwl, lightOwl } from './themes/night-owl';
import { tokyoNight, tokyoNightStorm, tokyoNightDay } from './themes/tokyo-night';
import { solarizedDark, solarizedLight } from './themes/solarized';
import { ayuDark, ayuMirage, ayuLight } from './themes/ayu';
import { synthwave84 } from './themes/synthwave-84';
import { shadesOfPurple, shadesOfPurpleSuperDark } from './themes/shades-of-purple';
import { cobalt2 } from './themes/cobalt2';
import { horizon } from './themes/horizon';
import { pandaSyntax } from './themes/panda';
import { andromeda } from './themes/andromeda';
import { winterIsComing } from './themes/winter-is-coming';
import { atomOneDark } from './themes/atom-one-dark';
import { vitesseDark, vitesseLight } from './themes/vitesse';
import { everforestDark, everforestLight } from './themes/everforest';
import { githubDarkDimmed } from './themes/github-dimmed';
import { relaxing } from './themes/relaxing';
import { rosePine, rosePineDawn, rosePineMoon } from './themes/rose-pine';
import { shadesOfPurple, shadesOfPurpleSuperDark } from './themes/shades-of-purple';
import { slackAubergine } from './themes/slack';
import { noctisAzureus } from './themes/noctis';
import { blulocoDark, blulocoLight } from './themes/bluloco';
import { fleetLight, fleetDarkPurple, fleetDark } from './themes/fleet';
import { solarizedDark, solarizedLight } from './themes/solarized';
import { synthwave84 } from './themes/synthwave-84';
import { tokyoNight, tokyoNightDay, tokyoNightStorm } from './themes/tokyo-night';
import { triangle } from './themes/triangle';
import { vitesseDark, vitesseLight } from './themes/vitesse';
import { winterIsComing } from './themes/winter-is-coming';
export const plugin: PluginDefinition = {
themes: [
@@ -93,6 +91,8 @@ export const plugin: PluginDefinition = {
nightOwl,
noctisAzureus,
nord,
nordLight,
nordLightBrighter,
oneDarkPro,
pandaSyntax,
relaxing,

View File

@@ -20,10 +20,66 @@ export const nord: Theme = {
},
components: {
sidebar: {
backdrop: 'hsl(220,16%,22%)',
surface: 'hsl(220,16%,22%)',
},
appHeader: {
backdrop: 'hsl(220,14%,28%)',
surface: 'hsl(220,14%,28%)',
},
},
};
export const nordLight: Theme = {
id: 'nord-light',
label: 'Nord Light',
dark: false,
base: {
surface: '#eceff4',
surfaceHighlight: '#e5e9f0',
text: '#24292e',
textSubtle: '#444d56',
textSubtlest: '#586069',
primary: '#2188ff',
secondary: '#586069',
info: '#005cc5',
success: '#28a745',
notice: '#e36209',
warning: '#e36209',
danger: '#cb2431',
},
components: {
sidebar: {
surface: '#e5e9f0',
},
appHeader: {
surface: '#e5e9f0',
},
},
};
export const nordLightBrighter: Theme = {
id: 'nord-light-brighter',
label: 'Nord Light Brighter',
dark: false,
base: {
surface: '#ffffff',
surfaceHighlight: '#f6f8fa',
text: '#24292e',
textSubtle: '#444d56',
textSubtlest: '#586069',
primary: '#2188ff',
secondary: '#586069',
info: '#005cc5',
success: '#28a745',
notice: '#e36209',
warning: '#e36209',
danger: '#cb2431',
},
components: {
sidebar: {
surface: '#f6f8fa',
},
appHeader: {
surface: '#f6f8fa',
},
},
};

View File

@@ -10,9 +10,9 @@ export const synthwave84: Theme = {
text: 'hsl(300, 50%, 90%)',
textSubtle: 'hsl(280, 25%, 65%)',
textSubtlest: 'hsl(280, 20%, 50%)',
primary: 'hsl(177, 100%, 55%)',
primary: 'hsl(320, 100%, 75%)',
secondary: 'hsl(280, 20%, 60%)',
info: 'hsl(320, 100%, 75%)',
info: 'hsl(177, 100%, 55%)',
success: 'hsl(83, 100%, 60%)',
notice: 'hsl(57, 100%, 60%)',
warning: 'hsl(30, 100%, 60%)',
@@ -35,9 +35,9 @@ export const synthwave84: Theme = {
border: 'hsl(253, 40%, 22%)',
},
button: {
primary: 'hsl(177, 100%, 48%)',
primary: 'hsl(320, 100%, 68%)',
secondary: 'hsl(280, 20%, 53%)',
info: 'hsl(320, 100%, 68%)',
info: 'hsl(177, 100%, 48%)',
success: 'hsl(83, 100%, 53%)',
notice: 'hsl(57, 100%, 53%)',
warning: 'hsl(30, 100%, 53%)',

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env node
/**
* Git post-checkout hook for auto-configuring worktree environments.
* This runs after 'git checkout' or 'git worktree add'.
*
* Args from git:
* process.argv[2] - previous HEAD ref
* process.argv[3] - new HEAD ref
* process.argv[4] - flag (1 = branch checkout, 0 = file checkout)
*/
import fs from 'fs';
import path from 'path';
import { execSync, execFileSync } from 'child_process';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isBranchCheckout = process.argv[4] === '1';
if (!isBranchCheckout) {
process.exit(0);
}
// Check if we're in a worktree by looking for .git file (not directory)
const gitPath = path.join(process.cwd(), '.git');
const isWorktree = fs.existsSync(gitPath) && fs.statSync(gitPath).isFile();
if (!isWorktree) {
process.exit(0);
}
const envLocalPath = path.join(process.cwd(), '.env.local');
// Don't overwrite existing .env.local
if (fs.existsSync(envLocalPath)) {
process.exit(0);
}
console.log('Detected new worktree - configuring ports in .env.local');
// Find the highest ports in use across all worktrees
// Main worktree (first in list) is assumed to use default ports 1420/64343
let maxMcpPort = 64343;
let maxDevPort = 1420;
try {
const worktreeList = execSync('git worktree list --porcelain', { encoding: 'utf8' });
const worktreePaths = worktreeList
.split('\n')
.filter(line => line.startsWith('worktree '))
.map(line => line.replace('worktree ', '').trim());
// Skip the first worktree (main) since it uses default ports
for (let i = 1; i < worktreePaths.length; i++) {
const worktreePath = worktreePaths[i];
const envPath = path.join(worktreePath, '.env.local');
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, 'utf8');
const mcpMatch = content.match(/^YAAK_PLUGIN_MCP_SERVER_PORT=(\d+)/m);
if (mcpMatch) {
const port = parseInt(mcpMatch[1], 10);
if (port > maxMcpPort) {
maxMcpPort = port;
}
}
const devMatch = content.match(/^YAAK_DEV_PORT=(\d+)/m);
if (devMatch) {
const port = parseInt(devMatch[1], 10);
if (port > maxDevPort) {
maxDevPort = port;
}
}
}
}
// Increment to get the next available port
maxDevPort++;
maxMcpPort++;
} catch (err) {
console.error('Warning: Could not check other worktrees for port conflicts:', err.message);
// Continue with default ports
}
// Get worktree name from current directory
const worktreeName = path.basename(process.cwd());
// Create .env.local with unique ports
const envContent = `# Auto-generated by git post-checkout hook
# This file configures ports for this worktree to avoid conflicts
# Vite dev server port (main worktree uses 1420)
YAAK_DEV_PORT=${maxDevPort}
# MCP Server port (main worktree uses 64343)
YAAK_PLUGIN_MCP_SERVER_PORT=${maxMcpPort}
# Database path prefix for worktree isolation
YAAK_DB_PATH_PREFIX=worktrees/${worktreeName}
`;
fs.writeFileSync(envLocalPath, envContent, 'utf8');
console.log(`Created .env.local with YAAK_DEV_PORT=${maxDevPort} and YAAK_PLUGIN_MCP_SERVER_PORT=${maxMcpPort}`);
// Copy gitignored editor config folders from main worktree (.zed, .vscode, .claude, etc.)
// This ensures your editor settings, tasks, and configurations are available in the new worktree
// without needing to manually copy them or commit them to git.
try {
const worktreeList = execSync('git worktree list --porcelain', { encoding: 'utf8' });
const mainWorktreePath = worktreeList
.split('\n')
.find(line => line.startsWith('worktree '))
?.replace('worktree ', '')
.trim();
if (mainWorktreePath) {
// Find all .* folders in main worktree root that are gitignored
const entries = fs.readdirSync(mainWorktreePath, { withFileTypes: true });
const dotFolders = entries
.filter(entry => entry.isDirectory() && entry.name.startsWith('.'))
.map(entry => entry.name);
for (const folder of dotFolders) {
const sourcePath = path.join(mainWorktreePath, folder);
const destPath = path.join(process.cwd(), folder);
try {
// Check if it's gitignored - run from main worktree directory
execFileSync('git', ['check-ignore', '-q', folder], {
stdio: 'pipe',
cwd: mainWorktreePath
});
// It's gitignored, copy it
fs.cpSync(sourcePath, destPath, { recursive: true });
console.log(`Copied ${folder} from main worktree`);
} catch {
// Not gitignored or doesn't exist, skip
}
}
}
} catch (err) {
console.warn('Warning: Could not copy files from main worktree:', err.message);
// Continue anyway
}
// Run npm run init to install dependencies and bootstrap
console.log('\nRunning npm run init to install dependencies and bootstrap...');
try {
execSync('npm run init', { stdio: 'inherit' });
console.log('\n✓ Worktree setup complete!');
} catch (err) {
console.error('\n✗ Failed to run npm run init. You may need to run it manually.');
process.exit(1);
}

49
scripts/run-dev.mjs Normal file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Script to run Tauri dev server with dynamic port configuration.
* Loads port from .env.local if present, otherwise uses default port 1420.
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load .env.local if it exists
const envLocalPath = path.join(__dirname, '..', '.env.local');
if (fs.existsSync(envLocalPath)) {
const envContent = fs.readFileSync(envLocalPath, 'utf8');
const envVars = envContent
.split('\n')
.filter(line => line && !line.startsWith('#'))
.reduce((acc, line) => {
const [key, value] = line.split('=');
if (key && value) {
acc[key.trim()] = value.trim();
}
return acc;
}, {});
Object.assign(process.env, envVars);
}
const port = process.env.YAAK_DEV_PORT || '1420';
const config = JSON.stringify({ build: { devUrl: `http://localhost:${port}` } });
// Get additional arguments passed after npm run app-dev --
const additionalArgs = process.argv.slice(2);
const args = [
'dev',
'--no-watch',
'--config', './src-tauri/tauri.development.conf.json',
'--config', config,
...additionalArgs
];
const result = spawnSync('tauri', args, { stdio: 'inherit', shell: false, env: process.env });
process.exit(result.status || 0);

View File

@@ -1,5 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };

View File

@@ -41,6 +41,9 @@ pub enum Error {
#[error(transparent)]
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
#[error(transparent)]
OpenerError(#[from] tauri_plugin_opener::Error),
#[error("Updater error: {0}")]
UpdaterError(#[from] tauri_plugin_updater::Error),

View File

@@ -18,6 +18,7 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use std::{fs, panic};
use tauri::path::BaseDirectory;
use tauri::{AppHandle, Emitter, RunEvent, State, WebviewWindow, is_dev};
use tauri::{Listener, Runtime};
use tauri::{Manager, WindowEvent};
@@ -82,6 +83,7 @@ struct AppMetaData {
name: String,
app_data_dir: String,
app_log_dir: String,
vendored_plugin_dir: String,
feature_updater: bool,
feature_license: bool,
}
@@ -90,12 +92,15 @@ struct AppMetaData {
async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
let app_data_dir = app_handle.path().app_data_dir()?;
let app_log_dir = app_handle.path().app_log_dir()?;
let vendored_plugin_dir =
app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?;
Ok(AppMetaData {
is_dev: is_dev(),
version: app_handle.package_info().version.to_string(),
name: app_handle.package_info().name.to_string(),
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(),
feature_license: cfg!(feature = "license"),
feature_updater: cfg!(feature = "updater"),
})
@@ -1276,12 +1281,14 @@ async fn cmd_install_plugin<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<Plugin> {
plugin_manager.add_plugin_by_dir(&PluginContext::new(&window), &directory).await?;
Ok(app_handle.db().upsert_plugin(
&Plugin { directory: directory.into(), url, ..Default::default() },
let plugin = app_handle.db().upsert_plugin(
&Plugin { directory: directory.into(), url, enabled: true, ..Default::default() },
&UpdateSource::from_window(&window),
)?)
)?;
plugin_manager.add_plugin(&PluginContext::new(&window), &plugin).await?;
Ok(plugin)
}
#[tauri::command]
@@ -1410,43 +1417,48 @@ async fn cmd_check_for_updates<R: Runtime>(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
.plugin(
Builder::default()
.targets([
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir { file_name: None }),
Target::new(TargetKind::Webview),
])
.level_for("plugin_runtime", log::LevelFilter::Info)
.level_for("cookie_store", log::LevelFilter::Info)
.level_for("eventsource_client::event_parser", log::LevelFilter::Info)
.level_for("h2", log::LevelFilter::Info)
.level_for("hyper", log::LevelFilter::Info)
.level_for("hyper_util", log::LevelFilter::Info)
.level_for("hyper_rustls", log::LevelFilter::Info)
.level_for("reqwest", log::LevelFilter::Info)
.level_for("sqlx", log::LevelFilter::Debug)
.level_for("tao", log::LevelFilter::Info)
.level_for("tokio_util", log::LevelFilter::Info)
.level_for("tonic", log::LevelFilter::Info)
.level_for("tower", log::LevelFilter::Info)
.level_for("tracing", log::LevelFilter::Warn)
.level_for("swc_ecma_codegen", log::LevelFilter::Off)
.level_for("swc_ecma_transforms_base", log::LevelFilter::Off)
.with_colors(ColoredLevelConfig::default())
.level(if is_dev() { log::LevelFilter::Debug } else { log::LevelFilter::Info })
.build(),
)
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
let mut builder = tauri::Builder::default().plugin(
Builder::default()
.targets([
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir { file_name: None }),
Target::new(TargetKind::Webview),
])
.level_for("plugin_runtime", log::LevelFilter::Info)
.level_for("cookie_store", log::LevelFilter::Info)
.level_for("eventsource_client::event_parser", log::LevelFilter::Info)
.level_for("h2", log::LevelFilter::Info)
.level_for("hyper", log::LevelFilter::Info)
.level_for("hyper_util", log::LevelFilter::Info)
.level_for("hyper_rustls", log::LevelFilter::Info)
.level_for("reqwest", log::LevelFilter::Info)
.level_for("sqlx", log::LevelFilter::Debug)
.level_for("tao", log::LevelFilter::Info)
.level_for("tokio_util", log::LevelFilter::Info)
.level_for("tonic", log::LevelFilter::Info)
.level_for("tower", log::LevelFilter::Info)
.level_for("tracing", log::LevelFilter::Warn)
.level_for("swc_ecma_codegen", log::LevelFilter::Off)
.level_for("swc_ecma_transforms_base", log::LevelFilter::Off)
.with_colors(ColoredLevelConfig::default())
.level(if is_dev() { log::LevelFilter::Debug } else { log::LevelFilter::Info })
.build(),
);
// Only enable single-instance in production builds. In dev mode, we want to allow
// multiple instances for testing and worktree workflows (running multiple branches).
if !is_dev() {
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// When trying to open a new app instance (common operation on Linux),
// focus the first existing window we find instead of opening a new one
// TODO: Keep track of the last focused window and always focus that one
if let Some(window) = app.webview_windows().values().next() {
let _ = window.set_focus();
}
}))
}));
}
builder = builder
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init())
// Don't restore StateFlags::DECORATIONS because we want to be able to toggle them on/off on a restart

View File

@@ -11,6 +11,7 @@ use cookie::Cookie;
use log::error;
use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt;
use yaak_common::window::WorkspaceWindowTrait;
use yaak_models::blob_manager::BlobManagerExt;
use yaak_models::models::{AnyModel, HttpResponse, Plugin};
@@ -21,10 +22,10 @@ use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::events::{
Color, DeleteKeyValueResponse, EmptyPayload, ErrorResponse, FindHttpResponsesResponse,
GetCookieValueResponse, GetHttpRequestByIdResponse, GetKeyValueResponse, Icon, InternalEvent,
InternalEventPayload, ListCookieNamesResponse, ListHttpRequestsResponse, ListWorkspacesResponse,
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
SetKeyValueResponse, ShowToastRequest, TemplateRenderResponse, WindowInfoResponse,
WindowNavigateEvent, WorkspaceInfo,
InternalEventPayload, ListCookieNamesResponse, ListHttpRequestsResponse,
ListWorkspacesResponse, RenderGrpcRequestResponse, RenderHttpRequestResponse,
SendHttpRequestResponse, SetKeyValueResponse, ShowToastRequest, TemplateRenderResponse,
WindowInfoResponse, WindowNavigateEvent, WorkspaceInfo,
};
use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
@@ -107,7 +108,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
Workspace(app_handle.db().upsert_workspace(m, &UpdateSource::Plugin)?)
}
_ => {
return Err(PluginErr("Upsert not supported for this model type".into()).into())
return Err(PluginErr("Upsert not supported for this model type".into()).into());
}
};
@@ -118,14 +119,10 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
InternalEventPayload::DeleteModelRequest(req) => {
let model = match req.model.as_str() {
"http_request" => AnyModel::HttpRequest(
app_handle
.db()
.delete_http_request_by_id(&req.id, &UpdateSource::Plugin)?,
app_handle.db().delete_http_request_by_id(&req.id, &UpdateSource::Plugin)?,
),
"grpc_request" => AnyModel::GrpcRequest(
app_handle
.db()
.delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)?,
app_handle.db().delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)?,
),
"websocket_request" => AnyModel::WebsocketRequest(
app_handle
@@ -133,17 +130,13 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
.delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)?,
),
"folder" => AnyModel::Folder(
app_handle
.db()
.delete_folder_by_id(&req.id, &UpdateSource::Plugin)?,
app_handle.db().delete_folder_by_id(&req.id, &UpdateSource::Plugin)?,
),
"environment" => AnyModel::Environment(
app_handle
.db()
.delete_environment_by_id(&req.id, &UpdateSource::Plugin)?,
app_handle.db().delete_environment_by_id(&req.id, &UpdateSource::Plugin)?,
),
_ => {
return Err(PluginErr("Delete not supported for this model type".into()).into())
return Err(PluginErr("Delete not supported for this model type".into()).into());
}
};
@@ -378,6 +371,10 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
}
Ok(None)
}
InternalEventPayload::OpenExternalUrlRequest(req) => {
app_handle.opener().open_url(&req.url, None::<&str>)?;
Ok(Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {})))
}
InternalEventPayload::SetKeyValueRequest(req) => {
let name = plugin_handle.info().name;
app_handle.db().set_plugin_key_value(&name, &req.key, &req.value);

View File

@@ -33,26 +33,48 @@ pub(crate) fn git_push(dir: &Path) -> Result<PushResult> {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = stdout + stderr;
let combined_lower = combined.to_lowercase();
info!("Pushed to repo status={} {combined}", out.status);
if combined.to_lowercase().contains("could not read") {
return Ok(PushResult::NeedsCredentials { url: remote_url.to_string(), error: None });
}
if combined.to_lowercase().contains("unable to access") {
return Ok(PushResult::NeedsCredentials {
url: remote_url.to_string(),
error: Some(combined.to_string()),
});
}
if combined.to_lowercase().contains("up-to-date") {
return Ok(PushResult::UpToDate);
// Helper to check if this is a credentials error
let is_credentials_error = || {
combined_lower.contains("could not read")
|| combined_lower.contains("unable to access")
|| combined_lower.contains("authentication failed")
};
// Check for explicit rejection indicators first (e.g., protected branch rejections)
// These can occur even if some git servers don't properly set exit codes
if combined_lower.contains("rejected") || combined_lower.contains("failed to push") {
if is_credentials_error() {
return Ok(PushResult::NeedsCredentials {
url: remote_url.to_string(),
error: Some(combined.to_string()),
});
}
return Err(GenericError(format!("Failed to push: {combined}")));
}
// Check exit status for any other failures
if !out.status.success() {
return Err(GenericError(format!("Failed to push {combined}")));
if combined_lower.contains("could not read") {
return Ok(PushResult::NeedsCredentials { url: remote_url.to_string(), error: None });
}
if combined_lower.contains("unable to access")
|| combined_lower.contains("authentication failed")
{
return Ok(PushResult::NeedsCredentials {
url: remote_url.to_string(),
error: Some(combined.to_string()),
});
}
return Err(GenericError(format!("Failed to push: {combined}")));
}
// Success cases (exit code 0 and no rejection indicators)
if combined_lower.contains("up-to-date") {
return Ok(PushResult::UpToDate);
}
Ok(PushResult::Success { message: format!("Pushed to {}/{}", remote_name, branch_name) })

View File

@@ -4,7 +4,6 @@ use crate::types::{SendableBody, SendableHttpRequest};
use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::{Client, Method, Version};
use std::collections::HashMap;
use std::fmt::Display;
use std::pin::Pin;
use std::task::{Context, Poll};
@@ -153,10 +152,10 @@ pub struct HttpResponse {
pub status: u16,
/// HTTP status reason phrase (e.g., "OK", "Not Found")
pub status_reason: Option<String>,
/// Response headers
pub headers: HashMap<String, String>,
/// Request headers
pub request_headers: HashMap<String, String>,
/// Response headers (Vec to support multiple headers with same name, e.g., Set-Cookie)
pub headers: Vec<(String, String)>,
/// Request headers (Vec to support multiple headers with same name)
pub request_headers: Vec<(String, String)>,
/// Content-Length from headers (may differ from actual body size)
pub content_length: Option<u64>,
/// Final URL (after redirects)
@@ -194,8 +193,8 @@ impl HttpResponse {
pub fn new(
status: u16,
status_reason: Option<String>,
headers: HashMap<String, String>,
request_headers: HashMap<String, String>,
headers: Vec<(String, String)>,
request_headers: Vec<(String, String)>,
content_length: Option<u64>,
url: String,
remote_addr: Option<String>,
@@ -395,10 +394,10 @@ impl HttpSender for ReqwestSender {
method: sendable_req.method().to_string(),
});
let mut request_headers = HashMap::new();
let mut request_headers = Vec::new();
for (name, value) in sendable_req.headers() {
let v = value.to_str().unwrap_or_default().to_string();
request_headers.insert(name.to_string(), v.clone());
request_headers.push((name.to_string(), v.clone()));
send_event(HttpResponseEvent::HeaderUp(name.to_string(), v));
}
send_event(HttpResponseEvent::Info("Sending request to server".to_string()));
@@ -426,12 +425,12 @@ impl HttpSender for ReqwestSender {
status: response.status().to_string(),
});
// Extract headers
let mut headers = HashMap::new();
// Extract headers (use Vec to preserve duplicates like Set-Cookie)
let mut headers = Vec::new();
for (key, value) in response.headers() {
if let Ok(v) = value.to_str() {
send_event(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string()));
headers.insert(key.to_string(), v.to_string());
headers.push((key.to_string(), v.to_string()));
}
}

View File

@@ -264,7 +264,6 @@ mod tests {
use crate::decompress::ContentEncoding;
use crate::sender::{HttpResponseEvent, HttpSender};
use async_trait::async_trait;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::AsyncRead;
@@ -277,7 +276,7 @@ mod tests {
struct MockResponse {
status: u16,
headers: HashMap<String, String>,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
@@ -306,7 +305,7 @@ mod tests {
mock.status,
None, // status_reason
mock.headers,
HashMap::new(),
Vec::new(),
None, // content_length
"https://example.com".to_string(), // url
None, // remote_addr
@@ -320,7 +319,7 @@ mod tests {
#[tokio::test]
async fn test_transaction_no_redirect() {
let response = MockResponse { status: 200, headers: HashMap::new(), body: b"OK".to_vec() };
let response = MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() };
let sender = MockSender::new(vec![response]);
let transaction = HttpTransaction::new(sender);
@@ -343,12 +342,11 @@ mod tests {
#[tokio::test]
async fn test_transaction_single_redirect() {
let mut redirect_headers = HashMap::new();
redirect_headers.insert("Location".to_string(), "https://example.com/new".to_string());
let redirect_headers = vec![("Location".to_string(), "https://example.com/new".to_string())];
let responses = vec![
MockResponse { status: 302, headers: redirect_headers, body: vec![] },
MockResponse { status: 200, headers: HashMap::new(), body: b"Final".to_vec() },
MockResponse { status: 200, headers: Vec::new(), body: b"Final".to_vec() },
];
let sender = MockSender::new(responses);
@@ -375,8 +373,7 @@ mod tests {
#[tokio::test]
async fn test_transaction_max_redirects_exceeded() {
let mut redirect_headers = HashMap::new();
redirect_headers.insert("Location".to_string(), "https://example.com/loop".to_string());
let redirect_headers = vec![("Location".to_string(), "https://example.com/loop".to_string())];
// Create more redirects than allowed
let responses: Vec<MockResponse> = (0..12)
@@ -474,8 +471,8 @@ mod tests {
Ok(HttpResponse::new(
200,
None,
HashMap::new(),
HashMap::new(),
Vec::new(),
Vec::new(),
None,
"https://example.com".to_string(),
None,
@@ -528,8 +525,7 @@ mod tests {
_request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> {
let mut headers = HashMap::new();
headers.insert("set-cookie".to_string(), "session=xyz789; Path=/".to_string());
let headers = vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
Box::pin(std::io::Cursor::new(vec![]));
@@ -537,7 +533,7 @@ mod tests {
200,
None,
headers,
HashMap::new(),
Vec::new(),
None,
"https://example.com".to_string(),
None,
@@ -569,6 +565,79 @@ mod tests {
assert!(cookies[0].raw_cookie.contains("session=xyz789"));
}
#[tokio::test]
async fn test_multiple_set_cookie_headers() {
// Create a cookie store
let cookie_store = CookieStore::new();
// Mock sender that returns multiple Set-Cookie headers
struct MultiSetCookieSender;
#[async_trait]
impl HttpSender for MultiSetCookieSender {
async fn send(
&self,
_request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> {
// Multiple Set-Cookie headers (this is standard HTTP behavior)
let headers = vec![
("set-cookie".to_string(), "session=abc123; Path=/".to_string()),
("set-cookie".to_string(), "user_id=42; Path=/".to_string()),
("set-cookie".to_string(), "preferences=dark; Path=/; Max-Age=86400".to_string()),
];
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
Box::pin(std::io::Cursor::new(vec![]));
Ok(HttpResponse::new(
200,
None,
headers,
Vec::new(),
None,
"https://example.com".to_string(),
None,
Some("HTTP/1.1".to_string()),
body_stream,
ContentEncoding::Identity,
))
}
}
let sender = MultiSetCookieSender;
let transaction = HttpTransaction::with_cookie_store(sender, cookie_store.clone());
let request = SendableHttpRequest {
url: "https://example.com/login".to_string(),
method: "POST".to_string(),
headers: vec![],
..Default::default()
};
let (_tx, rx) = tokio::sync::watch::channel(false);
let (event_tx, _event_rx) = mpsc::channel(100);
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
assert!(result.is_ok());
// Verify all three cookies were stored
let cookies = cookie_store.get_all_cookies();
assert_eq!(cookies.len(), 3, "All three Set-Cookie headers should be parsed and stored");
let cookie_values: Vec<&str> = cookies.iter().map(|c| c.raw_cookie.as_str()).collect();
assert!(
cookie_values.iter().any(|c| c.contains("session=abc123")),
"session cookie should be stored"
);
assert!(
cookie_values.iter().any(|c| c.contains("user_id=42")),
"user_id cookie should be stored"
);
assert!(
cookie_values.iter().any(|c| c.contains("preferences=dark")),
"preferences cookie should be stored"
);
}
#[tokio::test]
async fn test_cookies_across_redirects() {
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -595,9 +664,10 @@ mod tests {
let (status, headers) = if count == 0 {
// First request: return redirect with Set-Cookie
let mut h = HashMap::new();
h.insert("location".to_string(), "https://example.com/final".to_string());
h.insert("set-cookie".to_string(), "redirect_cookie=value1".to_string());
let h = vec![
("location".to_string(), "https://example.com/final".to_string()),
("set-cookie".to_string(), "redirect_cookie=value1".to_string()),
];
(302, h)
} else {
// Second request: verify cookie was sent
@@ -610,7 +680,7 @@ mod tests {
"Redirect cookie should be included"
);
(200, HashMap::new())
(200, Vec::new())
};
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
@@ -619,7 +689,7 @@ mod tests {
status,
None,
headers,
HashMap::new(),
Vec::new(),
None,
"https://example.com".to_string(),
None,

View File

@@ -5,6 +5,9 @@ version = "0.1.0"
edition = "2024"
publish = false
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
[build-dependencies]
tauri-plugin = { workspace = true, features = ["build"] }

View File

@@ -73,7 +73,7 @@ export type ProxySetting = { "type": "enabled", http: string, https: string, aut
export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: Record<string, string[]>, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN hotkeys TEXT DEFAULT '{}' NOT NULL;

View File

@@ -51,8 +51,17 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
let app_path = app_handle.path().app_data_dir().unwrap();
create_dir_all(app_path.clone()).expect("Problem creating App directory!");
let db_file_path = app_path.join("db.sqlite");
let blob_db_file_path = app_path.join("blobs.sqlite");
// Support per-worktree databases via YAAK_DB_PATH_PREFIX env var
let db_dir = match std::env::var("YAAK_DB_PATH_PREFIX") {
Ok(prefix) if !prefix.is_empty() => {
let dir = app_path.join(prefix);
create_dir_all(&dir).expect("Problem creating DB directory!");
dir
}
_ => app_path.clone(),
};
let db_file_path = db_dir.join("db.sqlite");
let blob_db_file_path = db_dir.join("blobs.sqlite");
// Main database pool
let manager = SqliteConnectionManager::file(db_file_path);

View File

@@ -11,6 +11,7 @@ use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_d
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fmt::{Debug, Display};
use std::str::FromStr;
use ts_rs::TS;
@@ -147,6 +148,7 @@ pub struct Settings {
pub autoupdate: bool,
pub auto_download_updates: bool,
pub check_notifications: bool,
pub hotkeys: HashMap<String, Vec<String>>,
}
impl UpsertModelInfo for Settings {
@@ -180,6 +182,7 @@ impl UpsertModelInfo for Settings {
Some(p) => Some(serde_json::to_string(&p)?),
};
let client_certificates = serde_json::to_string(&self.client_certificates)?;
let hotkeys = serde_json::to_string(&self.hotkeys)?;
Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
@@ -204,6 +207,7 @@ impl UpsertModelInfo for Settings {
(ColoredMethods, self.colored_methods.into()),
(CheckNotifications, self.check_notifications.into()),
(Proxy, proxy.into()),
(Hotkeys, hotkeys.into()),
])
}
@@ -231,6 +235,7 @@ impl UpsertModelInfo for Settings {
SettingsIden::AutoDownloadUpdates,
SettingsIden::ColoredMethods,
SettingsIden::CheckNotifications,
SettingsIden::Hotkeys,
]
}
@@ -241,6 +246,7 @@ impl UpsertModelInfo for Settings {
let proxy: Option<String> = row.get("proxy")?;
let client_certificates: String = row.get("client_certificates")?;
let editor_keymap: String = row.get("editor_keymap")?;
let hotkeys: String = row.get("hotkeys")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -267,6 +273,7 @@ impl UpsertModelInfo for Settings {
hide_license_badge: row.get("hide_license_badge")?,
colored_methods: row.get("colored_methods")?,
check_notifications: row.get("check_notifications")?,
hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(),
})
}
}

View File

@@ -8,6 +8,10 @@ impl<'a> DbContext<'a> {
self.find_one(PluginIden::Id, id)
}
pub fn get_plugin_by_directory(&self, directory: &str) -> Option<Plugin> {
self.find_optional(PluginIden::Directory, directory)
}
pub fn list_plugins(&self) -> Result<Vec<Plugin>> {
self.find_all()
}

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::db_context::DbContext;
use crate::error::Result;
use crate::models::{EditorKeymap, Settings, SettingsIden};
@@ -38,6 +40,7 @@ impl<'a> DbContext<'a> {
hide_license_badge: false,
auto_download_updates: true,
check_notifications: true,
hotkeys: HashMap::new(),
};
self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings")
}

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
const COMMANDS: &[&str] = &["search", "install", "updates", "uninstall"];
const COMMANDS: &[&str] = &["search", "install", "updates", "uninstall", "update_all"];
fn main() {
tauri_plugin::Builder::new(COMMANDS).build();

View File

@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { PluginSearchResponse, PluginUpdatesResponse } from './bindings/gen_api';
import { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } from './bindings/gen_api';
export * from './bindings/gen_models';
export * from './bindings/gen_events';
@@ -20,3 +20,7 @@ export async function uninstallPlugin(pluginId: string) {
export async function checkPluginUpdates() {
return invoke<PluginUpdatesResponse>('plugin:yaak-plugins|updates', {});
}
export async function updateAllPlugins() {
return invoke<PluginNameVersion[]>('plugin:yaak-plugins|update_all', {});
}

View File

@@ -1,3 +1,3 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-search", "allow-install", "allow-uninstall", "allow-updates"]
permissions = ["allow-search", "allow-install", "allow-uninstall", "allow-updates", "allow-update-all"]

View File

@@ -57,6 +57,7 @@ pub async fn check_plugin_updates<R: Runtime>(
.db()
.list_plugins()?
.into_iter()
.filter(|p| p.url.is_some()) // Only check plugins with URLs (from registry)
.filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) {
Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }),
Err(e) => {
@@ -123,8 +124,8 @@ pub struct PluginSearchResponse {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_api.ts")]
pub struct PluginNameVersion {
name: String,
version: String,
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]

View File

@@ -1,9 +1,10 @@
use crate::api::{
PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates, search_plugins,
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
search_plugins,
};
use crate::error::Result;
use crate::install::{delete_and_uninstall, download_and_install};
use tauri::{AppHandle, Runtime, WebviewWindow, command};
use tauri::{AppHandle, Manager, Runtime, WebviewWindow, command};
use yaak_models::models::Plugin;
#[command]
@@ -36,3 +37,34 @@ pub(crate) async fn uninstall<R: Runtime>(
pub(crate) async fn updates<R: Runtime>(app_handle: AppHandle<R>) -> Result<PluginUpdatesResponse> {
check_plugin_updates(&app_handle).await
}
#[command]
pub(crate) async fn update_all<R: Runtime>(
window: WebviewWindow<R>,
) -> Result<Vec<PluginNameVersion>> {
use log::info;
// Get list of available updates (already filtered to only registry plugins)
let updates = check_plugin_updates(&window.app_handle()).await?;
if updates.plugins.is_empty() {
return Ok(Vec::new());
}
let mut updated = Vec::new();
for update in updates.plugins {
info!("Updating plugin: {} to version {}", update.name, update.version);
match download_and_install(&window, &update.name, Some(update.version.clone())).await {
Ok(_) => {
info!("Successfully updated plugin: {}", update.name);
updated.push(update.clone());
}
Err(e) => {
log::error!("Failed to update plugin {}: {:?}", update.name, e);
}
}
}
Ok(updated)
}

View File

@@ -153,6 +153,9 @@ pub enum InternalEventPayload {
WindowCloseEvent,
CloseWindowRequest(CloseWindowRequest),
OpenExternalUrlRequest(OpenExternalUrlRequest),
OpenExternalUrlResponse(EmptyPayload),
ShowToastRequest(ShowToastRequest),
ShowToastResponse(EmptyPayload),
@@ -492,6 +495,13 @@ pub struct OpenWindowRequest {
pub data_dir_key: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct OpenExternalUrlRequest {
pub url: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
@@ -1351,8 +1361,8 @@ pub struct ListHttpRequestsResponse {
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
#[serde(default)]
#[ts(export, type = "{}", export_to = "gen_events.ts")]
pub struct ListFoldersRequest {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]

View File

@@ -55,9 +55,7 @@ pub async fn download_and_install<R: Runtime>(
zip_extract::extract(Cursor::new(&bytes), &plugin_dir, true)?;
info!("Extracted plugin {} to {}", plugin_version.id, plugin_dir_str);
plugin_manager.add_plugin_by_dir(&PluginContext::new(&window), &plugin_dir_str).await?;
window.db().upsert_plugin(
let plugin = window.db().upsert_plugin(
&Plugin {
id: plugin_version.id.clone(),
checked_at: Some(Utc::now().naive_utc()),
@@ -69,6 +67,8 @@ pub async fn download_and_install<R: Runtime>(
&UpdateSource::Background,
)?;
plugin_manager.add_plugin(&PluginContext::new(&window), &plugin).await?;
info!("Installed plugin {} to {}", plugin_version.id, plugin_dir_str);
Ok(plugin_version)

View File

@@ -1,9 +1,12 @@
use crate::commands::{install, search, uninstall, updates};
use crate::commands::{install, search, uninstall, update_all, updates};
use crate::manager::PluginManager;
use log::info;
use crate::plugin_updater::PluginUpdater;
use log::{info, warn};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tauri::plugin::{Builder, TauriPlugin};
use tauri::{Manager, RunEvent, Runtime, State, generate_handler};
use tauri::{Manager, RunEvent, Runtime, State, WindowEvent, generate_handler};
use tokio::sync::Mutex;
pub mod api;
mod checksum;
@@ -16,6 +19,7 @@ pub mod native_template_functions;
mod nodejs;
pub mod plugin_handle;
pub mod plugin_meta;
pub mod plugin_updater;
mod server_ws;
pub mod template_callback;
mod util;
@@ -24,10 +28,14 @@ static EXITING: AtomicBool = AtomicBool::new(false);
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-plugins")
.invoke_handler(generate_handler![search, install, uninstall, updates])
.invoke_handler(generate_handler![search, install, uninstall, updates, update_all])
.setup(|app_handle, _| {
let manager = PluginManager::new(app_handle.clone());
app_handle.manage(manager.clone());
let plugin_updater = PluginUpdater::new();
app_handle.manage(Mutex::new(plugin_updater));
Ok(())
})
.on_event(|app, e| match e {
@@ -44,6 +52,18 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
app.exit(0);
});
}
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
// Check for plugin updates on window focus
let w = app.get_webview_window(&label).unwrap();
let h = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_secs(3)).await; // Wait a bit so it's not so jarring
let val: State<'_, Mutex<PluginUpdater>> = h.state();
if let Err(e) = val.lock().await.maybe_check(&w).await {
warn!("Failed to check for plugin updates {e:?}");
}
});
}
_ => {}
})
.build()

View File

@@ -1,5 +1,6 @@
use crate::error::Error::{
AuthPluginNotFound, ClientNotInitializedErr, PluginErr, PluginNotFoundErr, UnknownEventErr,
self, AuthPluginNotFound, ClientNotInitializedErr, PluginErr, PluginNotFoundErr,
UnknownEventErr,
};
use crate::error::Result;
use crate::events::{
@@ -7,14 +8,15 @@ use crate::events::{
CallHttpAuthenticationActionArgs, CallHttpAuthenticationActionRequest,
CallHttpAuthenticationRequest, CallHttpAuthenticationResponse, CallHttpRequestActionRequest,
CallTemplateFunctionArgs, CallTemplateFunctionRequest, CallTemplateFunctionResponse,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, EmptyPayload, ErrorResponse,
FilterRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigRequest, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesRequest, GetThemesResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportRequest, ImportResponse,
InternalEvent, InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, Color, EmptyPayload,
ErrorResponse, FilterRequest, FilterResponse, GetFolderActionsResponse,
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigRequest,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigRequest,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesRequest,
GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, Icon,
ImportRequest, ImportResponse, InternalEvent, InternalEventPayload, JsonPrimitive,
PluginContext, RenderPurpose, ShowToastRequest,
};
use crate::native_template_functions::{template_function_keyring, template_function_secure};
use crate::nodejs::start_nodejs_plugin_runtime;
@@ -29,16 +31,16 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tauri::path::BaseDirectory;
use tauri::{AppHandle, Manager, Runtime, WebviewWindow, is_dev};
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
use tokio::fs::read_dir;
use tokio::net::TcpListener;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{Mutex, mpsc};
use tokio::time::{Instant, timeout};
use yaak_models::models::Environment;
use yaak_models::models::{Environment, Plugin};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::generate_id;
use yaak_models::util::{UpdateSource, generate_id};
use yaak_templates::error::Error::RenderError;
use yaak_templates::error::Result as TemplateResult;
use yaak_templates::{RenderErrorBehavior, RenderOptions, render_json_value_raw};
@@ -46,21 +48,16 @@ use yaak_templates::{RenderErrorBehavior, RenderOptions, render_json_value_raw};
#[derive(Clone)]
pub struct PluginManager {
subscribers: Arc<Mutex<HashMap<String, mpsc::Sender<InternalEvent>>>>,
plugins: Arc<Mutex<Vec<PluginHandle>>>,
plugin_handles: Arc<Mutex<Vec<PluginHandle>>>,
kill_tx: tokio::sync::watch::Sender<bool>,
ws_service: Arc<PluginRuntimeServerWebsocket>,
vendored_plugin_dir: PathBuf,
pub(crate) installed_plugin_dir: PathBuf,
}
#[derive(Clone)]
struct PluginCandidate {
dir: String,
}
impl PluginManager {
pub fn new<R: Runtime>(app_handle: AppHandle<R>) -> PluginManager {
let (events_tx, mut events_rx) = mpsc::channel(128);
let (events_tx, mut events_rx) = mpsc::channel(2048);
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128);
@@ -80,7 +77,7 @@ impl PluginManager {
.join("installed-plugins");
let plugin_manager = PluginManager {
plugins: Default::default(),
plugin_handles: Default::default(),
subscribers: Default::default(),
ws_service: Arc::new(ws_service.clone()),
kill_tx: kill_server_tx,
@@ -109,7 +106,7 @@ impl PluginManager {
// Handle when client plugin runtime disconnects
tauri::async_runtime::spawn(async move {
while let Some(_) = client_disconnect_rx.recv().await {
while (client_disconnect_rx.recv().await).is_some() {
// Happens when the app is closed
info!("Plugin runtime client disconnected");
}
@@ -163,10 +160,10 @@ impl PluginManager {
plugin_manager
}
async fn list_plugin_dirs<R: Runtime>(
async fn list_available_plugins<R: Runtime>(
&self,
app_handle: &AppHandle<R>,
) -> Vec<PluginCandidate> {
) -> Result<Vec<Plugin>> {
let plugins_dir = if is_dev() {
// Use plugins directly for easy development
env::current_dir()
@@ -178,18 +175,27 @@ impl PluginManager {
info!("Loading bundled plugins from {plugins_dir:?}");
let bundled_plugin_dirs: Vec<PluginCandidate> = read_plugins_dir(&plugins_dir)
// Read bundled plugin directories from disk
let bundled_plugin_dirs: Vec<String> = read_plugins_dir(&plugins_dir)
.await
.expect(format!("Failed to read plugins dir: {:?}", plugins_dir).as_str())
.iter()
.map(|d| PluginCandidate { dir: d.into() })
.collect();
.expect(&format!("Failed to read plugins dir: {:?}", plugins_dir));
let plugins = app_handle.db().list_plugins().unwrap_or_default();
let installed_plugin_dirs: Vec<PluginCandidate> =
plugins.iter().map(|p| PluginCandidate { dir: p.directory.to_owned() }).collect();
// Ensure all bundled plugins make it into the database
for dir in &bundled_plugin_dirs {
if app_handle.db().get_plugin_by_directory(dir).is_none() {
app_handle.db().upsert_plugin(
&Plugin {
directory: dir.clone(),
enabled: true,
url: None,
..Default::default()
},
&UpdateSource::Background,
)?;
}
}
[bundled_plugin_dirs, installed_plugin_dirs].concat()
Ok(app_handle.db().list_plugins()?)
}
pub async fn uninstall(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> {
@@ -202,16 +208,19 @@ impl PluginManager {
plugin_context: &PluginContext,
plugin: &PluginHandle,
) -> Result<()> {
// Terminate the plugin
self.send_to_plugin_and_wait(
plugin_context,
plugin,
&InternalEventPayload::TerminateRequest,
)
.await?;
// Terminate the plugin if it's enabled
if plugin.enabled {
self.send_to_plugin_and_wait(
plugin_context,
plugin,
&InternalEventPayload::TerminateRequest,
Duration::from_secs(5),
)
.await?;
}
// Remove the plugin from the list
let mut plugins = self.plugins.lock().await;
let mut plugins = self.plugin_handles.lock().await;
let pos = plugins.iter().position(|p| p.ref_id == plugin.ref_id);
if let Some(pos) = pos {
plugins.remove(pos);
@@ -220,40 +229,45 @@ impl PluginManager {
Ok(())
}
pub async fn add_plugin_by_dir(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> {
info!("Adding plugin by dir {dir}");
pub async fn add_plugin(&self, plugin_context: &PluginContext, plugin: &Plugin) -> Result<()> {
info!("Adding plugin by dir {}", plugin.directory);
let maybe_tx = self.ws_service.app_to_plugin_events_tx.lock().await;
let tx = match &*maybe_tx {
None => return Err(ClientNotInitializedErr),
Some(tx) => tx,
};
let plugin_handle = PluginHandle::new(dir, tx.clone())?;
let dir_path = Path::new(dir);
let plugin_handle = PluginHandle::new(&plugin.directory, plugin.enabled, tx.clone())?;
let dir_path = Path::new(&plugin.directory);
let is_vendored = dir_path.starts_with(self.vendored_plugin_dir.as_path());
let is_installed = dir_path.starts_with(self.installed_plugin_dir.as_path());
// Boot the plugin
let event = timeout(
Duration::from_secs(5),
self.send_to_plugin_and_wait(
plugin_context,
&plugin_handle,
&InternalEventPayload::BootRequest(BootRequest {
dir: dir.to_string(),
watch: !is_vendored && !is_installed,
}),
),
)
.await??;
// Boot the plugin if it's enabled
if plugin.enabled {
let event = self
.send_to_plugin_and_wait(
plugin_context,
&plugin_handle,
&InternalEventPayload::BootRequest(BootRequest {
dir: plugin.directory.clone(),
watch: !is_vendored && !is_installed,
}),
Duration::from_secs(5),
)
.await?;
if !matches!(event.payload, InternalEventPayload::BootResponse) {
return Err(UnknownEventErr);
if !matches!(event.payload, InternalEventPayload::BootResponse) {
// Add it to the plugin handles anyway...
let mut plugin_handles = self.plugin_handles.lock().await;
plugin_handles.retain(|p| p.dir != plugin.directory);
plugin_handles.push(plugin_handle.clone());
return Err(UnknownEventErr);
}
}
let mut plugins = self.plugins.lock().await;
plugins.retain(|p| p.dir != dir);
plugins.push(plugin_handle.clone());
let mut plugin_handles = self.plugin_handles.lock().await;
plugin_handles.retain(|p| p.dir != plugin.directory);
plugin_handles.push(plugin_handle.clone());
Ok(())
}
@@ -263,22 +277,37 @@ impl PluginManager {
app_handle: &AppHandle<R>,
plugin_context: &PluginContext,
) -> Result<()> {
info!("Initializing all plugins");
let start = Instant::now();
let candidates = self.list_plugin_dirs(app_handle).await;
for candidate in candidates.clone() {
// First remove the plugin if it exists
if let Some(plugin) = self.get_plugin_by_dir(candidate.dir.as_str()).await {
if let Err(e) = self.remove_plugin(plugin_context, &plugin).await {
error!("Failed to remove plugin {} {e:?}", candidate.dir);
for plugin in self.list_available_plugins(app_handle).await?.clone() {
// First remove the plugin if it exists and is enabled
if let Some(plugin_handle) = self.get_plugin_by_dir(&plugin.directory).await {
if let Err(e) = self.remove_plugin(plugin_context, &plugin_handle).await {
error!("Failed to remove plugin {} {e:?}", plugin.directory);
continue;
}
}
if let Err(e) = self.add_plugin_by_dir(plugin_context, candidate.dir.as_str()).await {
warn!("Failed to add plugin {} {e:?}", candidate.dir);
if let Err(e) = self.add_plugin(plugin_context, &plugin).await {
warn!("Failed to add plugin {} {e:?}", plugin.directory);
// Extract a user-friendly plugin name from the directory path
let plugin_name = plugin.directory.split('/').last().unwrap_or(&plugin.directory);
// Show a toast for all plugin failures
let toast = ShowToastRequest {
message: format!("Failed to start plugin '{}': {}", plugin_name, e),
color: Some(Color::Danger),
icon: Some(Icon::AlertTriangle),
timeout: Some(10000),
};
if let Err(emit_err) = app_handle.emit("show_toast", toast) {
error!("Failed to emit toast for plugin error: {emit_err:?}");
}
}
}
let plugins = self.plugins.lock().await;
let plugins = self.plugin_handles.lock().await;
let names = plugins.iter().map(|p| p.dir.to_string()).collect::<Vec<String>>();
info!(
"Initialized {} plugins in {:?}:\n - {}",
@@ -291,7 +320,7 @@ impl PluginManager {
}
pub async fn subscribe(&self, label: &str) -> (String, mpsc::Receiver<InternalEvent>) {
let (tx, rx) = mpsc::channel(128);
let (tx, rx) = mpsc::channel(2048);
let rx_id = format!("{label}_{}", generate_id());
self.subscribers.lock().await.insert(rx_id.clone(), tx);
(rx_id, rx)
@@ -324,15 +353,15 @@ impl PluginManager {
}
pub async fn get_plugin_by_ref_id(&self, ref_id: &str) -> Option<PluginHandle> {
self.plugins.lock().await.iter().find(|p| p.ref_id == ref_id).cloned()
self.plugin_handles.lock().await.iter().find(|p| p.ref_id == ref_id).cloned()
}
pub async fn get_plugin_by_dir(&self, dir: &str) -> Option<PluginHandle> {
self.plugins.lock().await.iter().find(|p| p.dir == dir).cloned()
self.plugin_handles.lock().await.iter().find(|p| p.dir == dir).cloned()
}
pub async fn get_plugin_by_name(&self, name: &str) -> Option<PluginHandle> {
for plugin in self.plugins.lock().await.iter().cloned() {
for plugin in self.plugin_handles.lock().await.iter().cloned() {
let info = plugin.info();
if info.name == name {
return Some(plugin);
@@ -346,19 +375,37 @@ impl PluginManager {
plugin_context: &PluginContext,
plugin: &PluginHandle,
payload: &InternalEventPayload,
timeout_duration: Duration,
) -> Result<InternalEvent> {
let events =
self.send_to_plugins_and_wait(plugin_context, payload, vec![plugin.to_owned()]).await?;
Ok(events.first().unwrap().to_owned())
if !plugin.enabled {
return Err(Error::PluginErr(format!("Plugin {} is disabled", plugin.metadata.name)));
}
let events = self
.send_to_plugins_and_wait(
plugin_context,
payload,
vec![plugin.to_owned()],
timeout_duration,
)
.await?;
Ok(events
.first()
.ok_or(Error::PluginErr(format!(
"No plugin events returned for: {}",
plugin.metadata.name
)))?
.to_owned())
}
async fn send_and_wait(
&self,
plugin_context: &PluginContext,
payload: &InternalEventPayload,
timeout_duration: Duration,
) -> Result<Vec<InternalEvent>> {
let plugins = { self.plugins.lock().await.clone() };
self.send_to_plugins_and_wait(plugin_context, payload, plugins).await
let plugins = { self.plugin_handles.lock().await.clone() };
self.send_to_plugins_and_wait(plugin_context, payload, plugins, timeout_duration).await
}
async fn send_to_plugins_and_wait(
@@ -366,6 +413,7 @@ impl PluginManager {
plugin_context: &PluginContext,
payload: &InternalEventPayload,
plugins: Vec<PluginHandle>,
timeout_duration: Duration,
) -> Result<Vec<InternalEvent>> {
let label = format!("wait[{}.{}]", plugins.len(), payload.type_name());
let (rx_id, mut rx) = self.subscribe(label.as_str()).await;
@@ -373,6 +421,7 @@ impl PluginManager {
// 1. Build the events with IDs and everything
let events_to_send = plugins
.iter()
.filter(|p| p.enabled)
.map(|p| p.build_event_to_send(plugin_context, payload, None))
.collect::<Vec<InternalEvent>>();
@@ -383,19 +432,28 @@ impl PluginManager {
tokio::spawn(async move {
let mut found_events = Vec::new();
while let Some(event) = rx.recv().await {
let matched_sent_event = events_to_send
.iter()
.find(|e| Some(e.id.to_owned()) == event.reply_id)
.is_some();
if matched_sent_event {
found_events.push(event.clone());
};
let collect_events = async {
while let Some(event) = rx.recv().await {
let matched_sent_event =
events_to_send.iter().any(|e| Some(e.id.to_owned()) == event.reply_id);
if matched_sent_event {
found_events.push(event.clone());
};
let found_them_all = found_events.len() == events_to_send.len();
if found_them_all {
break;
let found_them_all = found_events.len() == events_to_send.len();
if found_them_all {
break;
}
}
};
// Timeout to prevent hanging forever if plugin doesn't respond
if timeout(timeout_duration, collect_events).await.is_err() {
warn!(
"Timeout waiting for plugin responses. Got {}/{} responses",
found_events.len(),
events_to_send.len()
);
}
found_events
@@ -428,6 +486,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetThemesRequest(GetThemesRequest {}),
Duration::from_secs(5),
)
.await?;
@@ -449,6 +508,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetGrpcRequestActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -470,6 +530,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetHttpRequestActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -491,6 +552,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetWebsocketRequestActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -512,6 +574,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetWorkspaceActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -533,6 +596,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetFolderActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -586,7 +650,7 @@ impl PluginManager {
// We don't want to fail for this op because the UI will not be able to list any auth types then
let render_opt = RenderOptions { error_behavior: RenderErrorBehavior::ReturnEmpty };
let rendered_values = render_json_value_raw(json!(values), vars, &cb, &render_opt).await?;
let context_id = format!("{:x}", md5::compute(model_id.to_string()));
let context_id = format!("{:x}", md5::compute(model_id));
let event = self
.send_to_plugin_and_wait(
@@ -599,6 +663,7 @@ impl PluginManager {
context_id,
},
),
Duration::from_secs(5),
)
.await?;
match event.payload {
@@ -705,6 +770,7 @@ impl PluginManager {
.send_and_wait(
&plugin_context,
&InternalEventPayload::GetHttpAuthenticationSummaryRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -754,7 +820,7 @@ impl PluginManager {
// We don't want to fail for this op because the UI will not be able to list any auth types then
let render_opt = RenderOptions { error_behavior: RenderErrorBehavior::ReturnEmpty };
let rendered_values = render_json_value_raw(json!(values), vars, &cb, &render_opt).await?;
let context_id = format!("{:x}", md5::compute(model_id.to_string()));
let context_id = format!("{:x}", md5::compute(model_id));
let event = self
.send_to_plugin_and_wait(
&PluginContext::new(window),
@@ -765,6 +831,7 @@ impl PluginManager {
context_id,
},
),
Duration::from_secs(5),
)
.await?;
match event.payload {
@@ -804,7 +871,7 @@ impl PluginManager {
.find_map(|(p, r)| if r.name == auth_name { Some(p) } else { None })
.ok_or(PluginNotFoundErr(auth_name.into()))?;
let context_id = format!("{:x}", md5::compute(model_id.to_string()));
let context_id = format!("{:x}", md5::compute(model_id));
self.send_to_plugin_and_wait(
&PluginContext::new(window),
&plugin,
@@ -818,6 +885,7 @@ impl PluginManager {
},
},
),
Duration::from_secs(300), // 5 minutes for OAuth flows
)
.await?;
Ok(())
@@ -831,7 +899,7 @@ impl PluginManager {
plugin_context: &PluginContext,
) -> Result<CallHttpAuthenticationResponse> {
let disabled = match req.values.get("disabled") {
Some(JsonPrimitive::Boolean(v)) => v.clone(),
Some(JsonPrimitive::Boolean(v)) => *v,
_ => false,
};
@@ -855,6 +923,7 @@ impl PluginManager {
plugin_context,
&plugin,
&InternalEventPayload::CallHttpAuthenticationRequest(req),
Duration::from_secs(300), // 5 minutes for OAuth flows
)
.await?;
match event.payload {
@@ -876,6 +945,7 @@ impl PluginManager {
.send_and_wait(
&plugin_context,
&InternalEventPayload::GetTemplateFunctionSummaryRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -908,7 +978,11 @@ impl PluginManager {
};
let events = self
.send_and_wait(plugin_context, &InternalEventPayload::CallTemplateFunctionRequest(req))
.send_and_wait(
plugin_context,
&InternalEventPayload::CallTemplateFunctionRequest(req),
Duration::from_secs(300), // 5 minutes for user interactions (OAuth, prompts, etc.)
)
.await
.map_err(|e| RenderError(format!("Failed to call template function {e:}")))?;
@@ -945,6 +1019,7 @@ impl PluginManager {
&InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(),
}),
Duration::from_secs(5),
)
.await?;
@@ -987,6 +1062,7 @@ impl PluginManager {
filter: filter.to_string(),
content: content.to_string(),
}),
Duration::from_secs(5),
)
.await?;

View File

@@ -10,12 +10,13 @@ use tokio::sync::{Mutex, mpsc};
pub struct PluginHandle {
pub ref_id: String,
pub dir: String,
pub enabled: bool,
pub(crate) to_plugin_tx: Arc<Mutex<mpsc::Sender<InternalEvent>>>,
pub(crate) metadata: PluginMetadata,
}
impl PluginHandle {
pub fn new(dir: &str, tx: mpsc::Sender<InternalEvent>) -> Result<Self> {
pub fn new(dir: &str, enabled: bool, tx: mpsc::Sender<InternalEvent>) -> Result<Self> {
let ref_id = gen_id();
let metadata = get_plugin_meta(&Path::new(dir))?;
@@ -23,6 +24,7 @@ impl PluginHandle {
ref_id: ref_id.clone(),
dir: dir.to_string(),
to_plugin_tx: Arc::new(Mutex::new(tx)),
enabled,
metadata,
})
}

View File

@@ -0,0 +1,101 @@
use std::time::Instant;
use log::{error, info};
use serde::Serialize;
use tauri::{Emitter, Manager, Runtime, WebviewWindow};
use ts_rs::TS;
use yaak_models::query_manager::QueryManagerExt;
use crate::api::check_plugin_updates;
use crate::error::Result;
const MAX_UPDATE_CHECK_HOURS: u64 = 12;
pub struct PluginUpdater {
last_check: Option<Instant>,
}
#[derive(Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub struct PluginUpdateNotification {
pub update_count: usize,
pub plugins: Vec<PluginUpdateInfo>,
}
#[derive(Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub struct PluginUpdateInfo {
pub name: String,
pub current_version: String,
pub latest_version: String,
}
impl PluginUpdater {
pub fn new() -> Self {
Self { last_check: None }
}
pub async fn check_now<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {
self.last_check = Some(Instant::now());
info!("Checking for plugin updates");
let updates = check_plugin_updates(&window.app_handle()).await?;
if updates.plugins.is_empty() {
info!("No plugin updates available");
return Ok(false);
}
// Get current plugin versions to build notification
let plugins = window.app_handle().db().list_plugins()?;
let mut update_infos = Vec::new();
for update in &updates.plugins {
if let Some(plugin) = plugins.iter().find(|p| {
if let Ok(meta) =
crate::plugin_meta::get_plugin_meta(&std::path::Path::new(&p.directory))
{
meta.name == update.name
} else {
false
}
}) {
if let Ok(meta) =
crate::plugin_meta::get_plugin_meta(&std::path::Path::new(&plugin.directory))
{
update_infos.push(PluginUpdateInfo {
name: update.name.clone(),
current_version: meta.version,
latest_version: update.version.clone(),
});
}
}
}
let notification =
PluginUpdateNotification { update_count: update_infos.len(), plugins: update_infos };
info!("Found {} plugin update(s)", notification.update_count);
if let Err(e) = window.emit_to(window.label(), "plugin_updates_available", &notification) {
error!("Failed to emit plugin_updates_available event: {}", e);
}
Ok(true)
}
pub async fn maybe_check<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {
let update_period_seconds = MAX_UPDATE_CHECK_HOURS * 60 * 60;
if let Some(i) = self.last_check
&& i.elapsed().as_secs() < update_period_seconds
{
return Ok(false);
}
self.check_now(window).await
}
}

View File

@@ -37,7 +37,7 @@ impl PluginRuntimeServerWebsocket {
}
async fn accept_connection(&self, stream: TcpStream) {
let (to_plugin_tx, mut to_plugin_rx) = mpsc::channel::<InternalEvent>(128);
let (to_plugin_tx, mut to_plugin_rx) = mpsc::channel::<InternalEvent>(2048);
let mut app_to_plugin_events_tx = self.app_to_plugin_events_tx.lock().await;
*app_to_plugin_events_tx = Some(to_plugin_tx);
@@ -73,10 +73,17 @@ impl PluginRuntimeServerWebsocket {
// Skip non-text messages
if !msg.is_text() {
return;
warn!("Received non-text message from plugin runtime");
continue;
}
let msg_text = msg.into_text().unwrap();
let msg_text = match msg.into_text() {
Ok(text) => text,
Err(e) => {
error!("Failed to convert message to text: {e:?}");
continue;
}
};
let event = match serde_json::from_str::<InternalEventRawPayload>(&msg_text) {
Ok(e) => e,
Err(e) => {
@@ -117,9 +124,18 @@ impl PluginRuntimeServerWebsocket {
return;
},
Some(event) => {
let event_bytes = serde_json::to_string(&event).unwrap();
let event_bytes = match serde_json::to_string(&event) {
Ok(bytes) => bytes,
Err(e) => {
error!("Failed to serialize event: {:?}", e);
continue;
}
};
let msg = Message::text(event_bytes);
ws_sender.send(msg).await.unwrap();
if let Err(e) = ws_sender.send(msg).await {
error!("Failed to send message to plugin runtime: {:?}", e);
break;
}
}
}
}

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export function unescape_template(template: string): any;
export function escape_template(template: string): any;
export function parse_template(template: string): any;
export function escape_template(template: string): any;
export function unescape_template(template: string): any;

View File

@@ -165,10 +165,10 @@ function takeFromExternrefTable0(idx) {
* @param {string} template
* @returns {any}
*/
export function unescape_template(template) {
export function parse_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.unescape_template(ptr0, len0);
const ret = wasm.parse_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
@@ -193,10 +193,10 @@ export function escape_template(template) {
* @param {string} template
* @returns {any}
*/
export function parse_template(template) {
export function unescape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_template(ptr0, len0);
const ret = wasm.unescape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}

View File

Binary file not shown.

View File

@@ -5,7 +5,10 @@ import { jotaiStore } from '../lib/jotai';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
export const openSettings = createFastMutation<void, string, SettingsTab | null>({
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
mutationKey: ['open_settings'],
mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
@@ -14,7 +17,7 @@ export const openSettings = createFastMutation<void, string, SettingsTab | null>
const location = router.buildLocation({
to: '/workspaces/$workspaceId/settings',
params: { workspaceId },
search: { tab: tab ?? undefined },
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
});
await invokeCmd('cmd_new_child_window', {

View File

@@ -46,7 +46,7 @@ import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { CookieDialog } from './CookieDialog';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { HotKey } from './core/HotKey';
import { Hotkey } from './core/Hotkey';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
@@ -139,7 +139,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{
key: 'environment.edit',
label: 'Edit Environment',
action: 'environmentEditor.toggle',
action: 'environment_editor.toggle',
onSelect: () => editEnvironment(activeEnvironment),
},
{
@@ -493,5 +493,5 @@ function CommandPaletteItem({
}
function CommandPaletteAction({ action }: { action: HotkeyAction }) {
return <HotKey className="ml-auto" action={action} />;
return <Hotkey className="ml-auto" action={action} />;
}

View File

@@ -38,7 +38,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
</thead>
<tbody className="divide-y divide-surface-highlight">
{cookieJar?.cookies.map((c: Cookie) => (
<tr key={c.domain + c.raw_cookie + c.path + c.expires}>
<tr key={JSON.stringify(c)}>
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
{cookieDomain(c)}
</td>

View File

@@ -51,7 +51,11 @@ export function CreateWorkspaceDialog({ hide }: Props) {
gitMutations(syncConfig.filePath, gitCallbacks(syncConfig.filePath))
.init.mutateAsync()
.catch((err) => {
showErrorToast('git-init-error', String(err));
showErrorToast({
id: 'git-init-error',
title: 'Error initializing Git',
message: String(err),
});
});
}

View File

@@ -5,19 +5,28 @@ import { memo } from 'react';
interface Props {
className?: string;
style?: CSSProperties;
orientation?: 'horizontal' | 'vertical';
}
export const DropMarker = memo(
function DropMarker({ className, style }: Props) {
function DropMarker({ className, style, orientation = 'horizontal' }: Props) {
return (
<div
style={style}
className={classNames(
className,
'relative w-full h-0 overflow-visible pointer-events-none',
'absolute pointer-events-none z-50',
orientation === 'horizontal' && 'w-full',
orientation === 'vertical' && 'w-0 top-0 bottom-0',
)}
>
<div className="absolute z-50 left-2 right-2 -bottom-[0.1rem] h-[0.2rem] bg-primary rounded-full" />
<div
className={classNames(
'absolute bg-primary rounded-full',
orientation === 'horizontal' && 'left-2 right-2 -bottom-[0.1rem] h-[0.2rem]',
orientation === 'vertical' && '-left-[0.1rem] top-0 bottom-0 w-[0.2rem]',
)}
/>
</div>
);
},

View File

@@ -45,7 +45,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
: []) as DropdownItem[]),
{
label: 'Manage Environments',
hotKeyAction: 'environmentEditor.toggle',
hotKeyAction: 'environment_editor.toggle',
leftSlot: <Icon icon="box" />,
onSelect: () => editEnvironment(activeEnvironment),
},

View File

@@ -5,6 +5,7 @@ import { useAtomValue } from 'jotai';
import type { CSSProperties, ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { allRequestsAtom } from '../hooks/useAllRequests';
import { useFolderActions } from '../hooks/useFolderActions';
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { showDialog } from '../lib/dialog';
@@ -30,6 +31,12 @@ interface Props {
export function FolderLayout({ folder, style }: Props) {
const folders = useAtomValue(foldersAtom);
const requests = useAtomValue(allRequestsAtom);
const folderActions = useFolderActions();
const sendAllAction = useMemo(
() => folderActions.find((a) => a.label === 'Send All'),
[folderActions],
);
const children = useMemo(() => {
return [
...folders.filter((f) => f.folderId === folder.id),
@@ -37,6 +44,10 @@ export function FolderLayout({ folder, style }: Props) {
];
}, [folder.id, folders, requests]);
const handleSendAll = useCallback(() => {
sendAllAction?.call(folder);
}, [sendAllAction, folder]);
return (
<div style={style} className="p-6 pt-4 overflow-y-auto @container">
<HStack space={2} alignItems="center">
@@ -48,6 +59,8 @@ export function FolderLayout({ folder, style }: Props) {
color="secondary"
size="sm"
variant="border"
onClick={handleSendAll}
disabled={sendAllAction == null}
>
Send All
</Button>

View File

@@ -9,7 +9,7 @@ import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles';
import { activeGrpcConnectionAtom, useGrpcEvents } from '../hooks/usePinnedGrpcConnection';
import { workspaceLayoutAtom } from '../lib/atoms';
import { Banner } from './core/Banner';
import { HotKeyList } from './core/HotKeyList';
import { HotkeyList } from './core/HotkeyList';
import { SplitLayout } from './core/SplitLayout';
import { GrpcRequestPane } from './GrpcRequestPane';
import { GrpcResponsePane } from './GrpcResponsePane';
@@ -117,7 +117,7 @@ export function GrpcConnectionLayout({ style }: Props) {
) : grpcEvents.length >= 0 ? (
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
) : (
<HotKeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
<HotkeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
)}
</div>
)

View File

@@ -270,6 +270,7 @@ export function GrpcRequestPane({
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 !mb-1.5"
storageKey="grpc_request_tabs_order"
>
<TabContent value="message">
<GrpcEditor

View File

@@ -16,7 +16,7 @@ import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor';
import { HotKeyList } from './core/HotKeyList';
import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
@@ -73,7 +73,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
minHeightPx={20}
firstSlot={() =>
activeConnection == null ? (
<HotKeyList
<HotkeyList
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (

View File

@@ -359,6 +359,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 mb-1.5"
storageKey="http_request_tabs_order"
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />

View File

@@ -15,7 +15,7 @@ import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { HotKeyList } from './core/HotKeyList';
import { HotkeyList } from './core/HotkeyList';
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
import { HttpStatusTag } from './core/HttpStatusTag';
import { LoadingIcon } from './core/LoadingIcon';
@@ -28,8 +28,8 @@ import { ErrorBoundary } from './ErrorBoundary';
import { HttpResponseTimeline } from './HttpResponseTimeline';
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
import { RequestBodyViewer } from './RequestBodyViewer';
import { ResponseCookies } from './ResponseCookies';
import { ResponseHeaders } from './ResponseHeaders';
import { ResponseInfo } from './ResponseInfo';
import { AudioViewer } from './responseViewers/AudioViewer';
import { CsvViewer } from './responseViewers/CsvViewer';
import { EventStreamViewer } from './responseViewers/EventStreamViewer';
@@ -52,7 +52,7 @@ interface Props {
const TAB_BODY = 'body';
const TAB_REQUEST = 'request';
const TAB_HEADERS = 'headers';
const TAB_INFO = 'info';
const TAB_COOKIES = 'cookies';
const TAB_TIMELINE = 'timeline';
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
@@ -67,16 +67,31 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const responseEvents = useHttpResponseEvents(activeResponse);
const cookieCount = useMemo(() => {
if (!responseEvents.data) return 0;
let count = 0;
for (const event of responseEvents.data) {
const e = event.event;
if (
(e.type === 'header_up' && e.name.toLowerCase() === 'cookie') ||
(e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie')
) {
count++;
}
}
return count;
}, [responseEvents.data]);
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
label: 'Preview Mode',
label: 'Response',
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: 'Pretty', value: 'pretty' },
{ label: 'Response', value: 'pretty' },
...(mimeType?.startsWith('image') ? [] : [{ label: 'Raw', value: 'raw' }]),
],
},
@@ -97,20 +112,22 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
/>
),
},
{
value: TAB_COOKIES,
label: 'Cookies',
rightSlot: cookieCount > 0 ? <CountBadge count={cookieCount} /> : null,
},
{
value: TAB_TIMELINE,
label: 'Timeline',
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
},
{
value: TAB_INFO,
label: 'Info',
},
],
[
activeResponse?.headers,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
cookieCount,
mimeType,
responseEvents.data?.length,
setViewMode,
@@ -139,7 +156,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
>
{activeResponse == null ? (
<HotKeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack
@@ -194,6 +211,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5"
storageKey="http_response_tabs_order"
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
@@ -249,8 +267,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_INFO}>
<ResponseInfo response={activeResponse} />
<TabContent value={TAB_COOKIES}>
<ResponseCookies response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} />

View File

@@ -1,10 +1,10 @@
import { hotkeyActions } from '../hooks/useHotKey';
import { HotKeyList } from './core/HotKeyList';
import { HotkeyList } from './core/HotkeyList';
export function KeyboardShortcutsDialog() {
return (
<div className="grid h-full">
<HotKeyList hotkeys={hotkeyActions} className="pb-6" />
<HotkeyList hotkeys={hotkeyActions} className="pb-6" />
</div>
);
}

View File

@@ -0,0 +1,225 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useMemo } from 'react';
import type { JSX } from 'react/jsx-runtime';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { CountBadge } from './core/CountBadge';
import { DetailsBanner } from './core/DetailsBanner';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
interface Props {
response: HttpResponse;
}
interface ParsedCookie {
name: string;
value: string;
domain?: string;
path?: string;
expires?: string;
maxAge?: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: string;
isDeleted?: boolean;
}
function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {
// Parse "Cookie: name=value; name2=value2" format
return cookieHeader.split(';').map((pair) => {
const [name = '', ...valueParts] = pair.split('=');
return {
name: name.trim(),
value: valueParts.join('=').trim(),
};
});
}
function parseSetCookieHeader(setCookieHeader: string): ParsedCookie {
// Parse "Set-Cookie: name=value; Domain=...; Path=..." format
const parts = setCookieHeader.split(';').map((p) => p.trim());
const [nameValue = '', ...attributes] = parts;
const [name = '', ...valueParts] = nameValue.split('=');
const cookie: ParsedCookie = {
name: name.trim(),
value: valueParts.join('=').trim(),
};
for (const attr of attributes) {
const [key = '', val] = attr.split('=').map((s) => s.trim());
const lowerKey = key.toLowerCase();
if (lowerKey === 'domain') cookie.domain = val;
else if (lowerKey === 'path') cookie.path = val;
else if (lowerKey === 'expires') cookie.expires = val;
else if (lowerKey === 'max-age') cookie.maxAge = val;
else if (lowerKey === 'secure') cookie.secure = true;
else if (lowerKey === 'httponly') cookie.httpOnly = true;
else if (lowerKey === 'samesite') cookie.sameSite = val;
}
// Detect if cookie is being deleted
if (cookie.maxAge !== undefined) {
const maxAgeNum = Number.parseInt(cookie.maxAge, 10);
if (!Number.isNaN(maxAgeNum) && maxAgeNum <= 0) {
cookie.isDeleted = true;
}
} else if (cookie.expires !== undefined) {
// Check if expires date is in the past
try {
const expiresDate = new Date(cookie.expires);
if (expiresDate.getTime() < Date.now()) {
cookie.isDeleted = true;
}
} catch {
// Invalid date, ignore
}
}
return cookie;
}
export function ResponseCookies({ response }: Props) {
const { data: events } = useHttpResponseEvents(response);
const { sentCookies, receivedCookies } = useMemo(() => {
if (!events) return { sentCookies: [], receivedCookies: [] };
// Use Maps to deduplicate by cookie name (latest value wins)
const sentMap = new Map<string, { name: string; value: string }>();
const receivedMap = new Map<string, ParsedCookie>();
for (const event of events) {
const e = event.event;
// Cookie headers sent (header_up with name=cookie)
if (e.type === 'header_up' && e.name.toLowerCase() === 'cookie') {
const cookies = parseCookieHeader(e.value);
for (const cookie of cookies) {
sentMap.set(cookie.name, cookie);
}
}
// Set-Cookie headers received (header_down with name=set-cookie)
if (e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie') {
const cookie = parseSetCookieHeader(e.value);
receivedMap.set(cookie.name, cookie);
}
}
return {
sentCookies: Array.from(sentMap.values()),
receivedCookies: Array.from(receivedMap.values()),
};
}, [events]);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.sent_cookies`}
summary={
<h2 className="flex items-center">
Sent Cookies <CountBadge showZero count={sentCookies.length} />
</h2>
}
>
{sentCookies.length === 0 ? (
<NoCookies />
) : (
<KeyValueRows>
{sentCookies.map((cookie, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="primary" key={i} label={cookie.name}>
{cookie.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.received_cookies`}
summary={
<h2 className="flex items-center">
Received Cookies <CountBadge showZero count={receivedCookies.length} />
</h2>
}
>
{receivedCookies.length === 0 ? (
<NoCookies />
) : (
<div className="flex flex-col gap-4">
{receivedCookies.map((cookie, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<div key={i} className="flex flex-col gap-1">
<div className="flex items-center gap-2 my-1">
<span
className={classNames(
'font-mono text-editor select-auto cursor-auto',
cookie.isDeleted ? 'line-through opacity-60 text-text-subtle' : 'text-text',
)}
>
{cookie.name}
<span className="text-text-subtlest select-auto cursor-auto mx-0.5">=</span>
{cookie.value}
</span>
{cookie.isDeleted && (
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded">
Deleted
</span>
)}
</div>
<KeyValueRows>
{[
cookie.domain && (
<KeyValueRow labelColor="info" label="Domain" key="domain">
{cookie.domain}
</KeyValueRow>
),
cookie.path && (
<KeyValueRow labelColor="info" label="Path" key="path">
{cookie.path}
</KeyValueRow>
),
cookie.expires && (
<KeyValueRow labelColor="info" label="Expires" key="expires">
{cookie.expires}
</KeyValueRow>
),
cookie.maxAge && (
<KeyValueRow labelColor="info" label="Max-Age" key="maxAge">
{cookie.maxAge}
</KeyValueRow>
),
cookie.secure && (
<KeyValueRow labelColor="info" label="Secure" key="secure">
true
</KeyValueRow>
),
cookie.httpOnly && (
<KeyValueRow labelColor="info" label="HttpOnly" key="httpOnly">
true
</KeyValueRow>
),
cookie.sameSite && (
<KeyValueRow labelColor="info" label="SameSite" key="sameSite">
{cookie.sameSite}
</KeyValueRow>
),
].filter((item): item is JSX.Element => Boolean(item))}
</KeyValueRows>
</div>
))}
</div>
)}
</DetailsBanner>
</div>
);
}
function NoCookies() {
return <span className="text-text-subtlest text-sm italic">No Cookies</span>;
}

View File

@@ -1,7 +1,9 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import type { HttpResponse } from '@yaakapp-internal/models';
import { useMemo } from 'react';
import { CountBadge } from './core/CountBadge';
import { DetailsBanner } from './core/DetailsBanner';
import { IconButton } from './core/IconButton';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
interface Props {
@@ -25,11 +27,33 @@ export function ResponseHeaders({ response }: Props) {
);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows>
<KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span>
<IconButton
iconSize="sm"
className="inline-block w-auto !h-auto opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
title="Open in browser"
/>
</div>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Remote Address">
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Version">
{response.version ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<DetailsBanner
storageKey={`${response.requestId}.request_headers`}
summary={
<h2 className="flex items-center">
Request <CountBadge showZero count={requestHeaders.length} />
Request Headers <CountBadge showZero count={requestHeaders.length} />
</h2>
}
>
@@ -51,7 +75,7 @@ export function ResponseHeaders({ response }: Props) {
storageKey={`${response.requestId}.response_headers`}
summary={
<h2 className="flex items-center">
Response <CountBadge showZero count={responseHeaders.length} />
Response Headers <CountBadge showZero count={responseHeaders.length} />
</h2>
}
>
@@ -61,7 +85,7 @@ export function ResponseHeaders({ response }: Props) {
<KeyValueRows>
{responseHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="primary" key={i} label={h.name}>
<KeyValueRow labelColor="info" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}

View File

@@ -10,11 +10,13 @@ import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize';
import { CountBadge } from '../core/CountBadge';
import { Icon } from '../core/Icon';
import { HStack } from '../core/Stacks';
import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs';
import { HeaderSize } from '../HeaderSize';
import { SettingsCertificates } from './SettingsCertificates';
import { SettingsGeneral } from './SettingsGeneral';
import { SettingsHotkeys } from './SettingsHotkeys';
import { SettingsInterface } from './SettingsInterface';
import { SettingsLicense } from './SettingsLicense';
import { SettingsPlugins } from './SettingsPlugins';
@@ -28,6 +30,7 @@ interface Props {
const TAB_GENERAL = 'general';
const TAB_INTERFACE = 'interface';
const TAB_THEME = 'theme';
const TAB_SHORTCUTS = 'shortcuts';
const TAB_PROXY = 'proxy';
const TAB_CERTIFICATES = 'certificates';
const TAB_PLUGINS = 'plugins';
@@ -36,6 +39,7 @@ const tabs = [
TAB_GENERAL,
TAB_THEME,
TAB_INTERFACE,
TAB_SHORTCUTS,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_PLUGINS,
@@ -45,7 +49,9 @@ export type SettingsTab = (typeof tabs)[number];
export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
const [tab, setTab] = useState<string | undefined>(tabFromQuery);
// 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();
@@ -95,6 +101,24 @@ export default function Settings({ hide }: Props) {
value,
label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
leftSlot:
value === TAB_GENERAL ? (
<Icon icon="settings" className="text-secondary" />
) : value === TAB_THEME ? (
<Icon icon="palette" className="text-secondary" />
) : value === TAB_INTERFACE ? (
<Icon icon="columns_2" className="text-secondary" />
) : value === TAB_SHORTCUTS ? (
<Icon icon="keyboard" className="text-secondary" />
) : value === TAB_CERTIFICATES ? (
<Icon icon="shield_check" className="text-secondary" />
) : value === TAB_PROXY ? (
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
rightSlot:
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
@@ -117,8 +141,11 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
<SettingsPlugins />
<SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy />

View File

@@ -0,0 +1,326 @@
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import {
defaultHotkeys,
formatHotkeyString,
getHotkeyScope,
type HotkeyAction,
hotkeyActions,
hotkeysAtom,
useHotkeyLabel,
} from '../../hooks/useHotKey';
import { capitalize } from '../../lib/capitalize';
import { showDialog } from '../../lib/dialog';
import { Button } from '../core/Button';
import { Dropdown, type DropdownItem } from '../core/Dropdown';
import { Heading } from '../core/Heading';
import { HotkeyRaw } from '../core/Hotkey';
import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton';
import { HStack, VStack } from '../core/Stacks';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
const HOLD_KEYS = ['Shift', 'Control', 'Alt', 'Meta'];
const LAYOUT_INSENSITIVE_KEYS = ['Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote'];
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
function eventToHotkeyString(e: KeyboardEvent): string | null {
// Don't capture modifier-only key presses
if (HOLD_KEYS.includes(e.key)) {
return null;
}
const parts: string[] = [];
// Add modifiers in consistent order (Meta, Control, Alt, Shift)
if (e.metaKey) {
parts.push('Meta');
}
if (e.ctrlKey) {
parts.push('Control');
}
if (e.altKey) {
parts.push('Alt');
}
if (e.shiftKey) {
parts.push('Shift');
}
// Get the main key - use the same logic as useHotKey.ts
const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;
parts.push(key);
return parts.join('+');
}
export function SettingsHotkeys() {
const settings = useAtomValue(settingsAtom);
const hotkeys = useAtomValue(hotkeysAtom);
if (settings == null) {
return null;
}
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Keyboard Shortcuts</Heading>
<p className="text-text-subtle">
Click the menu button to add, remove, or reset keyboard shortcuts.
</p>
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{hotkeyActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action]}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
);
}
interface HotkeyRowProps {
action: HotkeyAction;
currentKeys: string[];
defaultKeys: string[];
onSave: (keys: string[]) => Promise<void>;
onReset: () => Promise<void>;
}
function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {
const label = useHotkeyLabel(action);
const scope = capitalize(getHotkeyScope(action).replace(/_/g, ' '));
const isCustomized = !arraysEqual(currentKeys, defaultKeys);
const isDisabled = currentKeys.length === 0;
const handleStartRecording = useCallback(() => {
showDialog({
id: `record-hotkey-${action}`,
title: label,
size: 'sm',
render: ({ hide }) => (
<RecordHotkeyDialog
label={label}
onSave={async (key) => {
await onSave([...currentKeys, key]);
hide();
}}
onCancel={hide}
/>
),
});
}, [action, label, currentKeys, onSave]);
const handleRemove = useCallback(
async (keyToRemove: string) => {
const newKeys = currentKeys.filter((k) => k !== keyToRemove);
await onSave(newKeys);
},
[currentKeys, onSave],
);
const handleClearAll = useCallback(async () => {
await onSave([]);
}, [onSave]);
// Build dropdown items dynamically
const dropdownItems: DropdownItem[] = [
{
label: 'Add Keyboard Shortcut',
leftSlot: <Icon icon="plus" />,
onSelect: handleStartRecording,
},
];
// Add remove options for each existing shortcut
if (!isDisabled) {
currentKeys.forEach((key) => {
dropdownItems.push({
label: (
<HStack space={1.5}>
<span>Remove</span>
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
</HStack>
),
leftSlot: <Icon icon="trash" />,
onSelect: () => handleRemove(key),
});
});
if (currentKeys.length > 1) {
dropdownItems.push(
{
type: 'separator',
},
{
label: 'Remove All Shortcuts',
leftSlot: <Icon icon="trash" />,
onSelect: handleClearAll,
},
);
}
}
if (isCustomized) {
dropdownItems.push({
type: 'separator',
});
dropdownItems.push({
label: 'Reset to Default',
leftSlot: <Icon icon="refresh" />,
onSelect: onReset,
});
}
return (
<TableRow>
<TableCell>
<span className="text-sm text-text-subtlest">{scope}</span>
</TableCell>
<TableCell>
<span className="text-sm">{label}</span>
</TableCell>
<TableCell>
<HStack space={1.5} className="py-1">
{isDisabled ? (
<span className="text-text-subtlest">Disabled</span>
) : (
currentKeys.map((k) => (
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
))
)}
</HStack>
</TableCell>
<TableCell align="right">
<Dropdown items={dropdownItems}>
<IconButton
icon="ellipsis_vertical"
size="sm"
title="Hotkey actions"
className="ml-auto text-text-subtlest"
/>
</Dropdown>
</TableCell>
</TableRow>
);
}
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((v, i) => v === sortedB[i]);
}
interface RecordHotkeyDialogProps {
label: string;
onSave: (key: string) => void;
onCancel: () => void;
}
function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {
const [recordedKey, setRecordedKey] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!isFocused) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
onCancel();
return;
}
const hotkeyString = eventToHotkeyString(e);
if (hotkeyString) {
setRecordedKey(hotkeyString);
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true });
};
}, [isFocused, onCancel]);
const handleSave = useCallback(() => {
if (recordedKey) {
onSave(recordedKey);
}
}, [recordedKey, onSave]);
return (
<VStack space={4}>
<div>
<p className="text-text-subtle mb-2">
Record a key combination for <span className="font-semibold">{label}</span>
</p>
<button
type="button"
data-disable-hotkey
aria-label="Keyboard shortcut input"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onClick={(e) => {
e.preventDefault();
e.currentTarget.focus();
}}
className={classNames(
'flex items-center justify-center',
'px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full',
'border-border-subtle focus:border-border-focus',
)}
>
{recordedKey ? (
<HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />
) : (
<span className="text-text-subtlest">Press keys...</span>
)}
</button>
</div>
<HStack space={2} justifyContent="end">
<Button color="secondary" onClick={onCancel}>
Cancel
</Button>
<Button color="primary" onClick={handleSave} disabled={!recordedKey}>
Save
</Button>
</HStack>
</VStack>
);
}

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { Plugin } from '@yaakapp-internal/models';
import { pluginsAtom } from '@yaakapp-internal/models';
import { patchModel, pluginsAtom } from '@yaakapp-internal/models';
import type { PluginVersion } from '@yaakapp-internal/plugins';
import {
checkPluginUpdates,
@@ -15,9 +15,11 @@ import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useInstallPlugin } from '../../hooks/useInstallPlugin';
import { usePluginInfo } from '../../hooks/usePluginInfo';
import { usePluginsKey, useRefreshPlugins } from '../../hooks/usePlugins';
import { appInfo } from '../../lib/appInfo';
import { showConfirmDelete } from '../../lib/confirm';
import { minPromiseMillis } from '../../lib/minPromiseMillis';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { CountBadge } from '../core/CountBadge';
import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton';
@@ -31,12 +33,28 @@ import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { EmptyStateText } from '../EmptyStateText';
import { SelectFile } from '../SelectFile';
export function SettingsPlugins() {
function isPluginBundled(plugin: Plugin, vendoredPluginDir: string): boolean {
const normalizedDir = plugin.directory.replace(/\\/g, '/');
const normalizedVendoredDir = vendoredPluginDir.replace(/\\/g, '/');
return (
normalizedDir.includes(normalizedVendoredDir) ||
normalizedDir.includes('vendored/plugins') ||
normalizedDir.includes('/plugins/')
);
}
interface SettingsPluginsProps {
defaultSubtab?: string;
}
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
const [directory, setDirectory] = useState<string | null>(null);
const plugins = useAtomValue(pluginsAtom);
const bundledPlugins = plugins.filter((p) => isPluginBundled(p, appInfo.vendoredPluginDir));
const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir));
const createPlugin = useInstallPlugin();
const refreshPlugins = useRefreshPlugins();
const [tab, setTab] = useState<string>();
const [tab, setTab] = useState<string | undefined>(defaultSubtab);
return (
<div className="h-full">
<Tabs
@@ -49,7 +67,12 @@ export function SettingsPlugins() {
{
label: 'Installed',
value: 'installed',
rightSlot: <CountBadge count={plugins.length} />,
rightSlot: <CountBadge count={installedPlugins.length} />,
},
{
label: 'Bundled',
value: 'bundled',
rightSlot: <CountBadge count={bundledPlugins.length} />,
},
]}
>
@@ -58,7 +81,7 @@ export function SettingsPlugins() {
</TabContent>
<TabContent value="installed" className="pb-0">
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
<InstalledPlugins />
<InstalledPlugins plugins={installedPlugins} />
<footer className="grid grid-cols-[minmax(0,1fr)_auto] -mx-4 py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
<SelectFile
size="xs"
@@ -101,6 +124,9 @@ export function SettingsPlugins() {
</footer>
</div>
</TabContent>
<TabContent value="bundled" className="pb-0">
<BundledPlugins plugins={bundledPlugins} />
</TabContent>
</Tabs>
</div>
);
@@ -119,6 +145,27 @@ function PluginTableRowForInstalledPlugin({ plugin }: { plugin: Plugin }) {
name={info.name}
displayName={info.displayName}
url={plugin.url}
showCheckbox={true}
showUninstall={true}
/>
);
}
function PluginTableRowForBundledPlugin({ plugin }: { plugin: Plugin }) {
const info = usePluginInfo(plugin.id).data;
if (info == null) {
return null;
}
return (
<PluginTableRow
plugin={plugin}
version={info.version}
name={info.name}
displayName={info.displayName}
url={plugin.url}
showCheckbox={true}
showUninstall={false}
/>
);
}
@@ -134,6 +181,7 @@ function PluginTableRowForRemotePluginVersion({ pluginVersion }: { pluginVersion
name={pluginVersion.name}
displayName={pluginVersion.displayName}
url={pluginVersion.url}
showCheckbox={false}
/>
);
}
@@ -144,12 +192,16 @@ function PluginTableRow({
version,
displayName,
url,
showCheckbox = true,
showUninstall = true,
}: {
plugin: Plugin | null;
name: string;
version: string;
displayName: string;
url: string | null;
showCheckbox?: boolean;
showUninstall?: boolean;
}) {
const updates = usePluginUpdates();
const latestVersion = updates.data?.plugins.find((u) => u.name === name)?.version;
@@ -158,9 +210,26 @@ function PluginTableRow({
mutationFn: (name: string) => installPlugin(name, null),
});
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
const refreshPlugins = useRefreshPlugins();
return (
<TableRow>
{showCheckbox && (
<TableCell className="!py-0">
<Checkbox
hideLabel
title={plugin?.enabled ? 'Disable plugin' : 'Enable plugin'}
checked={plugin?.enabled ?? false}
disabled={plugin == null}
onChange={async (enabled) => {
if (plugin) {
await patchModel(plugin, { enabled });
refreshPlugins.mutate();
}
}}
/>
</TableCell>
)}
<TableCell className="font-semibold">
{url ? (
<Link noUnderline href={url}>
@@ -170,6 +239,9 @@ function PluginTableRow({
displayName
)}
</TableCell>
<TableCell>
<InlineCode>{name}</InlineCode>
</TableCell>
<TableCell>
<HStack space={1.5}>
<InlineCode>{version}</InlineCode>
@@ -206,7 +278,7 @@ function PluginTableRow({
Install
</Button>
) : null}
{uninstall != null && (
{showUninstall && uninstall != null && (
<Button
size="xs"
title="Uninstall plugin"
@@ -253,6 +325,7 @@ function PluginSearch() {
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
@@ -270,9 +343,7 @@ function PluginSearch() {
);
}
function InstalledPlugins() {
const plugins = useAtomValue(pluginsAtom);
function InstalledPlugins({ plugins }: { plugins: Plugin[] }) {
return plugins.length === 0 ? (
<div className="pb-4">
<EmptyStateText className="text-center">
@@ -285,6 +356,8 @@ function InstalledPlugins() {
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell className="w-0" />
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
@@ -299,6 +372,31 @@ function InstalledPlugins() {
);
}
function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
return plugins.length === 0 ? (
<div className="pb-4">
<EmptyStateText className="text-center">No bundled plugins found.</EmptyStateText>
</div>
) : (
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell className="w-0" />
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => (
<PluginTableRowForBundledPlugin key={p.id} plugin={p} />
))}
</tbody>
</Table>
);
}
function usePromptUninstall(pluginId: string | null, name: string) {
const mut = useMutation({
mutationKey: ['uninstall_plugin', pluginId],

View File

@@ -27,8 +27,6 @@ import { selectAtom } from 'jotai/utils';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { moveToWorkspace } from '../commands/moveToWorkspace';
import { openFolderSettings } from '../commands/openFolderSettings';
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
import { activeEnvironmentAtom } from '../hooks/useActiveEnvironment';
import { activeFolderIdAtom } from '../hooks/useActiveFolderId';
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
@@ -49,7 +47,6 @@ import { jotaiStore } from '../lib/jotai';
import { resolvedModelName } from '../lib/resolvedModelName';
import { isSidebarFocused } from '../lib/scopes';
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
import { invokeCmd } from '../lib/tauri';
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import type { FieldDef } from './core/Editor/filter/extension';
@@ -331,20 +328,6 @@ function Sidebar({ className }: { className?: string }) {
leftSlot: <Icon icon="folder_cog" />,
onSelect: () => openFolderSettings(child.id),
},
{
label: 'Send All',
hidden: !(items.length === 1 && child.model === 'folder'),
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => {
const environment = jotaiStore.get(activeEnvironmentAtom);
const cookieJar = jotaiStore.get(activeCookieJarAtom);
invokeCmd('cmd_send_folder', {
folderId: child.id,
environmentId: environment?.id,
cookieJarId: cookieJar?.id,
});
},
},
{
label: 'Send',
hotKeyAction: 'request.send',

View File

@@ -234,6 +234,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-1 !mb-1.5"
storageKey="websocket_request_tabs_order"
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />

View File

@@ -18,7 +18,7 @@ import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor';
import { HotKeyList } from './core/HotKeyList';
import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { LoadingIcon } from './core/LoadingIcon';
@@ -71,7 +71,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
minHeightPx={20}
firstSlot={() =>
activeConnection == null ? (
<HotKeyList
<HotkeyList
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (

Some files were not shown because too many files have changed in this diff Show More