Compare commits

...

21 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
73 changed files with 2423 additions and 845 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,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
.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.10/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"linter": {
"enabled": true,
"rules": {

932
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -62,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",
@@ -93,6 +95,8 @@
"@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,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,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

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

@@ -646,6 +646,12 @@ export class PluginInstance {
},
};
},
openExternalUrl: async (url) => {
await this.#sendForReply(context, {
type: 'open_external_url_request',
url,
});
},
},
prompt: {
text: async (args) => {

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

View File

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

@@ -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()
@@ -167,7 +167,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
),
}),
},
},
async ({ workspaceId: ogWorkspaceId, ...args }) => {
const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);
@@ -192,7 +192,7 @@ 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().describe('Workspace ID'),
name: z.string().optional().describe('Request name'),
@@ -256,7 +256,7 @@ export function registerHttpRequestTools(server: McpServer, ctx: McpServerContex
'- "awsv4": { accessKeyId: "...", secretAccessKey: "...", service: "sts", region: "us-east-1", sessionToken: "..." }\n' +
'- "none": {}',
),
}),
},
},
async ({ id, workspaceId, ...updates }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
@@ -282,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

@@ -8,7 +8,6 @@ export function registerWindowTools(server: McpServer, ctx: McpServerContext) {
{
title: 'Get Workspace ID',
description: 'Get the current workspace ID',
inputSchema: {},
},
async () => {
const workspaceCtx = await getWorkspaceContext(ctx);
@@ -30,7 +29,6 @@ export function registerWindowTools(server: McpServer, ctx: McpServerContext) {
{
title: 'Get Environment ID',
description: 'Get the current environment ID',
inputSchema: {},
},
async () => {
const workspaceCtx = await getWorkspaceContext(ctx);

View File

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

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

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

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

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

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

@@ -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")]

View File

@@ -57,7 +57,7 @@ pub struct PluginManager {
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);
@@ -214,6 +214,7 @@ impl PluginManager {
plugin_context,
plugin,
&InternalEventPayload::TerminateRequest,
Duration::from_secs(5),
)
.await?;
}
@@ -251,6 +252,7 @@ impl PluginManager {
dir: plugin.directory.clone(),
watch: !is_vendored && !is_installed,
}),
Duration::from_secs(5),
)
.await?;
@@ -318,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)
@@ -373,13 +375,20 @@ impl PluginManager {
plugin_context: &PluginContext,
plugin: &PluginHandle,
payload: &InternalEventPayload,
timeout_duration: Duration,
) -> Result<InternalEvent> {
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()]).await?;
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!(
@@ -393,9 +402,10 @@ impl PluginManager {
&self,
plugin_context: &PluginContext,
payload: &InternalEventPayload,
timeout_duration: Duration,
) -> Result<Vec<InternalEvent>> {
let plugins = { self.plugin_handles.lock().await.clone() };
self.send_to_plugins_and_wait(plugin_context, payload, plugins).await
self.send_to_plugins_and_wait(plugin_context, payload, plugins, timeout_duration).await
}
async fn send_to_plugins_and_wait(
@@ -403,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;
@@ -436,8 +447,8 @@ impl PluginManager {
}
};
// Timeout after 10 seconds to prevent hanging forever if plugin doesn't respond
if timeout(Duration::from_secs(5), collect_events).await.is_err() {
// 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(),
@@ -475,6 +486,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetThemesRequest(GetThemesRequest {}),
Duration::from_secs(5),
)
.await?;
@@ -496,6 +508,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetGrpcRequestActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -517,6 +530,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetHttpRequestActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -538,6 +552,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetWebsocketRequestActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -559,6 +574,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetWorkspaceActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -580,6 +596,7 @@ impl PluginManager {
.send_and_wait(
&PluginContext::new(window),
&InternalEventPayload::GetFolderActionsRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -646,6 +663,7 @@ impl PluginManager {
context_id,
},
),
Duration::from_secs(5),
)
.await?;
match event.payload {
@@ -752,6 +770,7 @@ impl PluginManager {
.send_and_wait(
&plugin_context,
&InternalEventPayload::GetHttpAuthenticationSummaryRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -812,6 +831,7 @@ impl PluginManager {
context_id,
},
),
Duration::from_secs(5),
)
.await?;
match event.payload {
@@ -865,6 +885,7 @@ impl PluginManager {
},
},
),
Duration::from_secs(300), // 5 minutes for OAuth flows
)
.await?;
Ok(())
@@ -902,6 +923,7 @@ impl PluginManager {
plugin_context,
&plugin,
&InternalEventPayload::CallHttpAuthenticationRequest(req),
Duration::from_secs(300), // 5 minutes for OAuth flows
)
.await?;
match event.payload {
@@ -923,6 +945,7 @@ impl PluginManager {
.send_and_wait(
&plugin_context,
&InternalEventPayload::GetTemplateFunctionSummaryRequest(EmptyPayload {}),
Duration::from_secs(5),
)
.await?;
@@ -955,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:}")))?;
@@ -992,6 +1019,7 @@ impl PluginManager {
&InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(),
}),
Duration::from_secs(5),
)
.await?;
@@ -1034,6 +1062,7 @@ impl PluginManager {
filter: filter.to_string(),
content: content.to_string(),
}),
Duration::from_secs(5),
)
.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);

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

@@ -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,
@@ -97,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} />
@@ -119,6 +141,9 @@ 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 defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>

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

