Compare commits

...

38 Commits

Author SHA1 Message Date
Gregory Schier
4a58f73aa4 Oops 2024-04-02 10:11:37 +02:00
Gregory Schier
bd17650799 Postman text body import 2024-04-02 10:10:16 +02:00
Gregory Schier
db6a7dcabb Bump version 2024-04-01 08:48:26 +02:00
Gregory Schier
00b1f90074 Separate floating sidebar hidden state 2024-03-22 10:43:10 -07:00
Gregory Schier
e292235792 Filtering for cmd palette 2024-03-22 10:42:45 -07:00
Gregory Schier
5f86802d88 Space between var placeholders and code fold cursor 2024-03-22 10:42:35 -07:00
Gregory Schier
acb7f2e49b Fix Postman variable import 2024-03-22 10:40:51 -07:00
Gregory Schier
e2a15609bf Adjust highlight color 2024-03-22 10:37:45 -07:00
Gregory Schier
aa3bfd78c4 Some scrolling tweaks 2024-03-20 17:27:47 -07:00
Gregory Schier
23c4971127 Fix URL bar buttons in expanded state 2024-03-20 16:17:05 -07:00
Gregory Schier
40669217fb Bump version 2024-03-20 16:05:14 -07:00
Gregory Schier
8089ea87e8 Fix dialog height 2024-03-20 16:05:01 -07:00
Gregory Schier
9de24e3a40 Remove openOnHotKeyAction in favor of putting hotkey on the trigger button= 2024-03-20 15:56:39 -07:00
Gregory Schier
5afb8e7383 Use SQLite connect options 2024-03-20 13:33:11 -07:00
Gregory Schier
b3e3f22211 Pass workspace id to import 2024-03-20 07:30:59 -07:00
Gregory Schier
1ff6ff16b3 Handle import errors 2024-03-20 07:27:12 -07:00
Gregory Schier
b8a692f1a5 Postman bearer, global auth, global vars 2024-03-20 07:26:46 -07:00
Gregory Schier
5506cdd05f Implement select for command palette 2024-03-19 17:24:57 -07:00
Gregory Schier
4180fecb4b Tweak checkbox and autocomplete styles 2024-03-19 17:08:06 -07:00
Gregory Schier
fa257fdb18 Fix sidebar border 2024-03-19 16:44:37 -07:00
Gregory Schier
2da141ea16 Export multiple workspaces 2024-03-19 13:43:33 -07:00
Gregory Schier
1993361f87 Fix settings query store and analytics 2024-03-19 10:23:21 -07:00
Gregory Schier
a5dd3beb73 Start of command palette 2024-03-18 17:09:01 -07:00
Gregory Schier
17423f8c54 useRequests hook 2024-03-18 13:49:36 -07:00
Gregory Schier
8f495b9ade Fix editor key events 2024-03-18 13:40:15 -07:00
Gregory Schier
46b9b758fe Simple tests for Postman and Yaak importers 2024-03-18 13:40:00 -07:00
Gregory Schier
b0e84aac0c Set filename on Multipart part 2024-03-18 13:24:27 -07:00
Gregory Schier
20de2aeacc Fix GraphQL editor large variables quirk 2024-03-18 13:10:55 -07:00
Gregory Schier
7198534640 Fix postman import and import Insomnia gRPC 2024-03-18 08:18:04 -07:00
Gregory Schier
7e8ec36474 Better padding 2024-03-16 13:59:06 -07:00
Gregory Schier
52d1602d35 Remove debug log 2024-03-16 12:50:27 -07:00
Gregory Schier
e5731ceb1f Custom content-type for multipart items 2024-03-16 12:49:17 -07:00
Gregory Schier
3ed5a47a83 Content menu on entire sidebar 2024-03-16 10:47:10 -07:00
Gregory Schier
262a29ca5d Obfuscate environment variables 2024-03-16 10:42:46 -07:00
Gregory Schier
4a3e599128 Fix light mode text selection 2024-03-16 09:48:55 -07:00
Gregory Schier
7ebe844643 Stubbed out global commands helper 2024-03-16 09:46:11 -07:00
Gregory Schier
a49b72eebc Fix deleting workspace staying on deleted workspace path 2024-03-15 13:07:02 -07:00
Gregory Schier
bba3afa0b7 Bump version 2024-03-10 18:15:00 -07:00
75 changed files with 2961 additions and 668 deletions

View File