@@ -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']}
/>
) : (

View File

@@ -33,7 +33,7 @@ import { jotaiStore } from '../lib/jotai';
import { CreateDropdown } from './CreateDropdown';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { HotKeyList } from './core/HotKeyList';
import { HotkeyList } from './core/HotkeyList';
import { FeedbackLink } from './core/Link';
import { HStack } from './core/Stacks';
import { ErrorBoundary } from './ErrorBoundary';
@@ -73,14 +73,14 @@ export function Workspace() {
const newWidth = startWidth.current + (x - xStart);
if (newWidth < 50) {
await setSidebarHidden(true);
if (!sidebarHidden) await setSidebarHidden(true);
resetWidth();
} else {
await setSidebarHidden(false);
if (sidebarHidden) await setSidebarHidden(false);
setWidth(newWidth);
}
},
[width, setSidebarHidden, resetWidth, setWidth],
[width, sidebarHidden, setSidebarHidden, resetWidth, setWidth],
);
const handleResizeStart = useCallback(() => {
@@ -233,7 +233,7 @@ function WorkspaceBody() {
}
return (
<HotKeyList
<HotkeyList
hotkeys={['model.create', 'sidebar.focus', 'settings.show']}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">

View File

@@ -34,7 +34,7 @@ import { jotaiStore } from '../../lib/jotai';
import { ErrorBoundary } from '../ErrorBoundary';
import { Overlay } from '../Overlay';
import { Button } from './Button';
import { HotKey } from './HotKey';
import { Hotkey } from './Hotkey';
import { Icon } from './Icon';
import { LoadingIcon } from './LoadingIcon';
import { Separator } from './Separator';
@@ -177,18 +177,23 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
const child = useMemo(() => {
const existingChild = Children.only(children);
const originalOnClick = existingChild.props?.onClick;
const props: HTMLAttributes<HTMLButtonElement> & { ref: RefObject<HTMLButtonElement | null> } =
{
...existingChild.props,
ref: buttonRef,
'aria-haspopup': 'true',
onClick:
existingChild.props?.onClick ??
((e: MouseEvent<HTMLButtonElement>) => {
onClick: (e: MouseEvent<HTMLButtonElement>) => {
// Call original onClick first if it exists
originalOnClick?.(e);
// Only toggle dropdown if event wasn't prevented
if (!e.defaultPrevented) {
e.preventDefault();
e.stopPropagation();
handleSetIsOpen((o) => !o); // Toggle dropdown
}),
}
},
};
return cloneElement(existingChild, props);
}, [children, handleSetIsOpen]);
@@ -630,7 +635,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
[focused],
);
const rightSlot = item.rightSlot ?? <HotKey action={item.hotKeyAction ?? null} />;
const rightSlot = item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />;
return (
<Button

View File

@@ -9,23 +9,34 @@ interface Props {
variant?: 'text' | 'with-bg';
}
export function HotKey({ action, className, variant }: Props) {
export function Hotkey({ action, className, variant }: Props) {
const labelParts = useFormattedHotkey(action);
if (labelParts === null) {
return null;
}
return <HotkeyRaw labelParts={labelParts} className={className} variant={variant} />;
}
interface HotkeyRawProps {
labelParts: string[];
className?: string;
variant?: 'text' | 'with-bg';
}
export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
return (
<HStack
className={classNames(
className,
variant === 'with-bg' && 'rounded border',
'text-text-subtlest',
variant === 'with-bg' &&
'rounded bg-surface-highlight px-1 border border-border text-text-subtle',
variant === 'text' && 'text-text-subtlest',
)}
>
{labelParts.map((char, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<div key={index} className="min-w-[1.1em] text-center">
<div key={index} className="min-w-[1em] text-center">
{char}
</div>
))}

View File

@@ -1,14 +1,14 @@
import classNames from 'classnames';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKeyLabel } from '../../hooks/useHotKey';
import { useHotkeyLabel } from '../../hooks/useHotKey';
interface Props {
action: HotkeyAction;
className?: string;
}
export function HotKeyLabel({ action, className }: Props) {
const label = useHotKeyLabel(action);
export function HotkeyLabel({ action, className }: Props) {
const label = useHotkeyLabel(action);
return (
<span className={classNames(className, 'text-text-subtle whitespace-nowrap')}>{label}</span>
);

View File

@@ -2,8 +2,8 @@ import classNames from 'classnames';
import type { ReactNode } from 'react';
import { Fragment } from 'react';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { HotKey } from './HotKey';
import { HotKeyLabel } from './HotKeyLabel';
import { Hotkey } from './Hotkey';
import { HotkeyLabel } from './HotkeyLabel';
interface Props {
hotkeys: HotkeyAction[];
@@ -11,14 +11,14 @@ interface Props {
className?: string;
}
export const HotKeyList = ({ hotkeys, bottomSlot, className }: Props) => {
export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
return (
<div className={classNames(className, 'h-full flex items-center justify-center')}>
<div className="grid gap-2 grid-cols-[auto_auto]">
{hotkeys.map((hotkey) => (
<Fragment key={hotkey}>
<HotKeyLabel className="truncate" action={hotkey} />
<HotKey className="ml-4" action={hotkey} />
<HotkeyLabel className="truncate" action={hotkey} />
<Hotkey className="ml-4" action={hotkey} />
</Fragment>
))}
{bottomSlot}

View File

@@ -127,6 +127,7 @@ import {
UploadIcon,
VariableIcon,
Wand2Icon,
WifiIcon,
WrenchIcon,
XIcon,
} from 'lucide-react';
@@ -260,6 +261,7 @@ const icons = {
update: RefreshCcwIcon,
upload: UploadIcon,
variable: VariableIcon,
wifi: WifiIcon,
wrench: WrenchIcon,
x: XIcon,
_unknown: ShieldAlertIcon,

View File

@@ -224,7 +224,7 @@ export function PairEditor({
const side = computeSideForDragMove(overPair.id, e);
const overIndex = pairs.findIndex((p) => p.id === overId);
const hoveredIndex = overIndex + (side === 'above' ? 0 : 1);
const hoveredIndex = overIndex + (side === 'before' ? 0 : 1);
setHoveredIndex(hoveredIndex);
},

View File

@@ -51,12 +51,21 @@ export function TableRow({ children }: { children: ReactNode }) {
return <tr>{children}</tr>;
}
export function TableCell({ children, className }: { children: ReactNode; className?: string }) {
export function TableCell({
children,
className,
align = 'left',
}: {
children: ReactNode;
className?: string;
align?: 'left' | 'center' | 'right';
}) {
return (
<td
className={classNames(
className,
'py-2 [&:not(:first-child)]:pl-4 text-left whitespace-nowrap',
'py-2 [&:not(:first-child)]:pl-4 whitespace-nowrap',
align === 'left' ? 'text-left' : align === 'center' ? 'text-center' : 'text-right',
)}
>
{children}

View File

@@ -1,6 +1,20 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
closestCenter,
DndContext,
DragOverlay,
PointerSensor,
useDraggable,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useEffect, useRef } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useKeyValue } from '../../../hooks/useKeyValue';
import { computeSideForDragMove } from '../../../lib/dnd';
import { DropMarker } from '../../DropMarker';
import { ErrorBoundary } from '../../ErrorBoundary';
import type { ButtonProps } from '../Button';
import { Button } from '../Button';
@@ -13,11 +27,13 @@ export type TabItem =
value: string;
label: string;
hidden?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
| {
value: string;
options: Omit<RadioDropdownProps, 'children'>;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
@@ -31,6 +47,7 @@ interface Props {
children: ReactNode;
addBorders?: boolean;
layout?: 'horizontal' | 'vertical';
storageKey?: string | string[];
}
export function Tabs({
@@ -38,13 +55,62 @@ export function Tabs({
onChangeValue,
label,
children,
tabs,
tabs: originalTabs,
className,
tabListClassName,
addBorders,
layout = 'vertical',
storageKey,
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const reorderable = !!storageKey;
// Use key-value storage for persistence if storageKey is provided
const { value: savedOrder, set: setSavedOrder } = useKeyValue<string[]>({
namespace: 'global',
key: storageKey ?? ['tabs_order', 'default'],
fallback: [],
});
// State for ordered tabs
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
const [isDragging, setIsDragging] = useState<TabItem | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
// Reorder tabs based on saved order when tabs or savedOrder changes
useEffect(() => {
if (!storageKey || savedOrder == null || savedOrder.length === 0) {
setOrderedTabs(originalTabs);
return;
}
// Create a map of tab values to tab items
const tabMap = new Map(originalTabs.map((tab) => [tab.value, tab]));
// Reorder based on saved order, adding any new tabs at the end
const reordered: TabItem[] = [];
const seenValues = new Set<string>();
// Add tabs in saved order
for (const value of savedOrder) {
const tab = tabMap.get(value);
if (tab) {
reordered.push(tab);
seenValues.add(value);
}
}
// Add any new tabs that weren't in the saved order
for (const tab of originalTabs) {
if (!seenValues.has(tab.value)) {
reordered.push(tab);
}
}
setOrderedTabs(reordered);
}, [originalTabs, savedOrder, storageKey]);
const tabs = storageKey ? orderedTabs : originalTabs;
value = value ?? tabs[0]?.value;
@@ -68,6 +134,149 @@ export function Tabs({
}
}, [value]);
// Drag and drop handlers
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
const onDragStart = useCallback(
(e: DragStartEvent) => {
const tab = tabs.find((t) => t.value === e.active.id);
setIsDragging(tab ?? null);
},
[tabs],
);
const onDragMove = useCallback(
(e: DragMoveEvent) => {
const overId = e.over?.id as string | undefined;
if (!overId) return setHoveredIndex(null);
const overTab = tabs.find((t) => t.value === overId);
if (overTab == null) return setHoveredIndex(null);
// For vertical layout, tabs are arranged horizontally (side-by-side)
const orientation = layout === 'vertical' ? 'horizontal' : 'vertical';
const side = computeSideForDragMove(overTab.value, e, orientation);
// If computeSideForDragMove returns null (shouldn't happen but be safe), default to null
if (side === null) return setHoveredIndex(null);
const overIndex = tabs.findIndex((t) => t.value === overId);
const hoveredIndex = overIndex + (side === 'before' ? 0 : 1);
setHoveredIndex(hoveredIndex);
},
[tabs, layout],
);
const onDragCancel = useCallback(() => {
setIsDragging(null);
setHoveredIndex(null);
}, []);
const onDragEnd = useCallback(
(e: DragEndEvent) => {
setIsDragging(null);
setHoveredIndex(null);
const activeId = e.active.id as string | undefined;
const overId = e.over?.id as string | undefined;
if (!activeId || !overId || activeId === overId) return;
const from = tabs.findIndex((t) => t.value === activeId);
const baseTo = tabs.findIndex((t) => t.value === overId);
const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
if (from !== -1 && to !== -1 && from !== to) {
const newTabs = [...tabs];
const [moved] = newTabs.splice(from, 1);
if (moved === undefined) return;
newTabs.splice(to > from ? to - 1 : to, 0, moved);
setOrderedTabs(newTabs);
// Save order to storage
setSavedOrder(newTabs.map((t) => t.value)).catch(console.error);
}
},
[tabs, hoveredIndex, setSavedOrder],
);
const tabButtons = useMemo(() => {
const items: ReactNode[] = [];
tabs.forEach((t, i) => {
if ('hidden' in t && t.hidden) {
return;
}
const isActive = t.value === value;
const showDropMarkerBefore = hoveredIndex === i;
if (showDropMarkerBefore) {
items.push(
<div
key={`marker-${t.value}`}
className={classNames(
'relative',
layout === 'vertical' ? 'w-0' : 'h-0',
)}
>
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
</div>
);
}
items.push(
<TabButton
key={t.value}
tab={t}
isActive={isActive}
addBorders={addBorders}
layout={layout}
reorderable={reorderable}
isDragging={isDragging?.value === t.value}
onChangeValue={onChangeValue}
/>
);
});
return items;
}, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]);
const tabList = (
<div
role="tablist"
aria-label={label}
className={classNames(
tabListClassName,
addBorders && layout === 'horizontal' && 'pl-3 -ml-1',
addBorders && layout === 'vertical' && 'ml-0 mb-2',
'flex items-center hide-scrollbars',
layout === 'horizontal' && 'h-full overflow-auto p-2',
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
// Give space for button focus states within overflow boundary.
!addBorders && layout === 'vertical' && 'py-1 pl-3 -ml-5 pr-1',
)}
>
<div
className={classNames(
layout === 'horizontal' && 'flex flex-col w-full pb-3 mb-auto',
layout === 'vertical' && 'flex flex-row flex-shrink-0 w-full',
)}
>
{tabButtons}
{hoveredIndex === tabs.length && (
<div
className={classNames(
'relative',
layout === 'vertical' ? 'w-0' : 'h-0',
)}
>
<DropMarker orientation={layout === 'vertical' ? 'vertical' : 'horizontal'} />
</div>
)}
</div>
</div>
);
return (
<div
ref={ref}
@@ -79,104 +288,168 @@ export function Tabs({
layout === 'vertical' && 'grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
)}
>
<div
role="tablist"
aria-label={label}
className={classNames(
tabListClassName,
addBorders && layout === 'horizontal' && 'pl-3 -ml-1',
addBorders && layout === 'vertical' && 'ml-0 mb-2',
'flex items-center hide-scrollbars',
layout === 'horizontal' && 'h-full overflow-auto p-2',
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
// Give space for button focus states within overflow boundary.
!addBorders && layout === 'vertical' && 'py-1 pl-3 -ml-5 pr-1',
)}
>
<div
className={classNames(
layout === 'horizontal' && 'flex flex-col gap-1 w-full pb-3 mb-auto',
layout === 'vertical' && 'flex flex-row flex-shrink-0 gap-2 w-full',
)}
{reorderable ? (
<DndContext
autoScroll
sensors={sensors}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
{tabs.map((t) => {
if ('hidden' in t && t.hidden) {
return null;
}
const isActive = t.value === value;
const btnProps: Partial<ButtonProps> = {
size: 'sm',
color: 'custom',
justify: layout === 'horizontal' ? 'start' : 'center',
onClick: isActive ? undefined : () => onChangeValue(t.value),
className: classNames(
'flex items-center rounded whitespace-nowrap',
'!px-2 ml-[1px]',
'outline-none',
'ring-none',
'focus-visible-or-class:outline-2',
addBorders && 'border focus-visible:bg-surface-highlight',
isActive ? 'text-text' : 'text-text-subtle',
isActive && addBorders
? 'border-surface-active bg-surface-active'
: layout === 'vertical'
? 'border-border-subtle'
: 'border-transparent',
layout === 'horizontal' && 'min-w-[10rem]',
),
};
if ('options' in t) {
const option = t.options.items.find(
(i) => 'value' in i && i.value === t.options?.value,
);
return (
<RadioDropdown
key={t.value}
items={t.options.items}
itemsAfter={t.options.itemsAfter}
itemsBefore={t.options.itemsBefore}
value={t.options.value}
onChange={t.options.onChange}
>
<Button
rightSlot={
<div className="flex items-center">
{t.rightSlot}
<Icon
size="sm"
icon="chevron_down"
className={classNames(
'ml-1',
isActive ? 'text-text-subtle' : 'text-text-subtlest',
)}
/>
</div>
}
{...btnProps}
>
{option && 'shortLabel' in option && option.shortLabel
? option.shortLabel
: (option?.label ?? 'Unknown')}
</Button>
</RadioDropdown>
);
}
return (
<Button key={t.value} rightSlot={t.rightSlot} {...btnProps}>
{t.label}
</Button>
);
})}
</div>
</div>
{tabList}
<DragOverlay dropAnimation={null}>
{isDragging && (
<TabButton
tab={isDragging}
isActive={isDragging.value === value}
addBorders={addBorders}
layout={layout}
reorderable={false}
isDragging={false}
onChangeValue={onChangeValue}
overlay
/>
)}
</DragOverlay>
</DndContext>
) : (
tabList
)}
{children}
</div>
);
}
interface TabButtonProps {
tab: TabItem;
isActive: boolean;
addBorders?: boolean;
layout: 'horizontal' | 'vertical';
reorderable: boolean;
isDragging: boolean;
onChangeValue: (value: string) => void;
overlay?: boolean;
}
function TabButton({
tab,
isActive,
addBorders,
layout,
reorderable,
isDragging,
onChangeValue,
overlay = false,
}: TabButtonProps) {
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({
id: tab.value,
disabled: !reorderable,
});
const { setNodeRef: setDroppableRef } = useDroppable({
id: tab.value,
disabled: !reorderable,
});
const handleSetWrapperRef = useCallback(
(n: HTMLDivElement | null) => {
if (reorderable) {
setDraggableRef(n);
setDroppableRef(n);
}
},
[reorderable, setDraggableRef, setDroppableRef],
);
const btnProps: Partial<ButtonProps> = {
color: 'custom',
justify: layout === 'horizontal' ? 'start' : 'center',
onClick: isActive ? undefined : () => onChangeValue(tab.value),
className: classNames(
'flex items-center rounded whitespace-nowrap',
'!px-2 ml-[1px]',
'outline-none',
'ring-none',
'focus-visible-or-class:outline-2',
addBorders && 'border focus-visible:bg-surface-highlight',
isActive ? 'text-text' : 'text-text-subtle',
isActive && addBorders
? 'border-surface-active bg-surface-active'
: layout === 'vertical'
? 'border-border-subtle'
: 'border-transparent',
layout === 'horizontal' && 'min-w-[10rem]',
isDragging && 'opacity-50',
overlay && 'opacity-80',
),
};
const buttonContent = (() => {
if ('options' in tab) {
const option = tab.options.items.find((i) => 'value' in i && i.value === tab.options.value);
return (
<RadioDropdown
key={tab.value}
items={tab.options.items}
itemsAfter={tab.options.itemsAfter}
itemsBefore={tab.options.itemsBefore}
value={tab.options.value}
onChange={tab.options.onChange}
>
<Button
leftSlot={tab.leftSlot}
rightSlot={
<div className="flex items-center">
{tab.rightSlot}
<Icon
size="sm"
icon="chevron_down"
className={classNames(
'ml-1',
isActive ? 'text-text-subtle' : 'text-text-subtlest',
)}
/>
</div>
}
{...btnProps}
>
{option && 'shortLabel' in option && option.shortLabel
? option.shortLabel
: (option?.label ?? 'Unknown')}
</Button>
</RadioDropdown>
);
}
return (
<Button
leftSlot={tab.leftSlot}
rightSlot={tab.rightSlot}
{...btnProps}
>
{'label' in tab && tab.label ? tab.label : tab.value}
</Button>
);
})();
// Apply drag handlers to wrapper, not button
const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};
return (
<div
ref={handleSetWrapperRef}
className={classNames('relative', layout === 'vertical' && 'mr-2')}
{...wrapperProps}
>
{buttonContent}
</div>
);
}
interface TabContentProps {
value: string;
children: ReactNode;

View File

@@ -58,12 +58,11 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
'pointer-events-auto overflow-hidden',
'relative pointer-events-auto bg-surface text-text rounded-lg',
'border border-border shadow-lg w-[25rem]',
'grid grid-cols-[1fr_auto]',
)}
>
<div className="px-3 py-3 flex items-start gap-2 w-full">
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1" />}
<VStack space={2} className="w-full">
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-[11rem] overflow-auto">
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 flex-shrink-0" />}
<VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div>
{action?.({ hide: onClose })}
</VStack>
@@ -72,7 +71,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
<IconButton
color={color}
variant="border"
className="opacity-60 border-0"
className="opacity-60 border-0 !absolute top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}

View File

@@ -486,11 +486,11 @@ function TreeInner<T extends { id: string }>(
let hoveredParent = node.parent;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === 'above' ? 0 : 1);
const hoveredIndex = dragIndex + (side === 'before' ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === 'before' ? 0 : 1);
// Move into the folder if it's open and we're moving below it
if (hovered?.children != null && side === 'below') {
// Move into the folder if it's open and we're moving after it
if (hovered?.children != null && side === 'after') {
hoveredParent = hovered;
hoveredChildIndex = 0;
}
@@ -679,7 +679,8 @@ function TreeInner<T extends { id: string }>(
className={classNames(
'[&_.tree-item.selected_.tree-item-inner]:text-text',
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
'[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight',
'[&:not(:focus-within)]:[&_.tree-item.selected:not([data-context-menu-open])]:bg-surface-highlight',
'[&_.tree-item.selected[data-context-menu-open]]:bg-surface-active',
// Round the items, but only if the ends of the selection.
// Also account for the drop marker being in between items
'[&_.tree-item]:rounded-md',

View File

@@ -29,7 +29,7 @@ export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: strin
if (collapsed || node?.children?.length === 0) return null;
return (
<div className="drop-marker" style={{ paddingLeft: `${parentDepth}rem` }}>
<div className="drop-marker relative" style={{ paddingLeft: `${parentDepth}rem` }}>
<DropMarker className={classNames(className)} />
</div>
);

View File

@@ -208,7 +208,7 @@ function TreeItem_<T extends { id: string }>({
const isFolder = node.children != null;
const hasChildren = (node.children?.length ?? 0) > 0;
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));
if (isCollapsed && isFolder && hasChildren && side === 'below') {
if (isCollapsed && isFolder && hasChildren && side === 'after') {
setDropHover('animate');
clearTimeout(startedHoverTimeout.current);
startedHoverTimeout.current = setTimeout(() => {
@@ -221,7 +221,7 @@ function TreeItem_<T extends { id: string }>({
);
});
}, HOVER_CLOSED_FOLDER_DELAY);
} else if (isFolder && !hasChildren && side === 'below') {
} else if (isFolder && !hasChildren && side === 'after') {
setDropHover('drop');
} else {
clearDropHover();
@@ -235,6 +235,12 @@ function TreeItem_<T extends { id: string }>({
e.preventDefault();
e.stopPropagation();
// Set data attribute on the list item to preserve active state
if (listItemRef.current) {
listItemRef.current.setAttribute('data-context-menu-open', 'true');
}
const items = await getContextMenu(node.item);
setShowContextMenu({ items, x: e.clientX ?? 100, y: e.clientY ?? 100 });
},
@@ -242,6 +248,10 @@ function TreeItem_<T extends { id: string }>({
);
const handleCloseContextMenu = useCallback(() => {
// Remove data attribute when context menu closes
if (listItemRef.current) {
listItemRef.current.removeAttribute('data-context-menu-open');
}
setShowContextMenu(null);
}, []);

View File

@@ -66,7 +66,11 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
handlePushResult(r);
onDone();
} catch (err) {
showErrorToast('git-commit-and-push-error', String(err));
showErrorToast({
id: 'git-commit-and-push-error',
title: 'Error committing and pushing',
message: String(err),
});
} finally {
setIsPushing(false);
}

View File

@@ -62,6 +62,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
checkout.mutate(
{ branch, force },
{
disableToastError: true,
async onError(err) {
if (!force) {
// Checkout failed so ask user if they want to force it
@@ -78,7 +79,11 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
}
} else {
// Checkout failed
showErrorToast('git-checkout-error', String(err));
showErrorToast({
id: 'git-checkout-error',
title: 'Error checking out branch',
message: String(err),
});
}
},
async onSuccess(branchName) {
@@ -132,8 +137,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
await branch.mutateAsync(
{ branch: name },
{
disableToastError: true,
onError: (err) => {
showErrorToast('git-branch-error', String(err));
showErrorToast({
id: 'git-branch-error',
title: 'Error creating branch',
message: String(err),
});
},
},
);
@@ -163,6 +173,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
await mergeBranch.mutateAsync(
{ branch, force: false },
{
disableToastError: true,
onSettled: hide,
onSuccess() {
showToast({
@@ -177,7 +188,11 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
sync({ force: true });
},
onError(err) {
showErrorToast('git-merged-branch-error', String(err));
showErrorToast({
id: 'git-merged-branch-error',
title: 'Error merging branch',
message: String(err),
});
},
},
);
@@ -208,8 +223,13 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
await deleteBranch.mutateAsync(
{ branch: currentBranch },
{
disableToastError: true,
onError(err) {
showErrorToast('git-delete-branch-error', String(err));
showErrorToast({
id: 'git-delete-branch-error',
title: 'Error deleting branch',
message: String(err),
});
},
async onSuccess() {
await sync({ force: true });
@@ -226,9 +246,14 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
waitForOnSelect: true,
async onSelect() {
await push.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePullResult,
onError(err) {
showErrorToast('git-pull-error', String(err));
showErrorToast({
id: 'git-push-error',
title: 'Error pushing changes',
message: String(err),
});
},
});
},
@@ -240,9 +265,14 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
waitForOnSelect: true,
async onSelect() {
await pull.mutateAsync(undefined, {
disableToastError: true,
onSuccess: handlePullResult,
onError(err) {
showErrorToast('git-pull-error', String(err));
showErrorToast({
id: 'git-pull-error',
title: 'Error pulling changes',
message: String(err),
});
},
});
},

View File

@@ -1,6 +1,7 @@
import { type } from '@tauri-apps/plugin-os';
import { debounce } from '@yaakapp-internal/lib';
import { atom } from 'jotai';
import { settingsAtom } from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { capitalize } from '../lib/capitalize';
import { jotaiStore } from '../lib/jotai';
@@ -13,7 +14,7 @@ export type HotkeyAction =
| 'app.zoom_out'
| 'app.zoom_reset'
| 'command_palette.toggle'
| 'environmentEditor.toggle'
| 'environment_editor.toggle'
| 'hotkeys.showHelp'
| 'model.create'
| 'model.duplicate'
@@ -34,39 +35,94 @@ export type HotkeyAction =
| 'url_bar.focus'
| 'workspace_settings.show';
const hotkeys: Record<HotkeyAction, string[]> = {
'app.zoom_in': ['CmdCtrl+Equal'],
'app.zoom_out': ['CmdCtrl+Minus'],
'app.zoom_reset': ['CmdCtrl+0'],
'command_palette.toggle': ['CmdCtrl+k'],
'environmentEditor.toggle': ['CmdCtrl+Shift+E', 'CmdCtrl+Shift+e'],
'request.rename': type() === 'macos' ? ['Control+Shift+r'] : ['F2'],
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
'hotkeys.showHelp': ['CmdCtrl+Shift+/', 'CmdCtrl+Shift+?'], // when shift is pressed, it might be a question mark
'model.create': ['CmdCtrl+n'],
'model.duplicate': ['CmdCtrl+d'],
/** Default hotkeys for macOS (uses Meta for Cmd) */
const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
'app.zoom_in': ['Meta+Equal'],
'app.zoom_out': ['Meta+Minus'],
'app.zoom_reset': ['Meta+0'],
'command_palette.toggle': ['Meta+k'],
'environment_editor.toggle': ['Meta+Shift+e'],
'request.rename': ['Control+Shift+r'],
'request.send': ['Meta+Enter', 'Meta+r'],
'hotkeys.showHelp': ['Meta+Shift+/'],
'model.create': ['Meta+n'],
'model.duplicate': ['Meta+d'],
'switcher.next': ['Control+Shift+Tab'],
'switcher.prev': ['Control+Tab'],
'switcher.toggle': ['CmdCtrl+p'],
'settings.show': ['CmdCtrl+,'],
'sidebar.filter': ['CmdCtrl+f'],
'sidebar.expand_all': ['CmdCtrl+Shift+Equal'],
'sidebar.collapse_all': ['CmdCtrl+Shift+Minus'],
'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'],
'sidebar.selected.duplicate': ['CmdCtrl+d'],
'switcher.toggle': ['Meta+p'],
'settings.show': ['Meta+,'],
'sidebar.filter': ['Meta+f'],
'sidebar.expand_all': ['Meta+Shift+Equal'],
'sidebar.collapse_all': ['Meta+Shift+Minus'],
'sidebar.selected.delete': ['Delete', 'Meta+Backspace'],
'sidebar.selected.duplicate': ['Meta+d'],
'sidebar.selected.rename': ['Enter'],
'sidebar.focus': ['CmdCtrl+b'],
'sidebar.context_menu': type() === 'macos' ? ['Control+Enter'] : ['Alt+Insert'],
'url_bar.focus': ['CmdCtrl+l'],
'workspace_settings.show': ['CmdCtrl+;'],
'sidebar.focus': ['Meta+b'],
'sidebar.context_menu': ['Control+Enter'],
'url_bar.focus': ['Meta+l'],
'workspace_settings.show': ['Meta+;'],
};
/** Default hotkeys for Windows/Linux (uses Control for Ctrl) */
const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
'app.zoom_in': ['Control+Equal'],
'app.zoom_out': ['Control+Minus'],
'app.zoom_reset': ['Control+0'],
'command_palette.toggle': ['Control+k'],
'environment_editor.toggle': ['Control+Shift+e'],
'request.rename': ['F2'],
'request.send': ['Control+Enter', 'Control+r'],
'hotkeys.showHelp': ['Control+Shift+/'],
'model.create': ['Control+n'],
'model.duplicate': ['Control+d'],
'switcher.next': ['Control+Shift+Tab'],
'switcher.prev': ['Control+Tab'],
'switcher.toggle': ['Control+p'],
'settings.show': ['Control+,'],
'sidebar.filter': ['Control+f'],
'sidebar.expand_all': ['Control+Shift+Equal'],
'sidebar.collapse_all': ['Control+Shift+Minus'],
'sidebar.selected.delete': ['Delete', 'Control+Backspace'],
'sidebar.selected.duplicate': ['Control+d'],
'sidebar.selected.rename': ['Enter'],
'sidebar.focus': ['Control+b'],
'sidebar.context_menu': ['Alt+Insert'],
'url_bar.focus': ['Control+l'],
'workspace_settings.show': ['Control+;'],
};
/** Get the default hotkeys for the current platform */
export const defaultHotkeys: Record<HotkeyAction, string[]> =
type() === 'macos' ? defaultHotkeysMac : defaultHotkeysOther;
/** Atom that provides the effective hotkeys by merging defaults with user settings */
export const hotkeysAtom = atom((get) => {
const settings = get(settingsAtom);
const customHotkeys = settings?.hotkeys ?? {};
// Merge default hotkeys with custom hotkeys from settings
// Custom hotkeys override defaults for the same action
// An empty array means the hotkey is intentionally disabled
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
for (const [action, keys] of Object.entries(customHotkeys)) {
if (action in defaultHotkeys && Array.isArray(keys)) {
merged[action as HotkeyAction] = keys;
}
}
return merged;
});
/** Helper function to get current hotkeys from the store */
function getHotkeys(): Record<HotkeyAction, string[]> {
return jotaiStore.get(hotkeysAtom);
}
const hotkeyLabels: Record<HotkeyAction, string> = {
'app.zoom_in': 'Zoom In',
'app.zoom_out': 'Zoom Out',
'app.zoom_reset': 'Zoom to Actual Size',
'command_palette.toggle': 'Toggle Command Palette',
'environmentEditor.toggle': 'Edit Environments',
'environment_editor.toggle': 'Edit Environments',
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
'model.create': 'New Request',
'model.duplicate': 'Duplicate Request',
@@ -90,7 +146,16 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
const layoutInsensitiveKeys = ['Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote'];
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
export const hotkeyActions: HotkeyAction[] = (
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
).sort((a, b) => {
const scopeA = a.split('.')[0] || '';
const scopeB = b.split('.')[0] || '';
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
@@ -200,6 +265,7 @@ function handleKeyDown(e: KeyboardEvent) {
}
const executed: string[] = [];
const hotkeys = getHotkeys();
outer: for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
if (hkAction !== action) {
@@ -212,8 +278,7 @@ function handleKeyDown(e: KeyboardEvent) {
for (const hkKey of hkKeys) {
const keys = hkKey.split('+');
const adjustedKeys = keys.map(resolveHotkeyKey);
if (compareKeys(adjustedKeys, Array.from(currentKeysWithModifiers))) {
if (compareKeys(keys, Array.from(currentKeysWithModifiers))) {
if (!options.allowDefault) {
e.preventDefault();
e.stopPropagation();
@@ -233,34 +298,38 @@ function handleKeyDown(e: KeyboardEvent) {
clearCurrentKeysDebounced();
}
export function useHotKeyLabel(action: HotkeyAction): string {
export function useHotkeyLabel(action: HotkeyAction): string {
return hotkeyLabels[action];
}
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
if (trigger == null) {
return null;
}
export function getHotkeyScope(action: HotkeyAction): string {
const scope = action.split('.')[0];
return scope || '';
}
export function formatHotkeyString(trigger: string): string[] {
const os = type();
const parts = trigger.split('+');
const labelParts: string[] = [];
for (const p of parts) {
if (os === 'macos') {
if (p === 'CmdCtrl') {
if (p === 'Meta') {
labelParts.push('⌘');
} else if (p === 'Shift') {
labelParts.push('⇧');
} else if (p === 'Control') {
labelParts.push('⌃');
} else if (p === 'Alt') {
labelParts.push('⌥');
} else if (p === 'Enter') {
labelParts.push('↩');
} else if (p === 'Tab') {
labelParts.push('⇥');
} else if (p === 'Backspace') {
labelParts.push('⌫');
} else if (p === 'Delete') {
labelParts.push('⌦');
} else if (p === 'Minus') {
labelParts.push('-');
} else if (p === 'Plus') {
@@ -271,7 +340,7 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
labelParts.push(capitalize(p));
}
} else {
if (p === 'CmdCtrl') {
if (p === 'Control') {
labelParts.push('Ctrl');
} else {
labelParts.push(capitalize(p));
@@ -285,12 +354,15 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
return [labelParts.join('+')];
}
const resolveHotkeyKey = (key: string) => {
const os = type();
if (key === 'CmdCtrl' && os === 'macos') return 'Meta';
if (key === 'CmdCtrl') return 'Control';
return key;
};
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const hotkeys = useAtomValue(hotkeysAtom);
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
if (trigger == null) {
return null;
}
return formatHotkeyString(trigger);
}
function compareKeys(keysA: string[], keysB: string[]) {
if (keysA.length !== keysB.length) return false;

View File

@@ -38,7 +38,11 @@ export function useSubscribeHttpAuthentication() {
jotaiStore.set(httpAuthenticationSummariesAtom, result);
return result;
} catch (err) {
showErrorToast('http-authentication-error', err);
showErrorToast({
id: 'http-authentication-error',
title: 'HTTP Authentication Error',
message: err,
});
}
},
});

View File

@@ -1,3 +1,6 @@
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
return str
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}

View File

@@ -1,20 +1,39 @@
import type { DragMoveEvent } from '@dnd-kit/core';
export function computeSideForDragMove(id: string, e: DragMoveEvent): 'above' | 'below' | null {
export function computeSideForDragMove(
id: string,
e: DragMoveEvent,
orientation: 'vertical' | 'horizontal' = 'vertical',
): 'before' | 'after' | null {
if (e.over == null || e.over.id !== id) {
return null;
}
if (e.active.rect.current.initial == null) return null;
const overRect = e.over.rect;
const activeTop =
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
const hoverTop = overRect.top;
const hoverBottom = overRect.bottom;
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
const hoverClientY = pointerY - hoverTop;
if (orientation === 'horizontal') {
// For horizontal layouts (tabs side-by-side), use left/right logic
const activeLeft =
e.active.rect.current.translated?.left ?? e.active.rect.current.initial.left + e.delta.x;
const pointerX = activeLeft + e.active.rect.current.initial.width / 2;
return hoverClientY < hoverMiddleY ? 'above' : 'below';
const hoverLeft = overRect.left;
const hoverRight = overRect.right;
const hoverMiddleX = hoverLeft + (hoverRight - hoverLeft) / 2;
return pointerX < hoverMiddleX ? 'before' : 'after'; // 'before' = left, 'after' = right
} else {
// For vertical layouts, use top/bottom logic
const activeTop =
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
const hoverTop = overRect.top;
const hoverBottom = overRect.bottom;
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
const hoverClientY = pointerY - hoverTop;
return hoverClientY < hoverMiddleY ? 'before' : 'after';
}
}

View File

@@ -45,11 +45,24 @@ export function hideToast(toHide: ToastInstance) {
});
}
export function showErrorToast<T>(id: string, message: T) {
export function showErrorToast<T>({
id,
title,
message,
}: {
id: string;
title: string;
message: T;
}) {
return showToast({
id,
message: String(message),
timeout: 8000,
color: 'danger',
timeout: null,
message: (
<div className="w-full">
<h2 className="text-lg font-bold mb-2">{title}</h2>
<div className="whitespace-pre-wrap break-words">{String(message)}</div>
</div>
),
});
}

View File

@@ -1,7 +1,6 @@
// @ts-ignore
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react';
import { internalIpV4 } from 'internal-ip';
import { createRequire } from 'node:module';
import path from 'node:path';
import { defineConfig, normalizePath } from 'vite';
@@ -18,10 +17,9 @@ const standardFontsDir = normalizePath(
path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts'),
);
const mobile = !!/android|ios/.exec(process.env.TAURI_ENV_PLATFORM ?? '');
// https://vitejs.dev/config/
export default defineConfig(async () => ({
export default defineConfig(async () => {
return {
plugins: [
wasm(),
tanstackRouter({
@@ -54,16 +52,9 @@ export default defineConfig(async () => ({
},
clearScreen: false,
server: {
port: 1420,
port: parseInt(process.env.YAAK_DEV_PORT ?? '1420', 10),
strictPort: true,
host: mobile ? '0.0.0.0' : false,
hmr: mobile
? {
protocol: 'ws',
host: await internalIpV4(),
port: 1421,
}
: undefined,
},
envPrefix: ['VITE_', 'TAURI_'],
}));
};
});