@@ -6,10 +6,14 @@ export function isRequestGroup(obj) {
return isJSObject(obj) && obj._type === 'request_group';
}
export function isRequest(obj) {
export function isHttpRequest(obj) {
return isJSObject(obj) && obj._type === 'request';
}
export function isGrpcRequest(obj) {
return isJSObject(obj) && obj._type === 'grpc_request';
}
export function isEnvironment(obj) {
return isJSObject(obj) && obj._type === 'environment';
}

View File

@@ -0,0 +1,37 @@
import { convertSyntax } from '../helpers/variables.js';
/**
* Import an Insomnia GRPC request object.
* @param {Object} r - The request object to import.
* @param workspaceId - The workspace ID to use for the request.
* @param {number} sortPriority - The sort priority to use for the request.
*/
export function importGrpcRequest(r, workspaceId, sortPriority = 0) {
console.log('IMPORTING GRPC REQUEST', r._id, r.name, JSON.stringify(r, null, 2));
const parts = r.protoMethodName.split('/').filter((p) => p !== '');
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
id: r._id,
createdAt: new Date(r.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(r.updated ?? Date.now()).toISOString().replace('Z', ''),
workspaceId,
folderId: r.parentId === workspaceId ? null : r.parentId,
model: 'grpc_request',
sortPriority,
name: r.name,
url: convertSyntax(r.url),
service,
method,
message: r.body?.text ?? '',
metadata: (r.metadata ?? [])
.map(({ name, value, disabled }) => ({
enabled: !disabled,
name,
value,
}))
.filter(({ name, value }) => name !== '' || value !== ''),
};
}

View File

@@ -6,7 +6,7 @@ import { convertSyntax } from '../helpers/variables.js';
* @param workspaceId - The workspace ID to use for the request.
* @param {number} sortPriority - The sort priority to use for the request.
*/
export function importRequest(r, workspaceId, sortPriority = 0) {
export function importHttpRequest(r, workspaceId, sortPriority = 0) {
console.log('IMPORTING REQUEST', r._id, r.name, JSON.stringify(r, null, 2));
let bodyType = null;

View File

@@ -1,17 +1,18 @@
import { importEnvironment } from './importers/environment.js';
import { importRequest } from './importers/request.js';
import { importEnvironment } from './importers/environment';
import { importHttpRequest } from './importers/httpRequest';
import {
isEnvironment,
isJSObject,
isRequest,
isHttpRequest,
isRequestGroup,
isWorkspace,
isGrpcRequest,
} from './helpers/types.js';
import { parseVariables } from './helpers/variables.js';
import { importFolder } from './importers/folder.js';
import { importGrpcRequest } from './importers/grpcRequest';
export function pluginHookImport(contents) {
console.log('RUNNING INSOMNIA');
let parsed;
try {
parsed = JSON.parse(contents);
@@ -24,7 +25,8 @@ export function pluginHookImport(contents) {
const resources = {
workspaces: [],
requests: [],
httpRequests: [],
grpcRequests: [],
environments: [],
folders: [],
};
@@ -57,8 +59,15 @@ export function pluginHookImport(contents) {
if (isRequestGroup(child)) {
resources.folders.push(importFolder(child, workspaceToImport._id));
nextFolder(child._id);
} else if (isRequest(child)) {
resources.requests.push(importRequest(child, workspaceToImport._id, sortPriority++));
} else if (isHttpRequest(child)) {
resources.httpRequests.push(
importHttpRequest(child, workspaceToImport._id, sortPriority++),
);
} else if (isGrpcRequest(child)) {
console.log('GRPC', JSON.stringify(child, null, 1));
resources.grpcRequests.push(
importGrpcRequest(child, workspaceToImport._id, sortPriority++),
);
}
}
};
@@ -68,7 +77,8 @@ export function pluginHookImport(contents) {
}
// Filter out any `null` values
resources.requests = resources.requests.filter(Boolean);
resources.httpRequests = resources.httpRequests.filter(Boolean);
resources.grpcRequests = resources.grpcRequests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
{
"name": "importer-postman",
"version": "0.0.1"
"version": "0.0.1",
"devDependencies": {
"vitest": "^1.4.0"
}
}

View File

@@ -9,7 +9,7 @@ type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface ExportResources {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
requests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
}
@@ -23,10 +23,12 @@ export function pluginHookImport(contents: string): { resources: ExportResources
return;
}
const globalAuth = importAuth(root.auth);
const exportResources: ExportResources = {
workspaces: [],
environments: [],
requests: [],
httpRequests: [],
folders: [],
};
@@ -35,6 +37,11 @@ export function pluginHookImport(contents: string): { resources: ExportResources
id: generateId('wk'),
name: info.name || 'Postman Import',
description: info.description || '',
variables:
root.variable?.map((v: any) => ({
name: v.key,
value: v.value,
})) ?? [],
};
exportResources.workspaces.push(workspace);
@@ -54,8 +61,9 @@ export function pluginHookImport(contents: string): { resources: ExportResources
} else if (typeof v.name === 'string' && 'request' in v) {
const r = toRecord(v.request);
const bodyPatch = importBody(r.body);
const authPatch = importAuth(r.auth);
const request: ExportResources['requests'][0] = {
const requestAuthPath = importAuth(r.auth);
const authPatch = requestAuthPath.authenticationType == null ? globalAuth : requestAuthPath;
const request: ExportResources['httpRequests'][0] = {
model: 'http_request',
id: generateId('rq'),
workspaceId: workspace.id,
@@ -79,7 +87,7 @@ export function pluginHookImport(contents: string): { resources: ExportResources
}),
],
};
exportResources.requests.push(request);
exportResources.httpRequests.push(request);
} else {
console.log('Unknown item', v, folderId);
}
@@ -105,6 +113,14 @@ function importAuth(
password: auth.basic.password || '',
},
};
} else if ('bearer' in auth) {
return {
headers: [],
authenticationType: 'bearer',
authentication: {
token: auth.bearer.token || '',
},
};
} else {
// TODO: support other auth types
return { headers: [], authenticationType: null, authentication: {} };
@@ -175,6 +191,20 @@ function importBody(rawBody: any): Pick<HttpRequest, 'body' | 'bodyType' | 'head
),
},
};
} else if ('raw' in body) {
return {
headers: [
{
name: 'Content-Type',
value: body.options?.raw?.language === 'json' ? 'application/json' : '',
enabled: true,
},
],
bodyType: body.options?.raw?.language === 'json' ? 'application/json' : 'other',
body: {
text: body.raw ?? '',
},
};
} else {
// TODO: support other body types
return { headers: [], bodyType: null, body: {} };
@@ -213,7 +243,7 @@ function convertTemplateSyntax<T>(obj: T): T {
}
}
function generateId(prefix: 'wk' | 'rq' | 'fl'): string {
export function generateId(prefix: 'wk' | 'rq' | 'fl'): string {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let id = `${prefix}_`;
for (let i = 0; i < 10; i++) {

View File

@@ -0,0 +1,38 @@
{
"info": {
"_postman_id": "9e6dfada-256c-49ea-a38f-7d1b05b7ca2d",
"name": "New Collection",
"schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json",
"_exporter_id": "18798"
},
"item": [
{
"name": "Top Folder",
"item": [
{
"name": "Nested Folder",
"item": [
{
"name": "Request 1",
"request": {
"method": "GET"
}
}
]
},
{
"name": "Request 2",
"request": {
"method": "GET"
}
}
]
},
{
"name": "Request 3",
"request": {
"method": "GET"
}
}
]
}

View File

@@ -0,0 +1,64 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { pluginHookImport } from '../src';
let originalRandom = Math.random;
describe('importer-postman', () => {
beforeEach(() => {
let i = 0;
// Psuedo-random number generator to ensure consistent ID generation
Math.random = vi.fn(() => ((i++ * 1000) % 133) / 100);
});
afterEach(() => {
Math.random = originalRandom;
});
const p = path.join(__dirname, 'fixtures');
const fixtures = fs.readdirSync(p);
for (const fixture of fixtures) {
test('Imports ' + fixture, () => {
const contents = fs.readFileSync(path.join(p, fixture), 'utf-8');
const imported = pluginHookImport(contents);
expect(imported).toEqual({
resources: expect.objectContaining({
folders: expect.arrayContaining([
expect.objectContaining({
name: 'Top Folder',
workspaceId: 'wk_0G3J6M9QcT',
}),
expect.objectContaining({
name: 'Nested Folder',
workspaceId: 'wk_0G3J6M9QcT',
}),
]),
httpRequests: expect.arrayContaining([
expect.objectContaining({
name: 'Request 1',
workspaceId: 'wk_0G3J6M9QcT',
folderId: 'fl_vundefinedyundefinedBundefinedE0H3',
}),
expect.objectContaining({
name: 'Request 2',
workspaceId: 'wk_0G3J6M9QcT',
folderId: 'fl_fWiZlundefinedoundefinedrundefined',
}),
expect.objectContaining({
name: 'Request 3',
workspaceId: 'wk_0G3J6M9QcT',
folderId: null,
}),
]),
workspaces: [
expect.objectContaining({
name: 'New Collection',
}),
],
}),
});
});
}
});

View File

@@ -1,4 +1,4 @@
export function pluginHookImport(contents) {
export function pluginHookImport(contents: string) {
let parsed;
try {
parsed = JSON.parse(contents);
@@ -10,23 +10,20 @@ export function pluginHookImport(contents) {
return undefined;
}
if (!('yaakSchema' in parsed)) {
const isYaakExport = 'yaakSchema' in parsed;
if (!isYaakExport) {
return;
}
// Migrate v1 to v2 -- changes requests to httpRequests
if (parsed.yaakSchema === 1) {
if ('requests' in parsed.resources) {
parsed.resources.httpRequests = parsed.resources.requests;
parsed.yaakSchema = 2;
delete parsed.resources.requests;
}
if (parsed.yaakSchema === 2) {
return { resources: parsed.resources }; // Should already be in the correct format
}
return undefined;
return { resources: parsed.resources }; // Should already be in the correct format
}
export function isJSObject(obj) {
export function isJSObject(obj: any) {
return Object.prototype.toString.call(obj) === '[object Object]';
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, test } from 'vitest';
import { pluginHookImport } from '../src';
describe('importer-yaak', () => {
test('Skips invalid imports', () => {
expect(pluginHookImport('not JSON')).toBeUndefined();
expect(pluginHookImport('[]')).toBeUndefined();
expect(pluginHookImport(JSON.stringify({ resources: {} }))).toBeUndefined();
});
test('converts schema 1 to 2', () => {
const imported = pluginHookImport(
JSON.stringify({
yaakSchema: 1,
resources: {
requests: [],
},
}),
);
expect(imported).toEqual(
expect.objectContaining({
resources: {
httpRequests: [],
},
}),
);
});
});

View File

@@ -4,7 +4,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.js'),
entry: resolve(__dirname, 'src/index.ts'),
fileName: 'index',
formats: ['es'],
},

80
src-tauri/Cargo.lock generated
View File

@@ -19,9 +19,9 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "ahash"
version = "0.8.6"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"getrandom 0.2.11",
@@ -161,16 +161,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "atomic-write-file"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcdbedc2236483ab103a53415653d6b4442ea6141baf1ffa85df29635e88436"
dependencies = [
"nix",
"rand 0.8.5",
]
[[package]]
name = "autocfg"
version = "1.1.0"
@@ -249,6 +239,12 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9"
[[package]]
name = "base64"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51"
[[package]]
name = "base64ct"
version = "1.6.0"
@@ -2654,17 +2650,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54"
[[package]]
name = "nix"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
dependencies = [
"bitflags 2.4.1",
"cfg-if",
"libc",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -4314,9 +4299,9 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.7.3"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dba03c279da73694ef99763320dea58b51095dfe87d001b1d4b5fe78ba8763cf"
checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -4327,9 +4312,9 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.7.3"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d84b0a3c3739e220d94b3239fd69fb1f74bc36e16643423bd99de3b43c21bfbd"
checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6"
dependencies = [
"ahash",
"atoi",
@@ -4338,7 +4323,6 @@ dependencies = [
"chrono",
"crc",
"crossbeam-queue",
"dotenvy",
"either",
"event-listener",
"futures-channel",
@@ -4372,9 +4356,9 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.7.3"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89961c00dc4d7dffb7aee214964b065072bff69e36ddb9e2c107541f75e4f2a5"
checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127"
dependencies = [
"proc-macro2",
"quote",
@@ -4385,11 +4369,10 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.7.3"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0bd4519486723648186a08785143599760f7cc81c52334a55d6a83ea1e20841"
checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8"
dependencies = [
"atomic-write-file",
"dotenvy",
"either",
"heck 0.4.1",
@@ -4412,9 +4395,9 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.7.3"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e37195395df71fd068f6e2082247891bc11e3289624bbc776a0cdfa1ca7f1ea4"
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
dependencies = [
"atoi",
"base64 0.21.5",
@@ -4456,9 +4439,9 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.7.3"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6ac0ac3b7ccd10cc96c7ab29791a7dd236bd94021f31eec7ba3d46a74aa1c24"
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
dependencies = [
"atoi",
"base64 0.21.5",
@@ -4484,7 +4467,6 @@ dependencies = [
"rand 0.8.5",
"serde",
"serde_json",
"sha1",
"sha2",
"smallvec",
"sqlx-core",
@@ -4497,9 +4479,9 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.7.3"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "210976b7d948c7ba9fced8ca835b11cbb2d677c59c79de41ac0d397e14547490"
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
dependencies = [
"atoi",
"chrono",
@@ -5123,9 +5105,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.34.0"
version = "1.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9"
checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
dependencies = [
"backtrace",
"bytes",
@@ -5181,9 +5163,9 @@ dependencies = [
[[package]]
name = "tokio-stream"
version = "0.1.14"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842"
checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af"
dependencies = [
"futures-core",
"pin-project-lite",
@@ -6332,7 +6314,7 @@ dependencies = [
name = "yaak-app"
version = "0.0.0"
dependencies = [
"base64 0.21.5",
"base64 0.22.0",
"boa_engine",
"boa_runtime",
"chrono",
@@ -6387,18 +6369,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.7.25"
version = "0.7.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cd369a67c0edfef15010f980c3cbe45d7f651deac2cd67ce097cd801de16557"
checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.25"
version = "0.7.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2f140bda219a26ccc0cdb03dba58af72590c53b22642577d88a927bc5c87d6b"
checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -22,7 +22,7 @@ cocoa = "0.25.0"
openssl-sys = { version = "0.9", features = ["vendored"] } # For Ubuntu installation to work
[dependencies]
base64 = "0.21.0"
base64 = "0.22.0"
boa_engine = { version = "0.17.3", features = ["annex-b"] }
boa_runtime = { version = "0.17.3" }
chrono = { version = "0.4.31", features = ["serde"] }
@@ -33,7 +33,7 @@ reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "br
cookie = { version = "0.18.0" }
serde = { version = "1.0.195", features = ["derive"] }
serde_json = { version = "1.0.111", features = ["raw_value"] }
sqlx = { version = "0.7.3", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }
tauri = { version = "1.5.4", features = [
"config-toml",
"path-all",
@@ -56,14 +56,14 @@ tauri = { version = "1.5.4", features = [
] }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1", features = ["colored"] }
tokio = { version = "1.25.0", features = ["sync"] }
tokio = { version = "1.36.0", features = ["sync"] }
uuid = "1.3.0"
log = "0.4.20"
datetime = "0.5.2"
window-shadows = "0.2.2"
reqwest_cookie_store = "0.6.0"
grpc = { path = "./grpc" }
tokio-stream = "0.1.14"
tokio-stream = "0.1.15"
[features]
# by default Tauri runs in production mode

View File

@@ -1,27 +1,30 @@
function S(e, t) {
function g(e, n) {
return console.log("IMPORTING Environment", e._id, e.name, JSON.stringify(e, null, 2)), {
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace("Z", ""),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace("Z", ""),
workspaceId: t,
workspaceId: n,
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([n, a]) => ({
variables: Object.entries(e.data).map(([t, a]) => ({
enabled: !0,
name: n,
name: t,
value: `${a}`
}))
};
}
function I(e) {
function S(e) {
return m(e) && e._type === "workspace";
}
function y(e) {
function I(e) {
return m(e) && e._type === "request_group";
}
function g(e) {
function y(e) {
return m(e) && e._type === "request";
}
function h(e) {
return m(e) && e._type === "grpc_request";
}
function f(e) {
return m(e) && e._type === "environment";
}
@@ -32,103 +35,131 @@ function w(e) {
return Object.prototype.toString.call(e) === "[object String]";
}
function O(e) {
return Object.entries(e).map(([t, n]) => ({
return Object.entries(e).map(([n, t]) => ({
enabled: !0,
name: t,
value: `${n}`
name: n,
value: `${t}`
}));
}
function l(e) {
function d(e) {
return w(e) ? e.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") : e;
}
function h(e, t, n = 0) {
var c, o;
function _(e, n, t = 0) {
var l, r;
console.log("IMPORTING REQUEST", e._id, e.name, JSON.stringify(e, null, 2));
let a = null, r = null;
((c = e.body) == null ? void 0 : c.mimeType) === "application/graphql" ? (a = "graphql", r = l(e.body.text)) : ((o = e.body) == null ? void 0 : o.mimeType) === "application/json" && (a = "application/json", r = l(e.body.text));
let i = null, u = {};
return e.authentication.type === "bearer" ? (i = "bearer", u = {
token: l(e.authentication.token)
}) : e.authentication.type === "basic" && (i = "basic", u = {
username: l(e.authentication.username),
password: l(e.authentication.password)
let a = null, o = null;
((l = e.body) == null ? void 0 : l.mimeType) === "application/graphql" ? (a = "graphql", o = d(e.body.text)) : ((r = e.body) == null ? void 0 : r.mimeType) === "application/json" && (a = "application/json", o = d(e.body.text));
let s = null, p = {};
return e.authentication.type === "bearer" ? (s = "bearer", p = {
token: d(e.authentication.token)
}) : e.authentication.type === "basic" && (s = "basic", p = {
username: d(e.authentication.username),
password: d(e.authentication.password)
}), {
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace("Z", ""),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace("Z", ""),
workspaceId: t,
folderId: e.parentId === t ? null : e.parentId,
workspaceId: n,
folderId: e.parentId === n ? null : e.parentId,
model: "http_request",
sortPriority: n,
sortPriority: t,
name: e.name,
url: l(e.url),
body: r,
url: d(e.url),
body: o,
bodyType: a,
authentication: u,
authenticationType: i,
authentication: p,
authenticationType: s,
method: e.method,
headers: (e.headers ?? []).map(({ name: d, value: p, disabled: s }) => ({
enabled: !s,
name: d,
value: p
})).filter(({ name: d, value: p }) => d !== "" || p !== "")
headers: (e.headers ?? []).map(({ name: u, value: c, disabled: i }) => ({
enabled: !i,
name: u,
value: c
})).filter(({ name: u, value: c }) => u !== "" || c !== "")
};
}
function _(e, t) {
function R(e, n) {
return console.log("IMPORTING FOLDER", e._id, e.name, JSON.stringify(e, null, 2)), {
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace("Z", ""),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace("Z", ""),
folderId: e.parentId === t ? null : e.parentId,
workspaceId: t,
folderId: e.parentId === n ? null : e.parentId,
workspaceId: n,
model: "folder",
name: e.name
};
}
function b(e) {
console.log("RUNNING INSOMNIA");
let t;
function D(e, n, t = 0) {
var p;
console.log("IMPORTING GRPC REQUEST", e._id, e.name, JSON.stringify(e, null, 2));
const a = e.protoMethodName.split("/").filter((l) => l !== ""), o = a[0] ?? null, s = a[1] ?? null;
return {
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace("Z", ""),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace("Z", ""),
workspaceId: n,
folderId: e.parentId === n ? null : e.parentId,
model: "grpc_request",
sortPriority: t,
name: e.name,
url: d(e.url),
service: o,
method: s,
message: ((p = e.body) == null ? void 0 : p.text) ?? "",
metadata: (e.metadata ?? []).map(({ name: l, value: r, disabled: u }) => ({
enabled: !u,
name: l,
value: r
})).filter(({ name: l, value: r }) => l !== "" || r !== "")
};
}
function q(e) {
let n;
try {
t = JSON.parse(e);
n = JSON.parse(e);
} catch {
return;
}
if (!m(t) || !Array.isArray(t.resources))
if (!m(n) || !Array.isArray(n.resources))
return;
const n = {
const t = {
workspaces: [],
requests: [],
httpRequests: [],
grpcRequests: [],
environments: [],
folders: []
}, a = t.resources.filter(I);
for (const r of a) {
const i = t.resources.find(
(o) => f(o) && o.parentId === r._id
}, a = n.resources.filter(S);
for (const o of a) {
const s = n.resources.find(
(r) => f(r) && r.parentId === o._id
);
n.workspaces.push({
id: r._id,
t.workspaces.push({
id: o._id,
createdAt: new Date(a.created ?? Date.now()).toISOString().replace("Z", ""),
updatedAt: new Date(a.updated ?? Date.now()).toISOString().replace("Z", ""),
model: "workspace",
name: r.name,
variables: i ? O(i.data) : []
name: o.name,
variables: s ? O(s.data) : []
});
const u = t.resources.filter(
(o) => f(o) && o.parentId === (i == null ? void 0 : i._id)
const p = n.resources.filter(
(r) => f(r) && r.parentId === (s == null ? void 0 : s._id)
);
n.environments.push(
...u.map((o) => S(o, r._id))
t.environments.push(
...p.map((r) => g(r, o._id))
);
const c = (o) => {
const d = t.resources.filter((s) => s.parentId === o);
let p = 0;
for (const s of d)
y(s) ? (n.folders.push(_(s, r._id)), c(s._id)) : g(s) && n.requests.push(h(s, r._id, p++));
const l = (r) => {
const u = n.resources.filter((i) => i.parentId === r);
let c = 0;
for (const i of u)
I(i) ? (t.folders.push(R(i, o._id)), l(i._id)) : y(i) ? t.httpRequests.push(
_(i, o._id, c++)
) : h(i) && (console.log("GRPC", JSON.stringify(i, null, 1)), t.grpcRequests.push(
D(i, o._id, c++)
));
};
c(r._id);
l(o._id);
}
return n.requests = n.requests.filter(Boolean), n.environments = n.environments.filter(Boolean), n.workspaces = n.workspaces.filter(Boolean), { resources: n };
return t.httpRequests = t.httpRequests.filter(Boolean), t.grpcRequests = t.grpcRequests.filter(Boolean), t.environments = t.environments.filter(Boolean), t.workspaces = t.workspaces.filter(Boolean), { resources: t };
}
export {
b as pluginHookImport
q as pluginHookImport
};

View File

@@ -1,80 +1,92 @@
const T = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", w = "https://schema.getpostman.com/json/collection/v2.0.0/collection.json", A = [w, T];
function q(e) {
const t = b(e);
if (t == null)
const q = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", S = "https://schema.getpostman.com/json/collection/v2.0.0/collection.json", _ = [S, q];
function j(t) {
var g;
const e = A(t);
if (e == null)
return;
const n = a(t.info);
if (!A.includes(n.schema) || !Array.isArray(t.item))
const a = l(e.info);
if (!_.includes(a.schema) || !Array.isArray(e.item))
return;
const i = {
const c = w(e.auth), s = {
workspaces: [],
environments: [],
requests: [],
httpRequests: [],
folders: []
}, c = {
}, n = {
model: "workspace",
id: m("wk"),
name: n.name || "Postman Import",
description: n.description || ""
id: y("wk"),
name: a.name || "Postman Import",
description: a.description || "",
variables: ((g = e.variable) == null ? void 0 : g.map((r) => ({
name: r.key,
value: r.value
}))) ?? []
};
i.workspaces.push(c);
const f = (r, u = null) => {
s.workspaces.push(n);
const f = (r, d = null) => {
if (typeof r.name == "string" && Array.isArray(r.item)) {
const o = {
model: "folder",
workspaceId: c.id,
id: m("fl"),
workspaceId: n.id,
id: y("fl"),
name: r.name,
folderId: u
folderId: d
};
i.folders.push(o);
for (const s of r.item)
f(s, o.id);
s.folders.push(o);
for (const u of r.item)
f(u, o.id);
} else if (typeof r.name == "string" && "request" in r) {
const o = a(r.request), s = k(o.body), d = S(o.auth), g = {
const o = l(r.request), u = O(o.body), T = w(o.auth), p = T.authenticationType == null ? c : T, k = {
model: "http_request",
id: m("rq"),
workspaceId: c.id,
folderId: u,
id: y("rq"),
workspaceId: n.id,
folderId: d,
name: r.name,
method: o.method || "GET",
url: typeof o.url == "string" ? o.url : a(o.url).raw,
body: s.body,
bodyType: s.bodyType,
authentication: d.authentication,
authenticationType: d.authenticationType,
url: typeof o.url == "string" ? o.url : l(o.url).raw,
body: u.body,
bodyType: u.bodyType,
authentication: p.authentication,
authenticationType: p.authenticationType,
headers: [
...s.headers,
...d.headers,
...y(o.header).map((p) => ({
name: p.key,
value: p.value,
enabled: !p.disabled
...u.headers,
...p.headers,
...h(o.header).map((m) => ({
name: m.key,
value: m.value,
enabled: !m.disabled
}))
]
};
i.requests.push(g);
s.httpRequests.push(k);
} else
console.log("Unknown item", r, u);
console.log("Unknown item", r, d);
};
for (const r of t.item)
for (const r of e.item)
f(r);
return { resources: h(i) };
return { resources: b(s) };
}
function S(e) {
const t = a(e);
return "basic" in t ? {
function w(t) {
const e = l(t);
return "basic" in e ? {
headers: [],
authenticationType: "basic",
authentication: {
username: t.basic.username || "",
password: t.basic.password || ""
username: e.basic.username || "",
password: e.basic.password || ""
}
} : "bearer" in e ? {
headers: [],
authenticationType: "bearer",
authentication: {
token: e.bearer.token || ""
}
} : { headers: [], authenticationType: null, authentication: {} };
}
function k(e) {
const t = a(e);
return "graphql" in t ? {
function O(t) {
var a, i, c, s;
const e = l(t);
return "graphql" in e ? {
headers: [
{
name: "Content-Type",
@@ -85,12 +97,12 @@ function k(e) {
bodyType: "graphql",
body: {
text: JSON.stringify(
{ query: t.graphql.query, variables: b(t.graphql.variables) },
{ query: e.graphql.query, variables: A(e.graphql.variables) },
null,
2
)
}
} : "urlencoded" in t ? {
} : "urlencoded" in e ? {
headers: [
{
name: "Content-Type",
@@ -100,13 +112,13 @@ function k(e) {
],
bodyType: "application/x-www-form-urlencoded",
body: {
form: y(t.urlencoded).map((n) => ({
form: h(e.urlencoded).map((n) => ({
enabled: !n.disabled,
name: n.key ?? "",
value: n.value ?? ""
}))
}
} : "formdata" in t ? {
} : "formdata" in e ? {
headers: [
{
name: "Content-Type",
@@ -116,7 +128,7 @@ function k(e) {
],
bodyType: "multipart/form-data",
body: {
form: y(t.formdata).map(
form: h(e.formdata).map(
(n) => n.src != null ? {
enabled: !n.disabled,
name: n.key ?? "",
@@ -128,33 +140,46 @@ function k(e) {
}
)
}
} : "raw" in e ? {
headers: [
{
name: "Content-Type",
value: ((i = (a = e.options) == null ? void 0 : a.raw) == null ? void 0 : i.language) === "json" ? "application/json" : "",
enabled: !0
}
],
bodyType: ((s = (c = e.options) == null ? void 0 : c.raw) == null ? void 0 : s.language) === "json" ? "application/json" : "other",
body: {
text: e.raw ?? ""
}
} : { headers: [], bodyType: null, body: {} };
}
function b(e) {
function A(t) {
try {
return a(JSON.parse(e));
return l(JSON.parse(t));
} catch {
}
return null;
}
function a(e) {
return Object.prototype.toString.call(e) === "[object Object]" ? e : {};
function l(t) {
return Object.prototype.toString.call(t) === "[object Object]" ? t : {};
}
function y(e) {
return Object.prototype.toString.call(e) === "[object Array]" ? e : [];
function h(t) {
return Object.prototype.toString.call(t) === "[object Array]" ? t : [];
}
function h(e) {
return typeof e == "string" ? e.replace(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") : Array.isArray(e) && e != null ? e.map(h) : typeof e == "object" && e != null ? Object.fromEntries(
Object.entries(e).map(([t, n]) => [t, h(n)])
) : e;
function b(t) {
return typeof t == "string" ? t.replace(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") : Array.isArray(t) && t != null ? t.map(b) : typeof t == "object" && t != null ? Object.fromEntries(
Object.entries(t).map(([e, a]) => [e, b(a)])
) : t;
}
function m(e) {
const t = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
let n = `${e}_`;
for (let l = 0; l < 10; l++)
n += t[Math.floor(Math.random() * t.length)];
return n;
function y(t) {
const e = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
let a = `${t}_`;
for (let i = 0; i < 10; i++)
a += e[Math.floor(Math.random() * e.length)];
return a;
}
export {
q as pluginHookImport
y as generateId,
j as pluginHookImport
};

View File

@@ -5,8 +5,8 @@ function u(r) {
} catch {
return;
}
if (t(e) && "yaakSchema" in e && (e.yaakSchema === 1 && (e.resources.httpRequests = e.resources.requests, e.yaakSchema = 2), e.yaakSchema === 2))
return { resources: e.resources };
if (!(!t(e) || !("yaakSchema" in e)))
return "requests" in e.resources && (e.resources.httpRequests = e.resources.requests, delete e.resources.requests), { resources: e.resources };
}
function t(r) {
return Object.prototype.toString.call(r) === "[object Object]";

View File

@@ -1,6 +1,6 @@
use std::fmt::Display;
use log::{debug, warn};
use log::{warn};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::types::JsonValue;
@@ -26,6 +26,7 @@ pub enum AnalyticsResource {
KeyValue,
Sidebar,
Workspace,
Setting,
}
impl AnalyticsResource {
@@ -182,7 +183,7 @@ pub async fn track_event(
// Disable analytics actual sending in dev
if is_dev() {
debug!("track: {} {} {:?}", event, attributes_json, params);
// debug!("track: {} {} {:?}", event, attributes_json, params);
return;
}

View File

@@ -259,7 +259,6 @@ pub async fn send_http_request(
return response_err(response, e, window).await;
}
}
} else if body_type == "multipart/form-data" && request_body.contains_key("form") {
let mut multipart_form = multipart::Form::new();
if let Some(form_definition) = request_body.get("form") {
@@ -269,36 +268,64 @@ pub async fn send_http_request(
.unwrap_or(empty_bool)
.as_bool()
.unwrap_or(false);
let name = p
let name_raw = p
.get("name")
.unwrap_or(empty_string)
.as_str()
.unwrap_or_default();
if !enabled || name.is_empty() {
if !enabled || name_raw.is_empty() {
continue;
}
let file = p
let file_path = p
.get("file")
.unwrap_or(empty_string)
.as_str()
.unwrap_or_default();
let value = p
let value_raw = p
.get("value")
.unwrap_or(empty_string)
.as_str()
.unwrap_or_default();
multipart_form = multipart_form.part(
render::render(name, &workspace, environment_ref),
match !file.is_empty() {
true => {
multipart::Part::bytes(fs::read(file).map_err(|e| e.to_string())?)
let name = render::render(name_raw, &workspace, environment_ref);
let part = if file_path.is_empty() {
multipart::Part::text(render::render(
value_raw,
&workspace,
environment_ref,
))
} else {
match fs::read(file_path) {
Ok(f) => multipart::Part::bytes(f),
Err(e) => {
return response_err(response, e.to_string(), window).await;
}
false => multipart::Part::text(render::render(
value,
&workspace,
environment_ref,
)),
}
};
let ct_raw = p
.get("contentType")
.unwrap_or(empty_string)
.as_str()
.unwrap_or_default();
multipart_form = multipart_form.part(
name,
if ct_raw.is_empty() {
part
} else {
let content_type = render::render(ct_raw, &workspace, environment_ref);
let filename = PathBuf::from(file_path)
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
.to_string();
part.file_name(filename)
.mime_str(content_type.as_str())
.map_err(|e| e.to_string())?
},
);
}

View File

@@ -10,39 +10,55 @@ extern crate objc;
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::{create_dir_all, File, read_to_string};
use std::fs::{create_dir_all, read_to_string, File};
use std::path::PathBuf;
use std::process::exit;
use std::str::FromStr;
use ::http::Uri;
use ::http::uri::InvalidUri;
use ::http::Uri;
use base64::Engine;
use fern::colors::ColoredLevelConfig;
use log::{debug, error, info, warn};
use rand::random;
use serde_json::{json, Value};
use sqlx::{Pool, Sqlite, SqlitePool};
use sqlx::migrate::Migrator;
use sqlx::sqlite::{SqliteConnectOptions};
use sqlx::types::Json;
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl};
use tauri::{Manager, WindowEvent};
use sqlx::{Pool, Sqlite, SqlitePool};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl};
use tauri::{Manager, WindowEvent};
use tauri_plugin_log::{fern, LogTarget};
use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex;
use tokio::time::sleep;
use window_shadows::set_shadow;
use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition};
use ::grpc::manager::{DynamicMessage, GrpcHandle};
use ::grpc::{deserialize_message, serialize_message, Code, ServiceDefinition};
use window_ext::TrafficLightWindowExt;
use crate::analytics::{AnalyticsAction, AnalyticsResource};
use crate::grpc::metadata_to_map;
use crate::http::send_http_request;
use crate::models::{cancel_pending_grpc_connections, cancel_pending_responses, CookieJar, create_http_response, delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request, delete_http_response, delete_workspace, duplicate_grpc_request, duplicate_http_request, Environment, EnvironmentVariable, Folder, get_cookie_jar, get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request, get_http_response, get_key_value_raw, get_or_create_settings, get_workspace, get_workspace_export_resources, GrpcConnection, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpRequestHeader, HttpResponse, KeyValue, list_cookie_jars, list_environments, list_folders, list_grpc_connections, list_grpc_events, list_grpc_requests, list_requests, list_responses, list_workspaces, set_key_value_raw, Settings, update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, Workspace, WorkspaceExportResources};
use crate::models::{
cancel_pending_grpc_connections, cancel_pending_responses, create_http_response,
delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment,
delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request,
delete_http_response, delete_workspace, duplicate_grpc_request, duplicate_http_request,
get_cookie_jar, get_environment, get_folder, get_grpc_connection, get_grpc_request,
get_http_request, get_http_response, get_key_value_raw, get_or_create_settings, get_workspace,
get_workspace_export_resources, list_cookie_jars, list_environments, list_folders,
list_grpc_connections, list_grpc_events, list_grpc_requests, list_http_requests,
list_responses, list_workspaces, set_key_value_raw, update_response_if_id, update_settings,
upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection,
upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, CookieJar,
Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcEvent, GrpcEventType,
GrpcRequest, HttpRequest, HttpRequestHeader, HttpResponse, KeyValue, Settings, Workspace,
WorkspaceExportResources,
};
use crate::plugin::ImportResult;
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
@@ -56,18 +72,6 @@ mod updates;
mod window_ext;
mod window_menu;
#[derive(serde::Serialize)]
pub struct CustomResponse {
status: u16,
body: String,
url: String,
method: String,
elapsed: u128,
elapsed2: u128,
headers: HashMap<String, String>,
pub status_reason: Option<&'static str>,
}
async fn migrate_db(app_handle: AppHandle, db: &Mutex<Pool<Sqlite>>) -> Result<(), String> {
let pool = &*db.lock().await;
let p = app_handle
@@ -82,6 +86,26 @@ async fn migrate_db(app_handle: AppHandle, db: &Mutex<Pool<Sqlite>>) -> Result<(
Ok(())
}
#[derive(serde::Serialize)]
#[serde(default, rename_all = "camelCase")]
struct AppMetaData {
is_dev: bool,
version: String,
name: String,
app_data_dir: String,
}
#[tauri::command]
async fn cmd_metadata(app_handle: AppHandle) -> Result<AppMetaData, ()> {
let p = app_handle.path_resolver();
return Ok(AppMetaData {
is_dev: is_dev(),
version: app_handle.package_info().version.to_string(),
name: app_handle.package_info().name.to_string(),
app_data_dir: p.app_data_dir().unwrap().to_string_lossy().to_string(),
});
}
#[tauri::command]
async fn cmd_grpc_reflect(
request_id: &str,
@@ -701,15 +725,13 @@ async fn cmd_filter_response(w: Window, response_id: &str, filter: &str) -> Resu
#[tauri::command]
async fn cmd_import_data(
w: Window,
file_paths: Vec<&str>,
file_path: &str,
_workspace_id: &str,
) -> Result<WorkspaceExportResources, String> {
let mut result: Option<ImportResult> = None;
let plugins = vec!["importer-yaak", "importer-insomnia", "importer-postman"];
for plugin_name in plugins {
if let Some(r) =
plugin::run_plugin_import(&w.app_handle(), plugin_name, file_paths.first().unwrap())
.await
{
if let Some(r) = plugin::run_plugin_import(&w.app_handle(), plugin_name, file_path).await {
analytics::track_event(
&w.app_handle(),
AnalyticsResource::App,
@@ -723,29 +745,25 @@ async fn cmd_import_data(
}
match result {
None => Err("No importers found for the chosen file".to_string()),
None => Err("No import handlers found".to_string()),
Some(r) => {
let mut imported_resources = WorkspaceExportResources::default();
info!("Importing resources");
for v in r.resources.workspaces {
let x = upsert_workspace(&w, v)
.await
.expect("Failed to create workspace");
let x = upsert_workspace(&w, v).await.map_err(|e| e.to_string())?;
imported_resources.workspaces.push(x.clone());
info!("Imported workspace: {}", x.name);
}
for v in r.resources.environments {
let x = upsert_environment(&w, v)
.await
.expect("Failed to create environment");
let x = upsert_environment(&w, v).await.map_err(|e| e.to_string())?;
imported_resources.environments.push(x.clone());
info!("Imported environment: {}", x.name);
}
for v in r.resources.folders {
let x = upsert_folder(&w, v).await.expect("Failed to create folder");
let x = upsert_folder(&w, v).await.map_err(|e| e.to_string())?;
imported_resources.folders.push(x.clone());
info!("Imported folder: {}", x.name);
}
@@ -753,7 +771,7 @@ async fn cmd_import_data(
for v in r.resources.http_requests {
let x = upsert_http_request(&w, v)
.await
.expect("Failed to create HTTP request");
.map_err(|e| e.to_string())?;
imported_resources.http_requests.push(x.clone());
info!("Imported request: {}", x.name);
}
@@ -761,7 +779,7 @@ async fn cmd_import_data(
for v in r.resources.grpc_requests {
let x = upsert_grpc_request(&w, &v)
.await
.expect("Failed to create GRPC request");
.map_err(|e| e.to_string())?;
imported_resources.grpc_requests.push(x.clone());
info!("Imported request: {}", x.name);
}
@@ -775,9 +793,9 @@ async fn cmd_import_data(
async fn cmd_export_data(
app_handle: AppHandle,
export_path: &str,
workspace_id: &str,
workspace_ids: Vec<&str>,
) -> Result<(), String> {
let export_data = get_workspace_export_resources(&app_handle, workspace_id).await;
let export_data = get_workspace_export_resources(&app_handle, workspace_ids).await;
let f = File::options()
.create(true)
.truncate(true)
@@ -814,14 +832,13 @@ async fn cmd_send_http_request(
.expect("Failed to get request");
let environment = match environment_id {
Some(id) =>
match get_environment(&window, id).await {
Ok(env) => Some(env),
Err(e) => {
warn!("Failed to find environment by id {id} {}", e);
None
}
},
Some(id) => match get_environment(&window, id).await {
Ok(env) => Some(env),
Err(e) => {
warn!("Failed to find environment by id {id} {}", e);
None
}
},
None => None,
};
@@ -906,16 +923,11 @@ async fn cmd_track_event(
analytics::track_event(&window.app_handle(), resource, action, attributes).await;
}
(r, a) => {
println!(
"HttpRequest: {:?}",
serde_json::to_string(&AnalyticsResource::HttpRequest)
);
println!("Send: {:?}", serde_json::to_string(&AnalyticsAction::Send));
error!(
"Invalid action/resource for track_event: {resource}.{action} = {:?}.{:?}",
r, a
);
return Err("Invalid event".to_string());
return Err("Invalid analytics event".to_string());
}
};
Ok(())
@@ -1191,7 +1203,7 @@ async fn cmd_list_grpc_requests(workspace_id: &str, w: Window) -> Result<Vec<Grp
#[tauri::command]
async fn cmd_list_http_requests(workspace_id: &str, w: Window) -> Result<Vec<HttpRequest>, String> {
let requests = list_requests(&w, workspace_id)
let requests = list_http_requests(&w, workspace_id)
.await
.expect("Failed to find requests");
// .map_err(|e| e.to_string())
@@ -1419,7 +1431,8 @@ fn main() {
// Add DB handle
tauri::async_runtime::block_on(async move {
let pool = SqlitePool::connect(p.to_str().unwrap())
let opts = SqliteConnectOptions::from_str(p.to_str().unwrap()).unwrap();
let pool = SqlitePool::connect_with(opts)
.await
.expect("Failed to connect to database");
let m = Mutex::new(pool.clone());
@@ -1442,26 +1455,26 @@ fn main() {
cmd_create_grpc_request,
cmd_create_http_request,
cmd_create_workspace,
cmd_delete_all_http_responses,
cmd_delete_all_grpc_connections,
cmd_delete_all_http_responses,
cmd_delete_cookie_jar,
cmd_delete_environment,
cmd_delete_folder,
cmd_delete_grpc_request,
cmd_delete_grpc_connection,
cmd_delete_grpc_request,
cmd_delete_http_request,
cmd_delete_http_response,
cmd_delete_workspace,
cmd_duplicate_http_request,
cmd_duplicate_grpc_request,
cmd_duplicate_http_request,
cmd_export_data,
cmd_filter_response,
cmd_get_cookie_jar,
cmd_get_environment,
cmd_get_folder,
cmd_get_key_value,
cmd_get_http_request,
cmd_get_grpc_request,
cmd_get_http_request,
cmd_get_key_value,
cmd_get_settings,
cmd_get_workspace,
cmd_grpc_go,
@@ -1470,12 +1483,13 @@ fn main() {
cmd_list_cookie_jars,
cmd_list_environments,
cmd_list_folders,
cmd_list_http_requests,
cmd_list_grpc_requests,
cmd_list_grpc_connections,
cmd_list_grpc_events,
cmd_list_grpc_requests,
cmd_list_http_requests,
cmd_list_http_responses,
cmd_list_workspaces,
cmd_metadata,
cmd_new_window,
cmd_send_ephemeral_request,
cmd_send_http_request,

View File

@@ -1115,7 +1115,7 @@ pub async fn upsert_http_request(
}
}
pub async fn list_requests(
pub async fn list_http_requests(
mgr: &impl Manager<Wry>,
workspace_id: &str,
) -> Result<Vec<HttpRequest>, sqlx::Error> {
@@ -1524,49 +1524,68 @@ pub fn generate_id(prefix: Option<&str>) -> String {
#[derive(Default, Debug, Deserialize, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct WorkspaceExport {
pub yaak_version: String,
pub yaak_schema: i64,
pub timestamp: NaiveDateTime,
pub resources: WorkspaceExportResources,
pub yaak_version: String,
pub yaak_schema: i64,
pub timestamp: NaiveDateTime,
pub resources: WorkspaceExportResources,
}
#[derive(Default, Debug, Deserialize, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct WorkspaceExportResources {
pub workspaces: Vec<Workspace>,
pub environments: Vec<Environment>,
pub folders: Vec<Folder>,
pub http_requests: Vec<HttpRequest>,
pub grpc_requests: Vec<GrpcRequest>,
pub workspaces: Vec<Workspace>,
pub environments: Vec<Environment>,
pub folders: Vec<Folder>,
pub http_requests: Vec<HttpRequest>,
pub grpc_requests: Vec<GrpcRequest>,
}
pub async fn get_workspace_export_resources(
app_handle: &AppHandle,
workspace_id: &str,
workspace_ids: Vec<&str>,
) -> WorkspaceExport {
let workspace = get_workspace(app_handle, workspace_id)
.await
.expect("Failed to get workspace");
return WorkspaceExport {
let mut data = WorkspaceExport {
yaak_version: app_handle.package_info().version.clone().to_string(),
yaak_schema: 2,
timestamp: chrono::Utc::now().naive_utc(),
resources: WorkspaceExportResources {
workspaces: vec![workspace],
environments: list_environments(app_handle, workspace_id)
.await
.expect("Failed to get environments"),
folders: list_folders(app_handle, workspace_id)
.await
.expect("Failed to get folders"),
http_requests: list_requests(app_handle, workspace_id)
.await
.expect("Failed to get requests"),
grpc_requests: list_grpc_requests(app_handle, workspace_id)
.await
.expect("Failed to get grpc requests"),
workspaces: Vec::new(),
environments: Vec::new(),
folders: Vec::new(),
http_requests: Vec::new(),
grpc_requests: Vec::new(),
},
};
for workspace_id in workspace_ids {
data.resources.workspaces.push(
get_workspace(app_handle, workspace_id)
.await
.expect("Failed to get workspace"),
);
data.resources.environments.append(
&mut list_environments(app_handle, workspace_id)
.await
.expect("Failed to get environments"),
);
data.resources.folders.append(
&mut list_folders(app_handle, workspace_id)
.await
.expect("Failed to get folders"),
);
data.resources.http_requests.append(
&mut list_http_requests(app_handle, workspace_id)
.await
.expect("Failed to get http requests"),
);
data.resources.grpc_requests.append(
&mut list_grpc_requests(app_handle, workspace_id)
.await
.expect("Failed to get grpc requests"),
);
}
return data;
}
fn emit_upserted_model<S: Serialize + Clone>(mgr: &impl Manager<Wry>, model: S) -> S {

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Yaak",
"version": "2024.3.4"
"version": "2024.3.10"
},
"tauri": {
"windows": [],

View File

@@ -1,10 +1,9 @@
import { createBrowserRouter, Navigate, Outlet, RouterProvider, useParams } from 'react-router-dom';
import { createBrowserRouter, Navigate, RouterProvider, useParams } from 'react-router-dom';
import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
import { DialogProvider } from './DialogContext';
import { GlobalHooks } from './GlobalHooks';
import { DefaultLayout } from './DefaultLayout';
import { RedirectToLatestWorkspace } from './RedirectToLatestWorkspace';
import RouteError from './RouteError';
import Workspace from './Workspace';
import { RedirectToLatestWorkspace } from './RedirectToLatestWorkspace';
const router = createBrowserRouter([
{
@@ -58,7 +57,7 @@ function RedirectLegacyEnvironmentURLs() {
}>();
const environmentId = rawEnvironmentId === '__default__' ? undefined : rawEnvironmentId;
let to = '/';
let to;
if (workspaceId != null && requestId != null) {
to = routes.paths.request({ workspaceId, environmentId, requestId });
} else if (workspaceId != null) {
@@ -69,12 +68,3 @@ function RedirectLegacyEnvironmentURLs() {
return <Navigate to={to} />;
}
function DefaultLayout() {
return (
<DialogProvider>
<Outlet />
<GlobalHooks />
</DialogProvider>
);
}

View File

@@ -13,7 +13,7 @@ export function BasicAuth<T extends HttpRequest | GrpcRequest>({ request }: Prop
const updateGrpcRequest = useUpdateGrpcRequest(request.id);
return (
<VStack className="my-2" space={2}>
<VStack className="py-2 overflow-y-auto h-full" space={2}>
<Input
useTemplating
autocompleteVariables

View File

@@ -0,0 +1,127 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRequests } from '../hooks/useRequests';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Input } from './core/Input';
export function CommandPalette({ onClose }: { onClose: () => void }) {
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const routes = useAppRoutes();
const activeEnvironmentId = useActiveEnvironmentId();
const workspaces = useWorkspaces();
const requests = useRequests();
const [command, setCommand] = useState<string>('');
const items = useMemo<{ label: string; onSelect: () => void; key: string }[]>(() => {
const items = [];
for (const r of requests) {
items.push({
key: `switch-request-${r.id}`,
label: `Switch Request → ${fallbackRequestName(r)}`,
onSelect: () => {
return routes.navigate('request', {
workspaceId: r.workspaceId,
requestId: r.id,
environmentId: activeEnvironmentId ?? undefined,
});
},
});
}
for (const w of workspaces) {
items.push({
key: `switch-workspace-${w.id}`,
label: `Switch Workspace → ${w.name}`,
onSelect: async () => {
const environmentId = (await getRecentEnvironments(w.id))[0];
return routes.navigate('workspace', {
workspaceId: w.id,
environmentId,
});
},
});
}
return items;
}, [activeEnvironmentId, requests, routes, workspaces]);
const filteredItems = useMemo(() => {
return items.filter((v) => v.label.toLowerCase().includes(command.toLowerCase()));
}, [command, items]);
const handleSelectAndClose = useCallback(
(cb: () => void) => {
onClose();
cb();
},
[onClose],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
setSelectedIndex((prev) => prev + 1);
} else if (e.key === 'ArrowUp') {
setSelectedIndex((prev) => prev - 1);
} else if (e.key === 'Enter') {
const item = filteredItems[selectedIndex];
if (item) {
handleSelectAndClose(item.onSelect);
}
}
},
[filteredItems, handleSelectAndClose, selectedIndex],
);
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div className="px-2 py-2 w-full">
<Input
hideLabel
name="command"
label="Command"
placeholder="Type a command"
defaultValue=""
onChange={setCommand}
onKeyDown={handleKeyDown}
/>
</div>
<div className="h-full px-1.5 overflow-y-auto">
{filteredItems.map((v, i) => (
<CommandPaletteItem
active={i === selectedIndex}
key={v.key}
onClick={() => handleSelectAndClose(v.onSelect)}
>
{v.label}
</CommandPaletteItem>
))}
</div>
</div>
);
}
function CommandPaletteItem({
children,
active,
onClick,
}: {
children: ReactNode;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={classNames(
'w-full h-xs flex items-center rounded px-1.5 text-gray-600',
active && 'bg-highlightSecondary text-gray-800',
)}
>
{children}
</button>
);
}

View File

@@ -5,14 +5,9 @@ import { Dropdown } from './core/Dropdown';
interface Props {
hideFolder?: boolean;
children: DropdownProps['children'];
openOnHotKeyAction?: DropdownProps['openOnHotKeyAction'];
}
export function CreateDropdown({ hideFolder, children, openOnHotKeyAction }: Props) {
export function CreateDropdown({ hideFolder, children }: Props) {
const items = useCreateDropdownItems({ hideFolder, hideIcons: true });
return (
<Dropdown openOnHotKeyAction={openOnHotKeyAction} items={items}>
{children}
</Dropdown>
);
return <Dropdown items={items}>{children}</Dropdown>;
}

View File

@@ -0,0 +1,12 @@
import { Outlet } from 'react-router-dom';
import { DialogProvider } from './DialogContext';
import { GlobalHooks } from './GlobalHooks';
export function DefaultLayout() {
return (
<DialogProvider>
<Outlet />
<GlobalHooks />
</DialogProvider>
);
}

View File

@@ -6,7 +6,7 @@ import { Dialog } from './core/Dialog';
type DialogEntry = {
id: string;
render: ({ hide }: { hide: () => void }) => React.ReactNode;
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size' | 'noPadding'>;
} & Omit<DialogProps, 'onClose' | 'open' | 'children'>;
interface State {
dialogs: DialogEntry[];

View File

@@ -5,6 +5,7 @@ import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useDeleteEnvironment } from '../hooks/useDeleteEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import { useKeyValue } from '../hooks/useKeyValue';
import { usePrompt } from '../hooks/usePrompt';
import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
@@ -59,14 +60,16 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
<SidebarButton
active={selectedEnvironment?.id == null}
onClick={() => setSelectedEnvironmentId(null)}
className="group"
environment={null}
rightSlot={
<IconButton
size="sm"
iconSize="md"
color="custom"
title="Add sub environment"
icon="plusCircle"
iconClassName="text-gray-500 group-hover:text-gray-700"
className="group"
onClick={handleCreateEnvironment}
/>
}
@@ -113,6 +116,11 @@ const EnvironmentEditor = function ({
workspace: Workspace;
className?: string;
}) {
const valueVisibility = useKeyValue<boolean>({
namespace: 'global',
key: 'environmentValueVisibility',
fallback: true,
});
const environments = useEnvironments();
const updateEnvironment = useUpdateEnvironment(environment?.id ?? null);
const updateWorkspace = useUpdateWorkspace(workspace.id);
@@ -164,15 +172,26 @@ const EnvironmentEditor = function ({
return (
<VStack space={4} className={classNames(className, 'pl-4')}>
<HStack space={2} className="justify-between">
<Heading className="w-full flex items-center">
<Heading className="w-full flex items-center gap-1">
<div>{environment?.name ?? 'Global Variables'}</div>
<IconButton
iconClassName="text-gray-600"
size="sm"
icon={valueVisibility.value ? 'eye' : 'eyeClosed'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}
onClick={() => {
return valueVisibility.set((v) => !v);
}}
/>
</Heading>
</HStack>
<PairEditor
className="pr-2"
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueVisibility.value ? 'text' : 'password'}
valueAutocompleteVariables={false}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}
pairs={variables}
@@ -216,8 +235,8 @@ function SidebarButton({
<div
className={classNames(
className,
'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center',
'px-1', // Padding to show focus border
'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-0.5',
'px-2', // Padding to show focus border
)}
>
<Button
@@ -225,7 +244,7 @@ function SidebarButton({
size="xs"
className={classNames(
'w-full',
active ? 'text-gray-800' : 'text-gray-600 hover:text-gray-700',
active ? 'text-gray-800 bg-highlightSecondary' : 'text-gray-600 hover:text-gray-700',
)}
justify="start"
onClick={onClick}

View File

@@ -0,0 +1,107 @@
import { invoke } from '@tauri-apps/api';
import { save } from '@tauri-apps/api/dialog';
import { useState } from 'react';
import slugify from 'slugify';
import type { Workspace } from '../lib/models';
import { count } from '../lib/pluralize';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { HStack, VStack } from './core/Stacks';
interface Props {
onHide: () => void;
activeWorkspace: Workspace;
workspaces: Workspace[];
}
export function ExportDataDialog({ onHide, activeWorkspace, workspaces: allWorkspaces }: Props) {
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
[activeWorkspace.id]: true,
});
const workspaces = [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)];
const handleToggleAll = () => {
setSelectedWorkspaces(
allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}),
);
};
const handleExport = async () => {
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : 'workspaces';
const exportPath = await save({
title: 'Export Data',
defaultPath: `yaak.${slug}.json`,
});
if (exportPath == null) {
return;
}
await invoke('cmd_export_data', { workspaceIds: ids, exportPath });
onHide();
};
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<VStack space={3} className="w-full mb-3 px-4">
<table className="w-full mb-auto min-w-full max-w-full divide-y">
<thead>
<tr>
<th className="w-6 min-w-0 py-2 text-left pl-1">
<Checkbox
checked={allSelected}
indeterminate={!allSelected && !noneSelected}
hideLabel
title="All workspaces"
onChange={handleToggleAll}
/>
</th>
<th className="py-2 text-left pl-4" onClick={handleToggleAll}>
Workspace
</th>
</tr>
</thead>
<tbody className="divide-y">
{workspaces.map((w) => (
<tr key={w.id}>
<td className="min-w-0 py-1 pl-1">
<Checkbox
checked={selectedWorkspaces[w.id] ?? false}
title={w.name}
hideLabel
onChange={() =>
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
}
/>
</td>
<td
className="py-1 pl-4 text-gray-700 whitespace-nowrap overflow-x-auto hide-scrollbars"
onClick={() => setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))}
>
{w.name} {w.id === activeWorkspace.id ? '(current workspace)' : ''}
</td>
</tr>
))}
</tbody>
</table>
<HStack space={2} justifyContent="end">
<Button className="focus" color="gray" onClick={onHide}>
Cancel
</Button>
<Button
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={handleExport}
>
Export {count('Workspace', numSelected, { omitSingle: true, noneWord: 'Nothing' })}
</Button>
</HStack>
</VStack>
);
}

View File

@@ -16,6 +16,7 @@ export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {
enabled: p.enabled,
name: p.name,
value: p.file ?? p.value,
contentType: p.contentType,
isFile: !!p.file,
})),
[body.form],
@@ -27,6 +28,7 @@ export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {
form: pairs.map((p) => ({
enabled: p.enabled,
name: p.name,
contentType: p.contentType,
file: p.isFile ? p.value : undefined,
value: p.isFile ? undefined : p.value,
})),

View File

@@ -2,7 +2,9 @@ import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { useGlobalCommands } from '../hooks/useGlobalCommands';
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
@@ -32,8 +34,9 @@ export function GlobalHooks() {
useRecentRequests();
useSyncAppearance();
useSyncWindowTitle();
useGlobalCommands();
useCommandPalette();
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);

View File

@@ -124,7 +124,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
}
{...extraEditorProps}
/>
<div className="grid min-h-[5rem]">
<div className="grid grid-rows-[auto_minmax(0,1fr)] min-h-[5rem]">
<Separator variant="primary" className="pb-1">
Variables
</Separator>

View File

@@ -122,7 +122,7 @@ export function GrpcConnectionSetupPane({
const handleSend = useCallback(async () => {
if (activeRequest == null) return;
onSend({ message: activeRequest.message });
}, [activeRequest, onGo]);
}, [activeRequest, onSend]);
const tabs: TabItem[] = useMemo(
() => [
@@ -195,7 +195,6 @@ export function GrpcConnectionSetupPane({
shortLabel: o.label,
}))}
extraItems={[
{ type: 'separator' },
{
label: 'Refresh',
type: 'default',

View File

@@ -1,14 +1,13 @@
import classNames from 'classnames';
import { useMemo, useRef } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { useKeyPressEvent } from 'react-use';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useGrpcRequests } from '../hooks/useGrpcRequests';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequests } from '../hooks/useHttpRequests';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
@@ -21,20 +20,10 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
const activeEnvironment = useActiveEnvironment();
const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests();
const routes = useAppRoutes();
const allRecentRequestIds = useRecentRequests();
const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]);
const requests = useMemo(() => [...httpRequests, ...grpcRequests], [httpRequests, grpcRequests]);
// Toggle the menu on Cmd+k
useKey('k', (e) => {
if (e.metaKey) {
e.preventDefault();
dropdownRef.current?.toggle();
}
});
const requests = useRequests();
// Handle key-up
useKeyPressEvent('Control', undefined, () => {
@@ -42,12 +31,12 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
dropdownRef.current?.select?.();
});
useHotKey('requestSwitcher.prev', () => {
useHotKey('request_switcher.prev', () => {
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
dropdownRef.current?.next?.();
});
useHotKey('requestSwitcher.next', () => {
useHotKey('request_switcher.next', () => {
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
dropdownRef.current?.prev?.();
});
@@ -94,6 +83,7 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
<Button
data-tauri-drag-region
size="sm"
hotkeyAction="request_switcher.toggle"
className={classNames(
className,
'text-gray-800 text-sm truncate pointer-events-auto',

View File

@@ -3,17 +3,23 @@ import { useNavigate } from 'react-router-dom';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { getRecentRequests } from '../hooks/useRecentRequests';
import { getRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useWorkspaces } from '../hooks/useWorkspaces';
export function RedirectToLatestWorkspace() {
const navigate = useNavigate();
const routes = useAppRoutes();
const workspaces = useWorkspaces();
const recentWorkspaces = useRecentWorkspaces();
useEffect(() => {
if (workspaces.length === 0) {
console.log('No workspaces found to redirect to. Skipping.');
return;
}
(async function () {
const workspaceId = (await getRecentWorkspaces())[0] ?? workspaces[0]?.id ?? 'n/a';
const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? 'n/a';
const environmentId = (await getRecentEnvironments(workspaceId))[0];
const requestId = (await getRecentRequests(workspaceId))[0];
@@ -23,7 +29,7 @@ export function RedirectToLatestWorkspace() {
navigate(routes.paths.workspace({ workspaceId, environmentId }));
}
})();
}, [navigate, routes.paths, workspaces, workspaces.length]);
}, [navigate, recentWorkspaces, routes.paths, workspaces, workspaces.length]);
return <></>;
}

View File

@@ -61,6 +61,25 @@ export const RequestPane = memo(function RequestPane({
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const contentType = useContentTypeFromHeaders(activeRequest.headers);
const handleContentTypeChange = useCallback(
async (contentType: string | null) => {
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type');
if (contentType != null) {
headers.push({
name: 'Content-Type',
value: contentType,
enabled: true,
});
}
await updateRequest.mutateAsync({ headers });
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
},
[activeRequest.headers, updateRequest],
);
const tabs: TabItem[] = useMemo(
() => [
{
@@ -153,7 +172,15 @@ export const RequestPane = memo(function RequestPane({
},
},
],
[activeRequest, updateRequest],
[
activeRequest.authentication,
activeRequest.authenticationType,
activeRequest.bodyType,
activeRequest.headers,
activeRequest.urlParameters,
handleContentTypeChange,
updateRequest,
],
);
const handleBodyChange = useCallback(
@@ -161,24 +188,6 @@ export const RequestPane = memo(function RequestPane({
[updateRequest],
);
const handleContentTypeChange = useCallback(
async (contentType: string | null) => {
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type');
if (contentType != null) {
headers.push({
name: 'Content-Type',
value: contentType,
enabled: true,
});
}
await updateRequest.mutateAsync({ headers });
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
},
[activeRequest.headers, updateRequest],
);
const handleBinaryFileChange = useCallback(
(body: HttpRequest['body']) => {
updateRequest.mutate({ body });
@@ -337,7 +346,7 @@ export const RequestPane = memo(function RequestPane({
onChangeContentType={handleContentTypeChange}
/>
) : (
<EmptyStateText>No Body</EmptyStateText>
<EmptyStateText>Empty Body</EmptyStateText>
)}
</TabContent>
</Tabs>

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ForwardedRef, ReactNode } from 'react';
import React, { Fragment, forwardRef, useCallback, useMemo, useRef, useState } from 'react';
import React, { forwardRef, Fragment, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useKey, useKeyPressEvent } from 'react-use';
@@ -15,13 +15,12 @@ import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest';
import { useFolders } from '../hooks/useFolders';
import { useGrpcRequests } from '../hooks/useGrpcRequests';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequests } from '../hooks/useHttpRequests';
import { useKeyValue } from '../hooks/useKeyValue';
import { useLatestGrpcConnection } from '../hooks/useLatestGrpcConnection';
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
import { usePrompt } from '../hooks/usePrompt';
import { useRequests } from '../hooks/useRequests';
import { useSendManyRequests } from '../hooks/useSendFolder';
import { useSendRequest } from '../hooks/useSendRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
@@ -57,13 +56,12 @@ interface TreeNode {
}
export function Sidebar({ className }: Props) {
const { hidden, show, hide } = useSidebarHidden();
const [hidden, setHidden] = useSidebarHidden();
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequest = useActiveRequest();
const activeEnvironmentId = useActiveEnvironmentId();
const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests();
const folders = useFolders();
const requests = useRequests();
const activeWorkspace = useActiveWorkspace();
const duplicateHttpRequest = useDuplicateHttpRequest({
id: activeRequest?.id ?? null,
@@ -135,7 +133,7 @@ export function Sidebar({ className }: Props) {
selectedRequest = node.item;
}
const childItems = [...httpRequests, ...grpcRequests, ...folders].filter((f) =>
const childItems = [...requests, ...folders].filter((f) =>
node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id,
);
@@ -154,7 +152,7 @@ export function Sidebar({ className }: Props) {
const tree = next({ item: activeWorkspace, children: [], depth: 0 });
return { tree, treeParentMap, selectableRequests, selectedRequest };
}, [activeWorkspace, selectedId, httpRequests, grpcRequests, folders]);
}, [activeWorkspace, selectedId, requests, folders]);
const deleteSelectedRequest = useDeleteRequest(selectedRequest);
@@ -243,16 +241,15 @@ export function Sidebar({ className }: Props) {
useKeyPressEvent('Delete', handleDeleteKey);
useHotKey('sidebar.focus', async () => {
console.log('sidebar.focus', { hidden, hasFocus });
// Hide the sidebar if it's already focused
if (!hidden && hasFocus) {
await hide();
await setHidden(true);
return;
}
// Show the sidebar if it's hidden
if (hidden) {
await show();
await setHidden(false);
}
// Select 0 index on focus if none selected
@@ -419,6 +416,19 @@ export function Sidebar({ className }: Props) {
],
);
const [showMainContextMenu, setShowMainContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleMainContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowMainContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const mainContextMenuItems = useCreateDropdownItems();
// Not ready to render yet
if (tree == null || collapsed.value == null) {
return null;
@@ -431,11 +441,17 @@ export function Sidebar({ className }: Props) {
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
onContextMenu={handleMainContextMenu}
className={classNames(
className,
'h-full pb-3 overflow-y-scroll overflow-x-visible hide-scrollbars pt-2',
)}
>
<ContextMenu
show={showMainContextMenu}
items={mainContextMenuItems}
onClose={() => setShowMainContextMenu(null)}
/>
<SidebarItems
treeParentMap={treeParentMap}
selectedId={selectedId}
@@ -495,7 +511,7 @@ function SidebarItems({
className={classNames(
tree.depth > 0 && 'border-l border-highlight',
tree.depth === 0 && 'ml-0',
tree.depth >= 1 && 'ml-[1.3em]',
tree.depth >= 1 && 'ml-[1.2em]',
)}
>
{tree.children.map((child, i) => (
@@ -753,7 +769,7 @@ const SidebarItem = forwardRef(function SidebarItem(
data-active={isActive}
data-selected={selected}
className={classNames(
'w-full flex gap-2 items-center text-sm h-xs px-1.5 rounded-md transition-colors',
'w-full flex gap-1.5 items-center text-sm h-xs px-1.5 rounded-md transition-colors',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-highlightSecondary text-gray-800',
!isActive &&

View File

@@ -1,12 +1,22 @@
import { memo } from 'react';
import { useMemo } from 'react';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { trackEvent } from '../lib/analytics';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { CreateDropdown } from './CreateDropdown';
export const SidebarActions = memo(function SidebarActions() {
const { hidden, show, hide } = useSidebarHidden();
export function SidebarActions() {
const floating = useShouldFloatSidebar();
const [normalHidden, setNormalHidden] = useSidebarHidden();
const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();
const hidden = floating ? floatingHidden : normalHidden;
const setHidden = useMemo(
() => (floating ? setFloatingHidden : setNormalHidden),
[floating, setFloatingHidden, setNormalHidden],
);
return (
<HStack className="h-full" alignItems="center">
@@ -14,10 +24,9 @@ export const SidebarActions = memo(function SidebarActions() {
onClick={async () => {
trackEvent('sidebar', 'toggle');
// NOTE: We're not using `toggle` because it may be out of sync
// from changes in other windows
if (hidden) await show();
else await hide();
// NOTE: We're not using the (h) => !h pattern here because the data
// might be different if another window changed it (out of sync)
await setHidden(!hidden);
}}
className="pointer-events-auto"
size="sm"
@@ -25,9 +34,14 @@ export const SidebarActions = memo(function SidebarActions() {
hotkeyAction="sidebar.toggle"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
<CreateDropdown openOnHotKeyAction="http_request.create">
<IconButton size="sm" icon="plusCircle" title="Add Resource" />
<CreateDropdown>
<IconButton
size="sm"
icon="plusCircle"
title="Add Resource"
hotkeyAction="http_request.create"
/>
</CreateDropdown>
</HStack>
);
});
}

View File

@@ -60,7 +60,7 @@ export const UrlBar = memo(function UrlBar({
hideLabel
useTemplating
contentType="url"
className="px-0 py-0.5"
className="pl-0 pr-1.5 py-0.5"
name="url"
label="Enter URL"
forceUpdateKey={forceUpdateKey}
@@ -76,7 +76,7 @@ export const UrlBar = memo(function UrlBar({
<RequestMethodDropdown
method={method}
onChange={onMethodChange}
className="!h-auto my-0.5 ml-0.5"
className="my-0.5 ml-0.5"
/>
)
}
@@ -87,7 +87,7 @@ export const UrlBar = memo(function UrlBar({
iconSize="md"
title="Send Request"
type="submit"
className="w-8 !h-auto my-0.5 mr-0.5"
className="w-8 my-0.5 mr-0.5"
icon={isLoading ? 'x' : submitIcon}
hotkeyAction="http_request.send"
/>

View File

@@ -6,14 +6,16 @@ import type {
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
import { useImportData } from '../hooks/useImportData';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { useOsInfo } from '../hooks/useOsInfo';
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useWorkspaces } from '../hooks/useWorkspaces';
@@ -37,34 +39,22 @@ const head = { gridArea: 'head' };
const body = { gridArea: 'body' };
const drag = { gridArea: 'drag' };
const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
export default function Workspace() {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = useActiveWorkspaceId();
const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, show, hidden } = useSidebarHidden();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeRequest = useActiveRequest();
const windowSize = useWindowSize();
const importData = useImportData();
const [floating, setFloating] = useState<boolean>(false);
const floating = useShouldFloatSidebar();
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
// float/un-float sidebar on window resize
useEffect(() => {
const shouldHide = windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
if (shouldHide && !floating) {
setFloating(true);
hide().catch(console.error);
} else if (!shouldHide && floating) {
setFloating(false);
}
}, [floating, hide, windowSize.width]);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
@@ -84,10 +74,10 @@ export default function Workspace() {
e.preventDefault(); // Prevent text selection and things
const newWidth = startWidth + (e.clientX - mouseStartX);
if (newWidth < 50) {
await hide();
await setSidebarHidden(true);
resetWidth();
} else {
await show();
await setSidebarHidden(false);
setWidth(newWidth);
}
},
@@ -101,10 +91,10 @@ export default function Workspace() {
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
},
[setWidth, resetWidth, width, hide, show],
[width, setSidebarHidden, resetWidth, setWidth],
);
const sideWidth = hidden ? 0 : width;
const sideWidth = sidebarHidden ? 0 : width;
const styles = useMemo<CSSProperties>(
() => ({
gridTemplate: floating
@@ -144,7 +134,11 @@ export default function Workspace() {
)}
>
{floating ? (
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
<Overlay
open={!floatingSidebarHidden}
portalName="sidebar"
onClose={() => setFloatingSidebarHidden(true)}
>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}

View File

@@ -3,7 +3,7 @@ import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useCommand } from '../hooks/useCommands';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
@@ -30,7 +30,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const activeWorkspaceId = activeWorkspace?.id ?? null;
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const createWorkspace = useCommand('workspace.create');
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();

View File

@@ -16,7 +16,7 @@ export function Banner({ children, className, color = 'gray' }: Props) {
color === 'gray' && 'border-gray-500/60 bg-gray-300/10 text-gray-800',
color === 'warning' && 'border-orange-500/60 bg-orange-300/10 text-orange-800',
color === 'danger' && 'border-red-500/60 bg-red-300/10 text-red-800',
color === 'success' && 'border-green-500/60 bg-green-300/10 text-green-800',
color === 'success' && 'border-violet-500/60 bg-violet-300/10 text-violet-800',
)}
>
{children}

View File

@@ -60,17 +60,27 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
size === 'sm' && 'h-sm px-2.5 text-sm',
size === 'xs' && 'h-xs px-2 text-sm',
// Solids
variant === 'solid' && color === 'custom' && 'ring-blue-400',
variant === 'solid' &&
color === 'custom' &&
'ring-blue-400 enabled:hocus:bg-highlightSecondary',
variant === 'solid' &&
color === 'default' &&
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-800 ring-blue-400',
variant === 'solid' &&
color === 'gray' &&
'text-gray-800 bg-highlight enabled:hocus:text-gray-1000 ring-blue-400',
variant === 'solid' && color === 'primary' && 'bg-blue-400 text-white ring-blue-700',
variant === 'solid' && color === 'secondary' && 'bg-violet-400 text-white ring-violet-700',
variant === 'solid' && color === 'warning' && 'bg-orange-400 text-white ring-orange-700',
variant === 'solid' && color === 'danger' && 'bg-red-400 text-white ring-red-700',
'text-gray-800 bg-gray-200/70 enabled:hocus:bg-gray-200 ring-blue-400',
variant === 'solid' &&
color === 'primary' &&
'bg-blue-400 text-white ring-blue-700 enabled:hocus:bg-blue-500',
variant === 'solid' &&
color === 'secondary' &&
'bg-violet-400 text-white ring-violet-700 enabled:hocus:bg-violet-500',
variant === 'solid' &&
color === 'warning' &&
'bg-orange-400 text-white ring-orange-700 enabled:hocus:bg-orange-500',
variant === 'solid' &&
color === 'danger' &&
'bg-red-400 text-white ring-red-700 enabled:hocus:bg-red-500',
// Borders
variant === 'border' && 'border',
variant === 'border' &&

View File

@@ -8,10 +8,21 @@ interface Props {
onChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
inputWrapperClassName?: string;
indeterminate?: boolean;
hideLabel?: boolean;
}
export function Checkbox({ checked, onChange, className, disabled, title, hideLabel }: Props) {
export function Checkbox({
checked,
indeterminate,
onChange,
className,
inputWrapperClassName,
disabled,
title,
hideLabel,
}: Props) {
return (
<HStack
as="label"
@@ -19,35 +30,21 @@ export function Checkbox({ checked, onChange, className, disabled, title, hideLa
alignItems="center"
className={classNames(className, 'text-gray-900 text-sm', disabled && 'opacity-disabled')}
>
<div className="relative flex">
<div className={classNames(inputWrapperClassName, 'relative flex')}>
<input
aria-hidden
className="appearance-none w-4 h-4 flex-shrink-0 border border-gray-200 rounded focus:border-focus outline-none ring-0"
className={classNames(
'appearance-none w-4 h-4 flex-shrink-0 border border-highlight',
'rounded hocus:border-focus hocus:bg-focus/[5%] outline-none ring-0',
)}
type="checkbox"
disabled={disabled}
onChange={() => onChange(!checked)}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon size="sm" icon={checked ? 'check' : 'empty'} />
<Icon size="sm" icon={indeterminate ? 'minus' : checked ? 'check' : 'empty'} />
</div>
</div>
{/*<button*/}
{/* role="checkbox"*/}
{/* aria-checked={checked ? 'true' : 'false'}*/}
{/* disabled={disabled}*/}
{/* onClick={handleClick}*/}
{/* title={title}*/}
{/* className={classNames(*/}
{/* className,*/}
{/* 'flex-shrink-0 w-4 h-4 border border-gray-200 rounded',*/}
{/* 'focus:border-focus',*/}
{/* 'disabled:opacity-disabled',*/}
{/* checked && 'bg-gray-200/10',*/}
{/* // Remove focus style*/}
{/* 'outline-none',*/}
{/* )}*/}
{/*>*/}
{/*</button>*/}
{!hideLabel && title}
</HStack>
);

View File

@@ -17,6 +17,7 @@ export interface DialogProps {
size?: 'sm' | 'md' | 'lg' | 'full' | 'dynamic';
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
}
export function Dialog({
@@ -29,6 +30,7 @@ export function Dialog({
description,
hideX,
noPadding,
noScroll,
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
@@ -60,7 +62,7 @@ export function Dialog({
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
'grid grid-rows-[auto_minmax(0,1fr)]',
'grid grid-rows-[auto_auto_minmax(0,1fr)]',
'relative bg-gray-50 pointer-events-auto',
'rounded-lg',
'dark:border border-highlight shadow shadow-black/10',
@@ -79,15 +81,20 @@ export function Dialog({
) : (
<span />
)}
{description && (
{description ? (
<p className="px-6 text-gray-700" id={descriptionId}>
{description}
</p>
) : (
<span />
)}
<div
className={classNames(
'h-full w-full grid grid-cols-[minmax(0,1fr)] overflow-y-auto',
'h-full w-full grid grid-cols-[minmax(0,1fr)]',
!noPadding && 'px-6 py-2',
!noScroll && 'overflow-y-auto',
)}
>
{children}

View File

@@ -55,7 +55,6 @@ export type DropdownItem = DropdownItemDefault | DropdownItemSeparator;
export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[];
openOnHotKeyAction?: HotkeyAction;
onOpen?: () => void;
onClose?: () => void;
}
@@ -71,7 +70,7 @@ export interface DropdownRef {
}
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items, openOnHotKeyAction, onOpen, onClose }: DropdownProps,
{ children, items, onOpen, onClose }: DropdownProps,
ref,
) {
const [isOpen, _setIsOpen] = useState<boolean>(false);
@@ -88,11 +87,6 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
[onClose, onOpen],
);
useHotKey(openOnHotKeyAction ?? null, () => {
setIsOpen(true);
menuRef.current?.next?.();
});
useImperativeHandle(ref, () => ({
...menuRef.current,
isOpen: isOpen,

View File

@@ -55,7 +55,7 @@
}
.placeholder-widget {
@apply text-xs text-violet-700 dark:text-violet-700 px-1 rounded cursor-default dark:shadow;
@apply text-xs text-violet-700 dark:text-violet-700 px-1 mx-[0.5px] rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-violet-500/20 border border-violet-500/20 border-opacity-40;
@@ -131,7 +131,8 @@
}
.cm-editor .fold-gutter-icon {
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded;
@apply cursor-default !important;
}
.cm-editor .fold-gutter-icon::after {
@@ -152,8 +153,9 @@
}
.cm-editor .cm-foldPlaceholder {
@apply px-2 border border-gray-400/50 bg-gray-300/50 cursor-default;
@apply px-2 border border-gray-400/50 bg-gray-300/50;
@apply hover:text-gray-800 hover:border-gray-400;
@apply cursor-default !important;
}
.cm-editor .cm-activeLineGutter {
@@ -185,18 +187,18 @@
}
.cm-tooltip.cm-tooltip-hover {
@apply shadow-lg bg-gray-200 rounded text-gray-700 border border-gray-500 z-50 pointer-events-auto text-xs;
@apply shadow-lg bg-gray-100 rounded text-gray-700 border border-gray-500 z-50 pointer-events-auto text-xs;
@apply px-2 py-1;
a {
@apply text-yellow-500 font-bold;
@apply text-gray-800;
&:hover {
@apply underline;
}
&::after {
@apply text-yellow-600 bg-yellow-600 h-3 w-3 ml-1;
@apply text-gray-800 bg-gray-800 h-3 w-3 ml-1;
content: '';
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
-webkit-mask-size: contain;
@@ -206,8 +208,9 @@
}
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip-autocomplete {
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-300 z-50 pointer-events-auto text-xs;
.cm-tooltip.cm-tooltip-autocomplete,
.cm-tooltip.cm-completionInfo {
@apply shadow-lg bg-gray-100 rounded text-gray-700 border border-gray-300 z-50 pointer-events-auto text-xs;
.cm-completionIcon {
@apply italic font-mono;

View File

@@ -9,7 +9,6 @@ import {
cloneElement,
forwardRef,
isValidElement,
memo,
useCallback,
useEffect,
useImperativeHandle,
@@ -57,7 +56,7 @@ export interface EditorProps {
actions?: ReactNode;
}
const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
readOnly,
type = 'text',
@@ -295,8 +294,6 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
);
});
export const Editor = memo(_Editor);
function getExtensions({
container,
readOnly,
@@ -319,7 +316,12 @@ function getExtensions({
undefined;
return [
...baseExtensions,
// NOTE: These *must* be anonymous functions so the references update properly
EditorView.domEventHandlers({
focus: () => onFocus.current?.(),
blur: () => onBlur.current?.(),
keydown: (e) => onKeyDown.current?.(e),
}),
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
@@ -328,20 +330,14 @@ function getExtensions({
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
// Handle onFocus
// NOTE: These *must* be anonymous functions so the references update properly
EditorView.domEventHandlers({
focus: () => onFocus.current?.(),
blur: () => onBlur.current?.(),
keydown: (e) => onKeyDown.current?.(e),
}),
// Handle onChange
EditorView.updateListener.of((update) => {
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
...baseExtensions,
];
}

View File

@@ -17,6 +17,7 @@ const icons = {
arrowUpFromDot: lucide.ArrowUpFromDotIcon,
box: lucide.BoxIcon,
cake: lucide.CakeIcon,
minus: lucide.MinusIcon,
chat: lucide.MessageSquare,
check: lucide.CheckIcon,
chevronDown: lucide.ChevronDownIcon,

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
import { useStateSyncDefault } from '../../hooks/useStateSyncDefault';
import type { EditorProps } from './Editor';
import { Editor } from './Editor';
import { IconButton } from './IconButton';
@@ -69,7 +70,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
}: InputProps,
ref,
) {
const [obscured, setObscured] = useState(type === 'password');
const [obscured, setObscured] = useStateSyncDefault(type === 'password');
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
@@ -181,9 +182,10 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className="mr-0.5"
className="mr-0.5 group/obscure !h-auto my-0.5"
iconClassName="text-gray-500 group-hover/obscure:text-gray-800"
iconSize="sm"
icon={obscured ? 'eyeClosed' : 'eye'}
icon={obscured ? 'eye' : 'eyeClosed'}
onClick={() => setObscured((o) => !o)}
/>
)}

View File

@@ -5,6 +5,7 @@ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } fro
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { v4 as uuid } from 'uuid';
import { usePrompt } from '../../hooks/usePrompt';
import { DropMarker } from '../DropMarker';
import { Button } from './Button';
import { Checkbox } from './Checkbox';
@@ -14,6 +15,7 @@ import { Icon } from './Icon';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
import { Input } from './Input';
import { RadioDropdown } from './RadioDropdown';
export type PairEditorProps = {
pairs: Pair[];
@@ -22,6 +24,7 @@ export type PairEditorProps = {
className?: string;
namePlaceholder?: string;
valuePlaceholder?: string;
valueType?: 'text' | 'password';
nameAutocomplete?: GenericCompletionConfig;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
nameAutocompleteVariables?: boolean;
@@ -29,6 +32,7 @@ export type PairEditorProps = {
allowFileValues?: boolean;
nameValidate?: InputProps['validate'];
valueValidate?: InputProps['validate'];
noScroll?: boolean;
};
export type Pair = {
@@ -36,6 +40,7 @@ export type Pair = {
enabled?: boolean;
name: string;
value: string;
contentType?: string;
isFile?: boolean;
};
@@ -51,7 +56,9 @@ export const PairEditor = memo(function PairEditor({
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
valueType,
onChange,
noScroll,
pairs: originalPairs,
valueAutocomplete,
valueAutocompleteVariables,
@@ -90,7 +97,7 @@ export const PairEditor = memo(function PairEditor({
[onChange],
);
const handleMove = useCallback<FormRowProps['onMove']>(
const handleMove = useCallback<PairEditorRowProps['onMove']>(
(id, side) => {
const dragIndex = pairs.findIndex((r) => r.id === id);
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
@@ -98,7 +105,7 @@ export const PairEditor = memo(function PairEditor({
[pairs],
);
const handleEnd = useCallback<FormRowProps['onEnd']>(
const handleEnd = useCallback<PairEditorRowProps['onEnd']>(
(id: string) => {
if (hoveredIndex === null) return;
setHoveredIndex(null);
@@ -157,7 +164,8 @@ export const PairEditor = memo(function PairEditor({
className={classNames(
className,
'@container',
'pb-2 grid overflow-auto max-h-full',
'pb-2 mb-auto',
!noScroll && 'overflow-y-auto max-h-full',
// Move over the width of the drag handle
'-ml-3',
// Pad to make room for the drag divider
@@ -169,13 +177,14 @@ export const PairEditor = memo(function PairEditor({
return (
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
<FormRow
<PairEditorRow
pairContainer={p}
className="py-1"
isLast={isLast}
allowFileValues={allowFileValues}
nameAutocompleteVariables={nameAutocompleteVariables}
valueAutocompleteVariables={valueAutocompleteVariables}
valueType={valueType}
forceFocusPairId={forceFocusPairId}
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
@@ -201,7 +210,7 @@ enum ItemTypes {
ROW = 'pair-row',
}
type FormRowProps = {
type PairEditorRowProps = {
className?: string;
pairContainer: PairContainer;
forceFocusPairId?: string | null;
@@ -218,6 +227,7 @@ type FormRowProps = {
| 'valueAutocomplete'
| 'nameAutocompleteVariables'
| 'valueAutocompleteVariables'
| 'valueType'
| 'namePlaceholder'
| 'valuePlaceholder'
| 'nameValidate'
@@ -226,7 +236,7 @@ type FormRowProps = {
| 'allowFileValues'
>;
const FormRow = memo(function FormRow({
function PairEditorRow({
allowFileValues,
className,
forceFocusPairId,
@@ -246,9 +256,11 @@ const FormRow = memo(function FormRow({
valueAutocompleteVariables,
valuePlaceholder,
valueValidate,
}: FormRowProps) {
valueType,
}: PairEditorRowProps) {
const { id } = pairContainer;
const ref = useRef<HTMLDivElement>(null);
const prompt = usePrompt();
const nameInputRef = useRef<EditorView>(null);
useEffect(() => {
@@ -278,6 +290,11 @@ const FormRow = memo(function FormRow({
[onChange, id, pairContainer.pair],
);
const handleChangeValueContentType = useMemo(
() => (contentType: string) => onChange({ id, pair: { ...pairContainer.pair, contentType } }),
[onChange, id, pairContainer.pair],
);
const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]);
const handleDelete = useCallback(
() => onDelete?.(pairContainer, false),
@@ -397,42 +414,76 @@ const FormRow = memo(function FormRow({
name="value"
onChange={handleChangeValueText}
onFocus={handleFocus}
type={isLast ? 'text' : valueType}
placeholder={valuePlaceholder ?? 'value'}
autocomplete={valueAutocomplete?.(pairContainer.pair.name)}
autocompleteVariables={valueAutocompleteVariables}
/>
)}
{allowFileValues && (
<Dropdown
items={[
{ key: 'text', label: 'Text', onSelect: () => handleChangeValueText('') },
{ key: 'file', label: 'File', onSelect: () => handleChangeValueFile('') },
]}
>
<IconButton
iconSize="sm"
size="xs"
icon={isLast ? 'empty' : 'chevronDown'}
title="Select form data type"
/>
</Dropdown>
)}
</div>
</div>
<IconButton
aria-hidden={isLast}
disabled={isLast}
color="custom"
icon={!isLast ? 'trash' : 'empty'}
size="sm"
iconSize="sm"
title="Delete header"
onClick={!isLast ? handleDelete : undefined}
className="ml-0.5 opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"
/>
{allowFileValues ? (
<RadioDropdown
value={pairContainer.pair.isFile ? 'file' : 'text'}
onChange={(v) => {
if (v === 'file') handleChangeValueFile('');
else handleChangeValueText('');
}}
items={[
{ label: 'Text', value: 'text' },
{ label: 'File', value: 'file' },
]}
extraItems={[
{
key: 'mime',
label: 'Set Content-Type',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const v = await prompt({
id: 'content-type',
require: false,
title: 'Override Content-Type',
label: 'Content-Type',
placeholder: 'text/plain',
defaultValue: pairContainer.pair.contentType ?? '',
name: 'content-type',
confirmLabel: 'Set',
description: 'Leave blank to auto-detect',
});
handleChangeValueContentType(v);
},
},
{
key: 'delete',
label: 'Delete',
onSelect: handleDelete,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
]}
>
<IconButton
iconSize="sm"
size="xs"
icon={isLast ? 'empty' : 'chevronDown'}
title="Select form data type"
/>
</RadioDropdown>
) : (
<Dropdown
items={[{ key: 'delete', label: 'Delete', onSelect: handleDelete, variant: 'danger' }]}
>
<IconButton
iconSize="sm"
size="xs"
icon={isLast ? 'empty' : 'chevronDown'}
title="Select form data type"
/>
</Dropdown>
)}
</div>
);
});
}
const newPairContainer = (initialPair?: Pair): PairContainer => {
const id = initialPair?.id ?? uuid();

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import type { DropdownItemSeparator, DropdownProps } from './Dropdown';
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from './Dropdown';
import { Dropdown } from './Dropdown';
import { Icon } from './Icon';
@@ -42,7 +42,7 @@ export function RadioDropdown<T = string | null>({
};
}
}),
...(extraItems ?? []),
...((extraItems ? [{ type: 'separator' }, ...extraItems] : []) as DropdownItem[]),
],
[items, extraItems, value, onChange],
);

View File

@@ -12,6 +12,7 @@ export interface PromptProps {
name: InputProps['name'];
defaultValue: InputProps['defaultValue'];
placeholder: InputProps['placeholder'];
require?: InputProps['require'];
confirmLabel?: string;
}
@@ -22,6 +23,7 @@ export function Prompt({
defaultValue,
placeholder,
onResult,
require = true,
confirmLabel = 'Save',
}: PromptProps) {
const [value, setValue] = useState<string>(defaultValue ?? '');
@@ -41,8 +43,8 @@ export function Prompt({
>
<Input
hideLabel
require
autoSelect
require={require}
placeholder={placeholder}
label={label}
name={name}

View File

@@ -1,7 +1,6 @@
import type { GrpcRequest, HttpRequest } from '../lib/models';
import { useActiveRequestId } from './useActiveRequestId';
import { useGrpcRequests } from './useGrpcRequests';
import { useHttpRequests } from './useHttpRequests';
import { useRequests } from './useRequests';
interface TypeMap {
http_request: HttpRequest;
@@ -12,16 +11,14 @@ export function useActiveRequest<T extends keyof TypeMap>(
model?: T | undefined,
): TypeMap[T] | null {
const requestId = useActiveRequestId();
const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests();
const requests = useRequests();
if (model === 'http_request') {
return (httpRequests.find((r) => r.id === requestId) ?? null) as TypeMap[T] | null;
} else if (model === 'grpc_request') {
return (grpcRequests.find((r) => r.id === requestId) ?? null) as TypeMap[T] | null;
} else {
return (grpcRequests.find((r) => r.id === requestId) ??
httpRequests.find((r) => r.id === requestId) ??
null) as TypeMap[T] | null;
for (const request of requests) {
const modelMatch = model == null ? true : request.model === model;
if (modelMatch && request.id === requestId) {
return request as TypeMap[T];
}
}
return null;
}

View File

@@ -1,10 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import * as app from '@tauri-apps/api/app';
import * as path from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api';
export function useAppInfo() {
return useQuery(['appInfo'], async () => {
const [version, appDataDir] = await Promise.all([app.getVersion(), path.appDataDir()]);
return { version, appDataDir };
return (await invoke('cmd_metadata')) as {
isDev: boolean;
version: string;
name: string;
appDataDir: string;
};
});
}

View File

@@ -0,0 +1,24 @@
import { CommandPalette } from '../components/CommandPalette';
import { useDialog } from '../components/DialogContext';
import { useAppInfo } from './useAppInfo';
import { useHotKey } from './useHotKey';
export function useCommandPalette() {
const dialog = useDialog();
const appInfo = useAppInfo();
useHotKey('command_palette.toggle', () => {
// Disabled in production for now
if (!appInfo.data?.isDev) {
return;
}
dialog.toggle({
id: 'command_palette',
size: 'md',
hideX: true,
noPadding: true,
noScroll: true,
render: ({ hide }) => <CommandPalette onClose={hide} />,
});
});
}

View File

@@ -0,0 +1,41 @@
import type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { useEffect } from 'react';
import { createGlobalState } from 'react-use';
import type { TrackAction, TrackResource } from '../lib/analytics';
import type { Workspace } from '../lib/models';
interface CommandInstance<T, V> extends UseMutationOptions<V, unknown, T> {
track?: [TrackResource, TrackAction];
name: string;
}
export type Commands = {
'workspace.create': CommandInstance<Partial<Pick<Workspace, 'name'>>, Workspace>;
};
const useCommandState = createGlobalState<Commands>();
export function useRegisterCommand<K extends keyof Commands>(action: K, command: Commands[K]) {
const [, setState] = useCommandState();
useEffect(() => {
setState((commands) => {
return { ...commands, [action]: command };
});
// Remove action when it goes out of scope
return () => {
setState((commands) => {
return { ...commands, [action]: undefined };
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [action]);
}
export function useCommand<K extends keyof Commands>(action: K) {
const [commands] = useCommandState();
const cmd = commands[action];
return useMutation({ ...cmd });
}

View File

@@ -1,31 +1,36 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { save } from '@tauri-apps/api/dialog';
import slugify from 'slugify';
import { useDialog } from '../components/DialogContext';
import { ExportDataDialog } from '../components/ExportDataDialog';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAlert } from './useAlert';
import { useWorkspaces } from './useWorkspaces';
export function useExportData() {
const workspace = useActiveWorkspace();
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const alert = useAlert();
const dialog = useDialog();
return useMutation({
onError: (err: string) => {
alert({ id: 'export-failed', title: 'Export Failed', body: err });
},
mutationFn: async () => {
if (workspace == null) return;
if (activeWorkspace == null || workspaces.length === 0) return;
const workspaceSlug = slugify(workspace.name, { lower: true });
const exportPath = await save({
title: 'Export Data',
defaultPath: `yaak.${workspaceSlug}.json`,
dialog.show({
id: 'export-data',
title: 'Export App Data',
size: 'md',
noPadding: true,
render: ({ hide }) => (
<ExportDataDialog
onHide={hide}
workspaces={workspaces}
activeWorkspace={activeWorkspace}
/>
),
});
if (exportPath == null) {
return;
}
await invoke('cmd_export_data', { workspaceId: workspace.id, exportPath });
},
});
}

View File

@@ -0,0 +1,13 @@
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useKeyValue } from './useKeyValue';
export function useFloatingSidebarHidden() {
const activeWorkspaceId = useActiveWorkspaceId();
const { set, value } = useKeyValue<boolean>({
namespace: 'no_sync',
key: ['floating_sidebar_hidden', activeWorkspaceId ?? 'n/a'],
fallback: false,
});
return [value, set] as const;
}

View File

@@ -1,14 +1,18 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Workspace } from '../lib/models';
import { useAppRoutes } from './useAppRoutes';
import { useRegisterCommand } from './useCommands';
import { usePrompt } from './usePrompt';
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
const routes = useAppRoutes();
export function useGlobalCommands() {
const prompt = usePrompt();
return useMutation<Workspace, unknown, Partial<Pick<Workspace, 'name'>>>({
const routes = useAppRoutes();
useRegisterCommand('workspace.create', {
name: 'New Workspace',
track: ['workspace', 'create'],
onSuccess: async (workspace) => {
routes.navigate('workspace', { workspaceId: workspace.id });
},
mutationFn: async ({ name: patchName }) => {
const name =
patchName ??
@@ -23,11 +27,5 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }
}));
return invoke('cmd_create_workspace', { name });
},
onSettled: () => trackEvent('workspace', 'create'),
onSuccess: async (workspace) => {
if (navigateAfter) {
routes.navigate('workspace', { workspaceId: workspace.id });
}
},
});
}

View File

@@ -13,12 +13,14 @@ export type HotkeyAction =
| 'http_request.create'
| 'http_request.duplicate'
| 'http_request.send'
| 'requestSwitcher.next'
| 'requestSwitcher.prev'
| 'request_switcher.next'
| 'request_switcher.prev'
| 'request_switcher.toggle'
| 'settings.show'
| 'sidebar.focus'
| 'sidebar.toggle'
| 'urlBar.focus';
| 'urlBar.focus'
| 'command_palette.toggle';
const hotkeys: Record<HotkeyAction, string[]> = {
'environmentEditor.toggle': ['CmdCtrl+Shift+e'],
@@ -27,12 +29,14 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'http_request.create': ['CmdCtrl+n'],
'http_request.duplicate': ['CmdCtrl+d'],
'http_request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
'requestSwitcher.next': ['Control+Shift+Tab'],
'requestSwitcher.prev': ['Control+Tab'],
'request_switcher.next': ['Control+Shift+Tab'],
'request_switcher.prev': ['Control+Tab'],
'request_switcher.toggle': ['CmdCtrl+p'],
'settings.show': ['CmdCtrl+,'],
'sidebar.focus': ['CmdCtrl+1'],
'sidebar.toggle': ['CmdCtrl+b'],
'urlBar.focus': ['CmdCtrl+l'],
'command_palette.toggle': ['CmdCtrl+k'],
};
const hotkeyLabels: Record<HotkeyAction, string> = {
@@ -42,12 +46,14 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
'http_request.create': 'New Request',
'http_request.duplicate': 'Duplicate Request',
'http_request.send': 'Send Request',
'requestSwitcher.next': 'Go To Previous Request',
'requestSwitcher.prev': 'Go To Next Request',
'request_switcher.next': 'Go To Previous Request',
'request_switcher.prev': 'Go To Next Request',
'request_switcher.toggle': 'Toggle Request Switcher',
'settings.show': 'Open Settings',
'sidebar.focus': 'Focus Sidebar',
'sidebar.toggle': 'Toggle Sidebar',
'urlBar.focus': 'Focus URL',
'command_palette.toggle': 'Toggle Command Palette',
};
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
@@ -135,7 +141,7 @@ export function useHotKey(
document.removeEventListener('keydown', down, { capture: true });
document.removeEventListener('keyup', up, { capture: true });
};
}, [options.enable, os]);
}, [action, options.enable, os]);
}
export function useHotKeyLabel(action: HotkeyAction): string {

View File

@@ -7,6 +7,7 @@ import { VStack } from '../components/core/Stacks';
import { useDialog } from '../components/DialogContext';
import type { Environment, Folder, GrpcRequest, HttpRequest, Workspace } from '../lib/models';
import { count } from '../lib/pluralize';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAlert } from './useAlert';
import { useAppRoutes } from './useAppRoutes';
@@ -19,6 +20,7 @@ export function useImportData() {
const routes = useAppRoutes();
const dialog = useDialog();
const alert = useAlert();
const activeWorkspaceId = useActiveWorkspaceId();
const importData = async () => {
const selected = await open(openArgs);
@@ -33,7 +35,8 @@ export function useImportData() {
httpRequests: HttpRequest[];
grpcRequests: GrpcRequest[];
} = await invoke('cmd_import_data', {
filePaths: Array.isArray(selected) ? selected : [selected],
filePath: Array.isArray(selected) ? selected[0] : selected,
workspaceId: activeWorkspaceId,
});
const importedWorkspace = imported.workspaces[0];
@@ -76,27 +79,40 @@ export function useImportData() {
alert({ id: 'import-failed', title: 'Import Failed', body: err });
},
mutationFn: async () => {
dialog.show({
id: 'import',
title: 'Import Data',
size: 'sm',
render: ({ hide }) => {
return (
<VStack space={3} className="pb-4">
<p>Insomnia or Postman Collection v2/v2.1 formats are supported</p>
<Button
size="sm"
color="primary"
onClick={async () => {
await importData();
hide();
}}
>
Select File
</Button>
</VStack>
);
},
return new Promise<void>((resolve, reject) => {
dialog.show({
id: 'import',
title: 'Import Data',
size: 'sm',
render: ({ hide }) => {
return (
<VStack space={5} className="pb-4">
<VStack space={1}>
<p>Supported Formats:</p>
<ul className="list-disc pl-5">
<li>Postman Collection v2/v2.1</li>
<li>Insomnia</li>
</ul>
</VStack>
<Button
size="sm"
color="primary"
onClick={async () => {
try {
await importData();
resolve();
} catch (err) {
reject(err);
}
hide();
}}
>
Select File
</Button>
</VStack>
);
},
});
});
},
});

View File

@@ -14,6 +14,7 @@ export function usePrompt() {
defaultValue,
placeholder,
confirmLabel,
require,
}: Pick<DialogProps, 'title' | 'description'> &
Omit<PromptProps, 'onResult' | 'onHide'> & { id: string }) =>
new Promise((onResult: PromptProps['onResult']) => {
@@ -24,7 +25,16 @@ export function usePrompt() {
hideX: true,
size: 'sm',
render: ({ hide }) =>
Prompt({ onHide: hide, onResult, name, label, defaultValue, placeholder, confirmLabel }),
Prompt({
onHide: hide,
onResult,
name,
label,
defaultValue,
placeholder,
confirmLabel,
require,
}),
});
});
}

View File

@@ -2,18 +2,15 @@ import { useEffect, useMemo } from 'react';
import { getKeyValue } from '../lib/keyValueStore';
import { useActiveRequestId } from './useActiveRequestId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useGrpcRequests } from './useGrpcRequests';
import { useHttpRequests } from './useHttpRequests';
import { useKeyValue } from './useKeyValue';
import { useRequests } from './useRequests';
const kvKey = (workspaceId: string) => 'recent_requests::' + workspaceId;
const namespace = 'global';
const fallback: string[] = [];
export function useRecentRequests() {
const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests();
const requests = useMemo(() => [...httpRequests, ...grpcRequests], [httpRequests, grpcRequests]);
const requests = useRequests();
const activeWorkspaceId = useActiveWorkspaceId();
const activeRequestId = useActiveRequestId();

View File

@@ -0,0 +1,9 @@
import { useMemo } from 'react';
import { useGrpcRequests } from './useGrpcRequests';
import { useHttpRequests } from './useHttpRequests';
export function useRequests() {
const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests();
return useMemo(() => [...httpRequests, ...grpcRequests], [httpRequests, grpcRequests]);
}

View File

@@ -11,8 +11,9 @@ export function useSettings() {
useQuery({
queryKey: settingsQueryKey(),
queryFn: async () => {
return (await invoke('cmd_get_settings')) as Settings;
const settings = (await invoke('cmd_get_settings')) as Settings;
return [settings];
},
}).data ?? undefined
}).data?.[0] ?? undefined
);
}

View File

@@ -0,0 +1,8 @@
import { useWindowSize } from 'react-use';
const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
export function useShouldFloatSidebar() {
const windowSize = useWindowSize();
return windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
}

View File

@@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useKeyValue } from './useKeyValue';
@@ -10,12 +9,5 @@ export function useSidebarHidden() {
fallback: false,
});
return useMemo(() => {
return {
show: () => set(false),
hide: () => set(true),
toggle: () => set((h) => !h),
hidden: value,
};
}, [set, value]);
return [value, set] as const;
}

View File

@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';
/**
* Like useState, except it will update the value when the default value changes
*/
export function useStateSyncDefault<T>(defaultValue: T) {
const [value, setValue] = useState(defaultValue);
useEffect(() => {
setValue(defaultValue);
}, [defaultValue]);
return [value, setValue] as const;
}

View File

@@ -11,7 +11,7 @@ export function useUpdateSettings() {
await invoke('cmd_update_settings', { settings });
},
onMutate: async (settings) => {
queryClient.setQueryData<Settings>(settingsQueryKey(), settings);
queryClient.setQueryData<Settings[]>(settingsQueryKey(), [settings]);
},
});
}

View File

@@ -1,35 +1,38 @@
import { invoke } from '@tauri-apps/api';
export function trackEvent(
resource:
| 'app'
| 'cookie_jar'
| 'dialog'
| 'environment'
| 'folder'
| 'grpc_connection'
| 'grpc_event'
| 'grpc_request'
| 'http_request'
| 'http_response'
| 'key_value'
| 'setting'
| 'sidebar'
| 'workspace',
action:
| 'cancel'
| 'commit'
| 'create'
| 'delete'
| 'delete_many'
| 'duplicate'
| 'hide'
| 'launch'
| 'send'
| 'show'
| 'toggle'
| 'update',
export type TrackResource =
| 'app'
| 'cookie_jar'
| 'dialog'
| 'environment'
| 'folder'
| 'grpc_connection'
| 'grpc_event'
| 'grpc_request'
| 'http_request'
| 'http_response'
| 'key_value'
| 'setting'
| 'sidebar'
| 'workspace';
export type TrackAction =
| 'cancel'
| 'commit'
| 'create'
| 'delete'
| 'delete_many'
| 'duplicate'
| 'hide'
| 'launch'
| 'send'
| 'show'
| 'toggle'
| 'update';
export function trackEvent(
resource: TrackResource,
action: TrackAction,
attributes: Record<string, string | number> = {},
) {
invoke('cmd_track_event', {

View File

@@ -5,6 +5,16 @@ export function pluralize(word: string, count: number): string {
return `${word}s`;
}
export function count(word: string, count: number): string {
export function count(
word: string,
count: number,
opt: { omitSingle?: boolean; noneWord?: string } = {},
): string {
if (opt.omitSingle && count === 1) {
return word;
}
if (opt.noneWord && count === 0) {
return opt.noneWord;
}
return `${count} ${pluralize(word, count)}`;
}

View File

@@ -9,15 +9,13 @@
@apply w-full h-full overflow-hidden text-gray-900 bg-gray-50;
}
/* Setup default transitions for elements */
* {
transition: background-color var(--transition-duration), border-color var(--transition-duration),
box-shadow var(--transition-duration);
font-variant-ligatures: none;
}
::selection {
@apply bg-selection;
::selection,
.cm-selectionBackground {
@apply bg-selection !important;
}
/* Disable user selection to make it more "app-like" */

View File

@@ -61,10 +61,10 @@ module.exports = {
},
colors: {
selection: 'hsl(var(--color-violet-500) / 0.3)',
focus: 'hsl(var(--color-blue-500) / 0.6)',
focus: 'hsl(var(--color-blue-500) / 0.7)',
invalid: 'hsl(var(--color-red-500))',
highlight: 'hsl(var(--color-gray-300) / 0.35)',
highlightSecondary: 'hsl(var(--color-gray-300) / 0.2)',
highlight: 'hsl(var(--color-gray-500) / 0.3)',
highlightSecondary: 'hsl(var(--color-gray-500) / 0.15)',
transparent: 'transparent',
white: 'hsl(0 100% 100% / <alpha-value>)',
black: 'hsl(0 100% 0% / <alpha-value>)',