Compare commits

...

51 Commits

Author SHA1 Message Date
Gregory Schier
012a984456 Fix bulk edit label 2024-06-11 09:02:11 -07:00
Gregory Schier
25800202f2 Bump version 2024-06-11 08:35:29 -07:00
Gregory Schier
a058064f1f Adjust fuzzy threshold 2024-06-10 23:25:57 -07:00
Gregory Schier
9f40804532 Fix window drag above cmd+k 2024-06-10 23:24:03 -07:00
Gregory Schier
26cc467858 Fix delete request in sidebar 2024-06-10 23:17:12 -07:00
Gregory Schier
be1cf7bf65 Don't arrow-nav to hidden dropdown items 2024-06-10 23:16:37 -07:00
Gregory Schier
ea4f104ca7 Bulk edit environments 2024-06-10 23:16:21 -07:00
Gregory Schier
32a28a3170 Fix hotkey react keys 2024-06-10 22:29:06 -07:00
Gregory Schier
6215914212 Cmd Palette Improvements (#50)
- Fuzzy matching
- Show hotkeys
- Add actions
2024-06-10 21:37:41 -07:00
Gregory Schier
a2dbd7f849 Download Active Response (#49)
This PR prompts you to save un-previewable file types and adds an option
to save to the response history.
2024-06-10 16:36:09 -07:00
Gregory Schier
5bb9815f4b Remove jump to request hotkey 2024-06-10 09:00:26 -07:00
Gregory Schier
7cd8ac3b21 Response viewer for PDF (#48)
This PR adds a response viewer for PDF files using `react-pdf`
2024-06-10 08:57:08 -07:00
Gregory Schier
456d3aaf52 Don't focus sidebar on cmd+0 2024-06-09 08:36:12 -07:00
Gregory Schier
113743f7cf Try fix CI 2024-06-09 08:09:47 -07:00
Gregory Schier
01a4d6f4ac Bump version 2024-06-09 07:56:44 -07:00
Gregory Schier
ff5cfe744e Remove CI again 2024-06-09 07:56:09 -07:00
Gregory Schier
29c4b51f54 Add it back 2024-06-09 07:23:28 -07:00
Gregory Schier
2f74bf8db8 Remove CI in release stage 2024-06-09 07:22:49 -07:00
Gregory Schier
b30d784d06 Remove GH Action step names 2024-06-09 07:16:53 -07:00
Gregory Schier
ac0adaf3d8 Back to other 2024-06-09 07:14:07 -07:00
Gregory Schier
c246d3a748 Fix action step 2024-06-09 07:10:44 -07:00
Gregory Schier
ae40728c1e Rust cache release 2024-06-09 07:09:46 -07:00
Gregory Schier
0430ec883b Cmd jump to request 2024-06-09 07:03:16 -07:00
Gregory Schier
eba6f33536 Only CI on PR 2024-06-09 06:47:47 -07:00
Gregory Schier
dae2873376 Fix Rust lint 2024-06-09 06:43:53 -07:00
Gregory Schier
cb2f56d9a1 Remove slow udeps from CI 2024-06-08 21:03:17 -07:00
Gregory Schier
7d82aa70a4 Rust CI to release 2024-06-08 20:51:03 -07:00
Gregory Schier
adea234987 Try again 2024-06-08 20:47:48 -07:00
Gregory Schier
37d0b487b8 Remove cache 2024-06-08 20:47:18 -07:00
Gregory Schier
d507f8c99f Try removing git db 2024-06-08 20:44:58 -07:00
Gregory Schier
60406ac83f Cargo check 2024-06-08 20:36:56 -07:00
Gregory Schier
8fe6f3a335 Remove Cargo fmt 2024-06-08 20:34:24 -07:00
Gregory Schier
69e027c302 Fix tests 2024-06-08 20:30:52 -07:00
Gregory Schier
4232bdd298 Cargo format 2024-06-08 20:28:45 -07:00
Gregory Schier
ef1c5da027 Set max dropdown width 2024-06-08 19:55:25 -07:00
Gregory Schier
e250326868 Bump version 2024-06-08 19:46:32 -07:00
Gregory Schier
125f503cfa Upgrade reqwest and add ALPN for http/2 2024-06-08 19:40:35 -07:00
Gregory Schier
8f086425fe Fix curl exporter 2024-06-08 19:40:11 -07:00
Gregory Schier
ae2da73873 Fix cmd+k filtering 2024-06-07 22:52:23 -07:00
Gregory Schier
3cd6688ffb Remove delete hotkey for request 2024-06-07 22:52:10 -07:00
Gregory Schier
8538da8879 Short method tags and hide active in cmd+k 2024-06-07 22:39:11 -07:00
Gregory Schier
b0e4ece278 Add command palette (#46)
This PR finished the initial PoC command palette. It currently only
supports switching between requests and workspaces, but can easily be
extended for more.
2024-06-07 21:59:57 -07:00
Gregory Schier
5e058af03e Bulk editor (#45)
Bulk editor for all pair editors except multipart/form-data
2024-06-07 13:42:08 -07:00
Gregory Schier
5108bc92f3 Fix rose pine and method tags 2024-06-07 12:01:31 -07:00
Gregory Schier
3c5fdcb18d Generate things 2024-06-07 11:58:08 -07:00
Gregory Schier
4672de4a47 Remove tauri-plugin-deep-link 2024-06-07 11:57:44 -07:00
Gregory Schier
239f6da141 Update CI script 2024-06-07 11:01:21 -07:00
Gregory Schier
ec148d1736 Fix workflow 2024-06-07 10:54:50 -07:00
Gregory Schier
392b549646 Deno plugins (#42)
Switch from BoaJS to Deno core
2024-06-07 10:47:41 -07:00
Gregory Schier
993d4dc65d Open workspace pref (#44)
Ability to remember workspace window opening selection.
2024-06-07 09:04:53 -07:00
Gregory Schier
e326405f4f Templating (#43)
Add new `templating` crate with custom parser/renderer for dealing with
variables
2024-06-07 08:39:12 -07:00
114 changed files with 3937 additions and 6463 deletions

18
.github/workflows/ci-js.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
on:
pull_request:
branches: [develop]
name: CI (JS)
jobs:
test:
name: Lint/Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- run: npm ci
- run: npm run lint
- run: npm test

36
.github/workflows/ci-rust.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
on:
pull_request:
branches: [develop]
paths:
- src-tauri/**
- .github/workflows/**
name: CI (Rust)
defaults:
run:
working-directory: src-tauri
jobs:
test:
name: Check/Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: actions/cache@v3
continue-on-error: false
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- run: cargo check --all
- run: cargo test --all

View File

@@ -32,6 +32,17 @@ jobs:
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- uses: actions/cache@v3
continue-on-error: false
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
run: |

3
.gitignore vendored
View File

@@ -27,4 +27,5 @@ dist-ssr
*.sqlite
*.sqlite-*
.cargo
.cargo
plugins/**/build

696
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,7 @@
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.6.1",
"date-fns": "^3.3.1",
"fast-fuzzy": "^1.12.0",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
@@ -64,6 +65,7 @@
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-pdf": "^9.0.0",
"react-router-dom": "^6.8.1",
"react-use": "^17.4.0",
"slugify": "^1.6.6",
@@ -103,6 +105,7 @@
"tailwindcss": "^3.2.7",
"typescript": "^5.4.5",
"vite": "^5.0.0",
"vite-plugin-static-copy": "^1.0.5",
"vite-plugin-svgr": "^4.2.0",
"vite-plugin-top-level-await": "^1.4.1",
"vitest": "^1.3.0"

View File

@@ -2,7 +2,7 @@ import { HttpRequest } from '../../../src-web/lib/models';
const NEWLINE = '\\\n ';
export function pluginHookExport(request: Partial<HttpRequest>) {
export function pluginHookExport(_: any, request: Partial<HttpRequest>) {
const xs = ['curl'];
// Add method and URL all on first line

View File

@@ -1,10 +1,12 @@
import { describe, expect, test } from 'vitest';
import { pluginHookExport } from '../src';
const ctx = {};
describe('exporter-curl', () => {
test('Exports GET with params', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
urlParameters: [
{ name: 'a', value: 'aaa' },
@@ -18,7 +20,7 @@ describe('exporter-curl', () => {
});
test('Exports POST with url form data', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
@@ -37,7 +39,7 @@ describe('exporter-curl', () => {
test('Exports PUT with multipart form', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
method: 'PUT',
bodyType: 'multipart/form-data',
@@ -62,7 +64,7 @@ describe('exporter-curl', () => {
test('Exports JSON body', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/json',
@@ -82,7 +84,7 @@ describe('exporter-curl', () => {
test('Exports multi-line JSON body', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/json',
@@ -102,7 +104,7 @@ describe('exporter-curl', () => {
test('Exports headers', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
headers: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
@@ -114,7 +116,7 @@ describe('exporter-curl', () => {
test('Basic auth', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {
@@ -127,7 +129,7 @@ describe('exporter-curl', () => {
test('Broken basic auth', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {},
@@ -137,7 +139,7 @@ describe('exporter-curl', () => {
test('Digest auth', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
authenticationType: 'digest',
authentication: {
@@ -150,7 +152,7 @@ describe('exporter-curl', () => {
test('Bearer auth', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
authenticationType: 'bearer',
authentication: {
@@ -162,7 +164,7 @@ describe('exporter-curl', () => {
test('Broken bearer auth', () => {
expect(
pluginHookExport({
pluginHookExport(ctx, {
url: 'https://yaak.app',
authenticationType: 'bearer',
authentication: {

View File

@@ -8,6 +8,8 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, '../../src-tauri/plugins/exporter-curl'),
emptyOutDir: true,
sourcemap: true,
outDir: resolve(__dirname, 'build'),
},
});

View File

@@ -1,6 +1,6 @@
import jp from 'jsonpath';
export function pluginHookResponseFilter(filter, text) {
export function pluginHookResponseFilter(ctx, filter, text) {
let parsed;
try {
parsed = JSON.parse(text);

View File

@@ -8,6 +8,8 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, '../../src-tauri/plugins/filter-jsonpath'),
emptyOutDir: true,
sourcemap: true,
outDir: resolve(__dirname, 'build'),
},
});

View File

@@ -1,7 +1,7 @@
import xpath from 'xpath';
import { DOMParser } from '@xmldom/xmldom';
export function pluginHookResponseFilter(filter, text) {
export function pluginHookResponseFilter(ctx, filter, text) {
const doc = new DOMParser().parseFromString(text, 'text/xml');
const filtered = `${xpath.select(filter, doc)}`;
return { filtered };

View File

@@ -8,6 +8,8 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, '../../src-tauri/plugins/filter-xpath'),
emptyOutDir: true,
sourcemap: true,
outDir: resolve(__dirname, 'build'),
},
});

View File

@@ -43,7 +43,7 @@ type Pair = string | boolean;
type PairsByName = Record<string, Pair[]>;
export function pluginHookImport(rawData: string) {
export function pluginHookImport(_: any, rawData: string) {
if (!rawData.match(/^\s*curl /)) {
return null;
}

View File

@@ -2,9 +2,11 @@ import { describe, expect, test } from 'vitest';
import { HttpRequest, Model, Workspace } from '../../../src-web/lib/models';
import { pluginHookImport } from '../src';
const ctx = {};
describe('importer-curl', () => {
test('Imports basic GET', () => {
expect(pluginHookImport('curl https://yaak.app')).toEqual({
expect(pluginHookImport(ctx, 'curl https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -17,7 +19,7 @@ describe('importer-curl', () => {
});
test('Explicit URL', () => {
expect(pluginHookImport('curl --url https://yaak.app')).toEqual({
expect(pluginHookImport(ctx, 'curl --url https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -30,7 +32,7 @@ describe('importer-curl', () => {
});
test('Missing URL', () => {
expect(pluginHookImport('curl -X POST')).toEqual({
expect(pluginHookImport(ctx, 'curl -X POST')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -43,7 +45,7 @@ describe('importer-curl', () => {
});
test('URL between', () => {
expect(pluginHookImport('curl -v https://yaak.app -X POST')).toEqual({
expect(pluginHookImport(ctx, 'curl -v https://yaak.app -X POST')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -57,7 +59,7 @@ describe('importer-curl', () => {
});
test('Random flags', () => {
expect(pluginHookImport('curl --random -Z -Y -S --foo https://yaak.app')).toEqual({
expect(pluginHookImport(ctx, 'curl --random -Z -Y -S --foo https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -70,7 +72,7 @@ describe('importer-curl', () => {
});
test('Imports --request method', () => {
expect(pluginHookImport('curl --request POST https://yaak.app')).toEqual({
expect(pluginHookImport(ctx, 'curl --request POST https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -84,7 +86,7 @@ describe('importer-curl', () => {
});
test('Imports -XPOST method', () => {
expect(pluginHookImport('curl -XPOST --request POST https://yaak.app')).toEqual({
expect(pluginHookImport(ctx, 'curl -XPOST --request POST https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -99,7 +101,10 @@ describe('importer-curl', () => {
test('Imports multiple requests', () => {
expect(
pluginHookImport('curl \\\n https://yaak.app\necho "foo"\ncurl example.com;curl foo.com'),
pluginHookImport(
ctx,
'curl \\\n https://yaak.app\necho "foo"\ncurl example.com;curl foo.com',
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
@@ -114,7 +119,7 @@ describe('importer-curl', () => {
test('Imports form data', () => {
expect(
pluginHookImport('curl -X POST -F "a=aaa" -F b=bbb" -F f=@filepath https://yaak.app'),
pluginHookImport(ctx, 'curl -X POST -F "a=aaa" -F b=bbb" -F f=@filepath https://yaak.app'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
@@ -144,7 +149,7 @@ describe('importer-curl', () => {
});
test('Imports data params as form url-encoded', () => {
expect(pluginHookImport('curl -d a -d b -d c=ccc https://yaak.app')).toEqual({
expect(pluginHookImport(ctx, 'curl -d a -d b -d c=ccc https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -174,7 +179,7 @@ describe('importer-curl', () => {
test('Imports data params as text', () => {
expect(
pluginHookImport('curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app'),
pluginHookImport(ctx, 'curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
@@ -194,6 +199,7 @@ describe('importer-curl', () => {
test('Imports multi-line JSON', () => {
expect(
pluginHookImport(
ctx,
`curl -H Content-Type:application/json -d $'{\n "foo":"bar"\n}' https://yaak.app`,
),
).toEqual({
@@ -214,7 +220,7 @@ describe('importer-curl', () => {
test('Imports multiple headers', () => {
expect(
pluginHookImport('curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app'),
pluginHookImport(ctx, 'curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
@@ -234,7 +240,7 @@ describe('importer-curl', () => {
});
test('Imports basic auth', () => {
expect(pluginHookImport('curl --user user:pass https://yaak.app')).toEqual({
expect(pluginHookImport(ctx, 'curl --user user:pass https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -252,7 +258,7 @@ describe('importer-curl', () => {
});
test('Imports digest auth', () => {
expect(pluginHookImport('curl --digest --user user:pass https://yaak.app')).toEqual({
expect(pluginHookImport(ctx, 'curl --digest --user user:pass https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -270,7 +276,7 @@ describe('importer-curl', () => {
});
test('Imports cookie as header', () => {
expect(pluginHookImport('curl --cookie "foo=bar" https://yaak.app')).toEqual({
expect(pluginHookImport(ctx, 'curl --cookie "foo=bar" https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
@@ -284,7 +290,7 @@ describe('importer-curl', () => {
});
test('Imports query params from the URL', () => {
expect(pluginHookImport('curl "https://yaak.app?foo=bar&baz=a%20a"')).toEqual({
expect(pluginHookImport(ctx, 'curl "https://yaak.app?foo=bar&baz=a%20a"')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [

View File

@@ -8,6 +8,8 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, '../../src-tauri/plugins/importer-curl'),
emptyOutDir: true,
sourcemap: true,
outDir: resolve(__dirname, 'build'),
},
});

View File

@@ -5,7 +5,7 @@ import {
HttpRequest,
Workspace,
} from '../../../src-web/lib/models';
import { parse as parseYaml } from 'yaml';
import '../../../src-web/plugin/runtime.d.ts';
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
@@ -17,7 +17,7 @@ export interface ExportResources {
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
}
export function pluginHookImport(contents: string) {
export function pluginHookImport(ctx: YaakContext, contents: string) {
let parsed: any;
try {
@@ -25,8 +25,10 @@ export function pluginHookImport(contents: string) {
} catch (e) {}
try {
parsed = parseYaml(contents);
} catch (e) {}
parsed = parsed ?? YAML.parse(contents);
} catch (e) {
console.log('FAILED', e);
}
if (!isJSObject(parsed)) return;
if (!Array.isArray(parsed.resources)) return;

View File

@@ -8,6 +8,8 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, '../../src-tauri/plugins/importer-insomnia'),
emptyOutDir: true,
sourcemap: true,
outDir: resolve(__dirname, 'build'),
},
});

View File

@@ -13,7 +13,11 @@ interface ExportResources {
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
}
export function pluginHookImport(contents: string): { resources: ExportResources } | undefined {
export function pluginHookImport(
ctx: any,
contents: string,
): { resources: ExportResources } | undefined {
console.log('CTX', ctx);
const root = parseJSONToRecord(contents);
if (root == null) return;

View File

@@ -23,7 +23,7 @@ describe('importer-postman', () => {
for (const fixture of fixtures) {
test('Imports ' + fixture, () => {
const contents = fs.readFileSync(path.join(p, fixture), 'utf-8');
const imported = pluginHookImport(contents);
const imported = pluginHookImport({}, contents);
const folder0 = newId('folder');
const folder1 = newId('folder');
expect(imported).toEqual({

View File

@@ -8,6 +8,8 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, '../../src-tauri/plugins/importer-postman'),
emptyOutDir: true,
sourcemap: true,
outDir: resolve(__dirname, 'build'),
},
});

View File

@@ -1,4 +1,4 @@
export function pluginHookImport(contents: string) {
export function pluginHookImport(ctx: any, contents: string) {
let parsed;
try {
parsed = JSON.parse(contents);
@@ -18,7 +18,7 @@ export function pluginHookImport(contents: string) {
// Migrate v1 to v2 -- changes requests to httpRequests
if ('requests' in parsed.resources) {
parsed.resources.httpRequests = parsed.resources.requests;
delete parsed.resources.requests;
delete parsed.resources['requests'];
}
return { resources: parsed.resources }; // Should already be in the correct format

View File

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

View File

@@ -8,6 +8,8 @@ export default defineConfig({
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, '../../src-tauri/plugins/importer-yaak'),
emptyOutDir: true,
sourcemap: true,
outDir: resolve(__dirname, 'build'),
},
});

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id, model, created_at, updated_at, theme, appearance,\n theme_dark, theme_light, update_channel,\n interface_font_size, interface_scale, editor_font_size, editor_soft_wrap\n FROM settings\n WHERE id = 'default'\n ",
"query": "\n SELECT\n id, model, created_at, updated_at, theme, appearance,\n theme_dark, theme_light, update_channel,\n interface_font_size, interface_scale, editor_font_size, editor_soft_wrap, \n open_workspace_new_window\n FROM settings\n WHERE id = 'default'\n ",
"describe": {
"columns": [
{
@@ -67,6 +67,11 @@
"name": "editor_soft_wrap",
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "open_workspace_new_window",
"ordinal": 13,
"type_info": "Bool"
}
],
"parameters": {
@@ -85,8 +90,9 @@
false,
false,
false,
false
false,
true
]
},
"hash": "ca3485d87b060cd77c4114d2af544adf18f6f15341d9d5db40865e92a80da4e2"
"hash": "05dca7fe15ab1bf03952e94498ef3130e16f752da72782783696eb2cca4736d5"
}

View File

@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings SET (\n theme, appearance, theme_dark, theme_light, update_channel,\n interface_font_size, interface_scale, editor_font_size, editor_soft_wrap\n ) = (?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE id = 'default';\n ",
"query": "\n UPDATE settings SET (\n theme, appearance, theme_dark, theme_light, update_channel,\n interface_font_size, interface_scale, editor_font_size, editor_soft_wrap,\n open_workspace_new_window\n ) = (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE id = 'default';\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 9
"Right": 10
},
"nullable": []
},
"hash": "efd8ba41ea909b18dd520c57c1d464c5ae057b720cbbedcaec1513d43535632c"
"hash": "6b5edf45a6799cd7f87c23a3c7f818ad110d58c601f694a619d9345ae9e8e11d"
}

1694
src-tauri/Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
workspace = { members = ["grpc"] }
workspace = { members = ["grpc", "templates"] }
[package]
name = "yaak-app"
@@ -24,32 +24,35 @@ cocoa = "0.25.0"
openssl-sys = { version = "0.9", features = ["vendored"] } # For Ubuntu installation to work
[dependencies]
grpc = { path = "./grpc" }
templates = { path = "./templates" }
anyhow = "1.0.86"
base64 = "0.22.0"
boa_engine = { version = "0.18.0", features = ["annex-b"] }
boa_runtime = { version = "0.18.0" }
chrono = { version = "0.4.31", features = ["serde"] }
http = "0.2.10"
datetime = "0.5.2"
deno_ast = { version = "0.39.0", features = ["transpiling"] }
deno_console = "0.155.0"
deno_core = { version = "0.284.0" }
hex_color = "3.0.0"
http = "1"
log = "0.4.21"
rand = "0.8.5"
reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "brotli", "deflate", "json"] }
regex = "1.10.2"
reqwest = { version = "0.12.4", features = ["multipart", "cookies", "gzip", "brotli", "deflate", "json", "native-tls-alpn"] }
reqwest_cookie_store = "0.8.0"
serde = { version = "1.0.198", features = ["derive"] }
serde_json = { version = "1.0.116", features = ["raw_value"] }
serde_yaml = "0.9.34"
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }
tauri = { version = "2.0.0-beta", features = ["config-toml", "devtools", "protocol-asset"] }
tauri = { version = "2.0.0-beta", features = ["devtools", "protocol-asset"] }
tauri-plugin-clipboard-manager = "2.1.0-beta"
tauri-plugin-dialog = "2.0.0-beta"
tauri-plugin-fs = "2.0.0-beta"
tauri-plugin-log = { version = "2.0.0-beta", features = ["colored"] }
tauri-plugin-shell = "2.0.0-beta"
tauri-plugin-os = "2.0.0-beta"
tauri-plugin-shell = "2.0.0-beta"
tauri-plugin-updater = "2.0.0-beta"
tauri-plugin-window-state = "2.0.0-beta"
tauri-plugin-fs = "2.0.0-beta"
tauri-plugin-deep-link = "2.0.0-beta"
tokio = { version = "1.36.0", features = ["sync"] }
uuid = "1.7.0"
log = "0.4.21"
datetime = "0.5.2"
reqwest_cookie_store = "0.6.0"
grpc = { path = "./grpc" }
tokio-stream = "0.1.15"
regex = "1.10.2"
hex_color = "3.0.0"
uuid = "1.7.0"

View File

File diff suppressed because one or more lines are too long

View File

@@ -2512,69 +2512,6 @@
"clipboard-manager:deny-write-text"
]
},
{
"description": "deep-link:default -> Allows reading the opened deep link via the get_current command",
"type": "string",
"enum": [
"deep-link:default"
]
},
{
"description": "deep-link:allow-get-current -> Enables the get_current command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:allow-get-current"
]
},
{
"description": "deep-link:allow-is-registered -> Enables the is_registered command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:allow-is-registered"
]
},
{
"description": "deep-link:allow-register -> Enables the register command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:allow-register"
]
},
{
"description": "deep-link:allow-unregister -> Enables the unregister command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:allow-unregister"
]
},
{
"description": "deep-link:deny-get-current -> Denies the get_current command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:deny-get-current"
]
},
{
"description": "deep-link:deny-is-registered -> Denies the is_registered command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:deny-is-registered"
]
},
{
"description": "deep-link:deny-register -> Denies the register command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:deny-register"
]
},
{
"description": "deep-link:deny-unregister -> Denies the unregister command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:deny-unregister"
]
},
{
"type": "string",
"enum": [

View File

@@ -2512,69 +2512,6 @@
"clipboard-manager:deny-write-text"
]
},
{
"description": "deep-link:default -> Allows reading the opened deep link via the get_current command",
"type": "string",
"enum": [
"deep-link:default"
]
},
{
"description": "deep-link:allow-get-current -> Enables the get_current command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:allow-get-current"
]
},
{
"description": "deep-link:allow-is-registered -> Enables the is_registered command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:allow-is-registered"
]
},
{
"description": "deep-link:allow-register -> Enables the register command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:allow-register"
]
},
{
"description": "deep-link:allow-unregister -> Enables the unregister command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:allow-unregister"
]
},
{
"description": "deep-link:deny-get-current -> Denies the get_current command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:deny-get-current"
]
},
{
"description": "deep-link:deny-is-registered -> Denies the is_registered command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:deny-is-registered"
]
},
{
"description": "deep-link:deny-register -> Denies the register command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:deny-register"
]
},
{
"description": "deep-link:deny-unregister -> Denies the unregister command without any pre-configured scope.",
"type": "string",
"enum": [
"deep-link:deny-unregister"
]
},
{
"type": "string",
"enum": [

View File

@@ -185,20 +185,22 @@ impl GrpcHandle {
pub async fn services_from_files(
&mut self,
id: &str,
uri: &Uri,
uri: &str,
paths: Vec<PathBuf>,
) -> Result<Vec<ServiceDefinition>, String> {
let pool = fill_pool_from_files(&self.app_handle, paths).await?;
self.pools.insert(self.get_pool_key(id, uri), pool.clone());
let uri = Uri::from_str(uri).map_err(|e| e.to_string())?;
self.pools.insert(self.get_pool_key(id, &uri), pool.clone());
Ok(self.services_from_pool(&pool))
}
pub async fn services_from_reflection(
&mut self,
id: &str,
uri: &Uri,
uri: &str,
) -> Result<Vec<ServiceDefinition>, String> {
let pool = fill_pool(uri).await?;
self.pools.insert(self.get_pool_key(id, uri), pool.clone());
let uri = Uri::from_str(uri).map_err(|e| e.to_string())?;
let pool = fill_pool(&uri).await?;
self.pools.insert(self.get_pool_key(id, &uri), pool.clone());
Ok(self.services_from_pool(&pool))
}
@@ -234,9 +236,10 @@ impl GrpcHandle {
pub async fn connect(
&mut self,
id: &str,
uri: Uri,
uri: &str,
proto_files: Vec<PathBuf>,
) -> Result<GrpcConnection, String> {
let uri = Uri::from_str(uri).map_err(|e| e.to_string())?;
let pool = match self.pools.get(id) {
Some(p) => p.clone(),
None => match proto_files.len() {

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN open_workspace_new_window BOOLEAN NULL DEFAULT NULL;

View File

@@ -1,297 +0,0 @@
var j = "(?:" + [
"\\|\\|",
"\\&\\&",
";;",
"\\|\\&",
"\\<\\(",
"\\<\\<\\<",
">>",
">\\&",
"<\\&",
"[&;()|<>]"
].join("|") + ")", D = new RegExp("^" + j + "$"), q = "|&;()<> \\t", M = '"((\\\\"|[^"])*?)"', Q = "'((\\\\'|[^'])*?)'", V = /^#$/, _ = "'", G = '"', U = "$", $ = "", z = 4294967296;
for (var L = 0; L < 4; L++)
$ += (z * Math.random()).toString(16);
var J = new RegExp("^" + $);
function X(n, s) {
for (var e = s.lastIndex, t = [], c; c = s.exec(n); )
t.push(c), s.lastIndex === c.index && (s.lastIndex += 1);
return s.lastIndex = e, t;
}
function F(n, s, e) {
var t = typeof n == "function" ? n(e) : n[e];
return typeof t > "u" && e != "" ? t = "" : typeof t > "u" && (t = "$"), typeof t == "object" ? s + $ + JSON.stringify(t) + $ : s + t;
}
function K(n, s, e) {
e || (e = {});
var t = e.escape || "\\", c = "(\\" + t + `['"` + q + `]|[^\\s'"` + q + "])+", m = new RegExp([
"(" + j + ")",
// control chars
"(" + c + "|" + M + "|" + Q + ")+"
].join("|"), "g"), f = X(n, m);
if (f.length === 0)
return [];
s || (s = {});
var w = !1;
return f.map(function(r) {
var a = r[0];
if (!a || w)
return;
if (D.test(a))
return { op: a };
var x = !1, C = !1, d = "", O = !1, i;
function T() {
i += 1;
var v, p, R = a.charAt(i);
if (R === "{") {
if (i += 1, a.charAt(i) === "}")
throw new Error("Bad substitution: " + a.slice(i - 2, i + 1));
if (v = a.indexOf("}", i), v < 0)
throw new Error("Bad substitution: " + a.slice(i));
p = a.slice(i, v), i = v;
} else if (/[*@#?$!_-]/.test(R))
p = R, i += 1;
else {
var g = a.slice(i);
v = g.match(/[^\w\d_]/), v ? (p = g.slice(0, v.index), i += v.index - 1) : (p = g, i = a.length);
}
return F(s, "", p);
}
for (i = 0; i < a.length; i++) {
var u = a.charAt(i);
if (O = O || !x && (u === "*" || u === "?"), C)
d += u, C = !1;
else if (x)
u === x ? x = !1 : x == _ ? d += u : u === t ? (i += 1, u = a.charAt(i), u === G || u === t || u === U ? d += u : d += t + u) : u === U ? d += T() : d += u;
else if (u === G || u === _)
x = u;
else {
if (D.test(u))
return { op: a };
if (V.test(u)) {
w = !0;
var b = { comment: n.slice(r.index + i + 1) };
return d.length ? [d, b] : [b];
} else
u === t ? C = !0 : u === U ? d += T() : d += u;
}
}
return O ? { op: "glob", pattern: d } : d;
}).reduce(function(r, a) {
return typeof a > "u" ? r : r.concat(a);
}, []);
}
var Y = function(s, e, t) {
var c = K(s, e, t);
return typeof e != "function" ? c : c.reduce(function(m, f) {
if (typeof f == "object")
return m.concat(f);
var w = f.split(RegExp("(" + $ + ".*?" + $ + ")", "g"));
return w.length === 1 ? m.concat(w[0]) : m.concat(w.filter(Boolean).map(function(r) {
return J.test(r) ? JSON.parse(r.split($)[1]) : r;
}));
}, []);
}, Z = Y;
const ae = "curl", se = "cURL", ie = "cURL command line tool", H = ["d", "data", "data-raw", "data-urlencode", "data-binary", "data-ascii"], ee = [
["url"],
// Specify the URL explicitly
["user", "u"],
// Authentication
["digest"],
// Apply auth as digest
["header", "H"],
["cookie", "b"],
["get", "G"],
// Put the post data in the URL
["d", "data"],
// Add url encoded data
["data-raw"],
["data-urlencode"],
["data-binary"],
["data-ascii"],
["form", "F"],
// Add multipart data
["request", "X"],
// Request method
H
].flatMap((n) => n);
function oe(n) {
if (!n.match(/^\s*curl /))
return null;
const s = [], e = n.replace(/\ncurl/g, "; curl");
let t = [];
const m = Z(e).flatMap((r) => typeof r == "string" && r.startsWith("-") && !r.startsWith("--") && r.length > 2 ? [r.slice(0, 2), r.slice(2)] : r);
for (const r of m) {
if (typeof r == "string") {
r.startsWith("$") ? t.push(r.slice(1)) : t.push(r);
continue;
}
if ("comment" in r)
continue;
const { op: a } = r;
if (a === ";") {
s.push(t), t = [];
continue;
}
if (a != null && a.startsWith("$")) {
const x = a.slice(2, a.length - 1).replace(/\\'/g, "'");
t.push(x);
continue;
}
a === "glob" && t.push(r.pattern);
}
s.push(t);
const f = {
model: "workspace",
id: N("workspace"),
name: "Curl Import"
};
return {
resources: {
httpRequests: s.filter((r) => r[0] === "curl").map((r) => te(r, f.id)),
workspaces: [f]
}
};
}
function te(n, s) {
const e = {}, t = [];
for (let o = 1; o < n.length; o++) {
let l = n[o];
if (typeof l == "string" && (l = l.trim()), typeof l == "string" && l.match(/^-{1,2}[\w-]+/)) {
const E = l[0] === "-" && l[1] !== "-";
let h = l.replace(/^-{1,2}/, "");
if (!ee.includes(h))
continue;
let y;
const S = n[o + 1];
E && h.length > 1 ? (y = h.slice(1), h = h.slice(0, 1)) : typeof S == "string" && !S.startsWith("-") ? (y = S, o++) : y = !0, e[h] = e[h] || [], e[h].push(y);
} else
l && t.push(l);
}
let c, m;
const f = A(e, t[0] || "", ["url"]), [w, r] = W(f, "?");
c = (r == null ? void 0 : r.split("&").map((o) => {
const l = W(o, "=");
return { name: l[0] ?? "", value: l[1] ?? "", enabled: !0 };
})) ?? [], m = w ?? f;
const [a, x] = A(e, "", ["u", "user"]).split(/:(.*)$/), C = A(e, !1, ["digest"]), d = a ? C ? "digest" : "basic" : null, O = a ? {
username: a.trim(),
password: (x ?? "").trim()
} : {}, i = [
...e.header || [],
...e.H || []
].map((o) => {
const [l, E] = o.split(/:(.*)$/);
return E ? {
name: (l ?? "").trim(),
value: E.trim(),
enabled: !0
} : {
name: (l ?? "").trim().replace(/;$/, ""),
value: "",
enabled: !0
};
}), T = [
...e.cookie || [],
...e.b || []
].map((o) => {
const l = o.split("=", 1)[0], E = o.replace(`${l}=`, "");
return `${l}=${E}`;
}).join("; "), u = i.find((o) => o.name.toLowerCase() === "cookie");
T && u ? u.value += `; ${T}` : T && i.push({
name: "Cookie",
value: T,
enabled: !0
});
const b = ne(e), v = i.find((o) => o.name.toLowerCase() === "content-type"), p = v ? v.value.split(";")[0] : null, R = [
...e.form || [],
...e.F || []
].map((o) => {
const l = o.split("="), E = l[0] ?? "", h = l[1] ?? "", y = {
name: E,
enabled: !0
};
return h.indexOf("@") === 0 ? y.file = h.slice(1) : y.value = h, y;
});
let g = {}, I = null;
const B = A(e, !1, ["G", "get"]);
b.length > 0 && B ? c.push(...b) : b.length > 0 && (p == null || p === "application/x-www-form-urlencoded") ? (I = p ?? "application/x-www-form-urlencoded", g = {
form: b.map((o) => ({
...o,
name: decodeURIComponent(o.name || ""),
value: decodeURIComponent(o.value || "")
}))
}, i.push({
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: !0
})) : b.length > 0 ? (I = p === "application/json" || p === "text/xml" || p === "text/plain" ? p : "other", g = {
text: b.map(({ name: o, value: l }) => o && l ? `${o}=${l}` : o || l).join("&")
}) : R.length && (I = p ?? "multipart/form-data", g = {
form: R
}, p == null && i.push({
name: "Content-Type",
value: "multipart/form-data",
enabled: !0
}));
let P = A(e, "", ["X", "request"]).toUpperCase();
return P === "" && g && (P = "text" in g || "form" in g ? "POST" : "GET"), {
id: N("http_request"),
model: "http_request",
workspaceId: s,
name: "",
urlParameters: c,
url: m,
method: P,
headers: i,
authentication: O,
authenticationType: d,
body: g,
bodyType: I,
folderId: null,
sortPriority: 0
};
}
const ne = (n) => {
let s = [];
for (const e of H) {
const t = n[e];
if (!(!t || t.length === 0))
for (const c of t) {
if (typeof c != "string")
continue;
const [m, f] = c.split("=");
c.startsWith("@") ? s.push({
name: m ?? "",
value: "",
filePath: c.slice(1),
enabled: !0
}) : s.push({
name: m ?? "",
value: e === "data-urlencode" ? encodeURIComponent(f ?? "") : f ?? "",
enabled: !0
});
}
}
return s;
}, A = (n, s, e) => {
for (const t of e)
if (n[t] && n[t].length)
return n[t][0];
return s;
};
function W(n, s) {
const e = n.indexOf(s);
return e > -1 ? [n.slice(0, e), n.slice(e + 1)] : [n];
}
const k = {};
function N(n) {
return k[n] = (k[n] ?? -1) + 1, `GENERATE_ID::${n.toUpperCase()}_${k[n]}`;
}
export {
ie as description,
ae as id,
te as importCommand,
se as name,
oe as pluginHookImport
};

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,17 +0,0 @@
function u(r) {
let e;
try {
e = JSON.parse(r);
} catch {
return;
}
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]";
}
export {
t as isJSObject,
u as pluginHookImport
};

View File

@@ -1,6 +1,6 @@
use std::fmt::Display;
use log::{debug, info, warn};
use log::{debug, info};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::types::JsonValue;

237
src-tauri/src/deno.rs Normal file
View File

@@ -0,0 +1,237 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
//! This example shows how to use swc to transpile TypeScript and JSX/TSX
//! modules.
//!
//! It will only transpile, not typecheck (like Deno's `--no-check` flag).
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use crate::deno_ops::op_yaml_parse;
use anyhow::anyhow;
use anyhow::bail;
use anyhow::Context;
use anyhow::Error;
use deno_ast::ParseParams;
use deno_ast::{EmitOptions, MediaType, SourceMapOption, TranspileOptions};
use deno_core::error::{AnyError, JsError};
use deno_core::resolve_path;
use deno_core::JsRuntime;
use deno_core::ModuleLoadResponse;
use deno_core::ModuleLoader;
use deno_core::ModuleSource;
use deno_core::ModuleSourceCode;
use deno_core::ModuleSpecifier;
use deno_core::ModuleType;
use deno_core::RequestedModuleType;
use deno_core::ResolutionKind;
use deno_core::RuntimeOptions;
use deno_core::SourceMapGetter;
use deno_core::{resolve_import, v8};
use tokio::task::block_in_place;
#[derive(Clone)]
struct SourceMapStore(Rc<RefCell<HashMap<String, Vec<u8>>>>);
impl SourceMapGetter for SourceMapStore {
fn get_source_map(&self, specifier: &str) -> Option<Vec<u8>> {
self.0.borrow().get(specifier).cloned()
}
fn get_source_line(&self, _file_name: &str, _line_number: usize) -> Option<String> {
None
}
}
struct TypescriptModuleLoader {
source_maps: SourceMapStore,
}
impl ModuleLoader for TypescriptModuleLoader {
fn resolve(
&self,
specifier: &str,
referrer: &str,
_kind: ResolutionKind,
) -> Result<ModuleSpecifier, Error> {
Ok(resolve_import(specifier, referrer)?)
}
fn load(
&self,
module_specifier: &ModuleSpecifier,
_maybe_referrer: Option<&ModuleSpecifier>,
_is_dyn_import: bool,
_requested_module_type: RequestedModuleType,
) -> ModuleLoadResponse {
let source_maps = self.source_maps.clone();
fn load(
source_maps: SourceMapStore,
module_specifier: &ModuleSpecifier,
) -> Result<ModuleSource, AnyError> {
let path = module_specifier
.to_file_path()
.map_err(|_| anyhow!("Only file:// URLs are supported."))?;
let media_type = MediaType::from_path(&path);
let (module_type, should_transpile) = match MediaType::from_path(&path) {
MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
(ModuleType::JavaScript, false)
}
MediaType::Jsx => (ModuleType::JavaScript, true),
MediaType::TypeScript
| MediaType::Mts
| MediaType::Cts
| MediaType::Dts
| MediaType::Dmts
| MediaType::Dcts
| MediaType::Tsx => (ModuleType::JavaScript, true),
MediaType::Json => (ModuleType::Json, false),
_ => bail!("Unknown extension {:?}", path.extension()),
};
let code = std::fs::read_to_string(&path)?;
let code = if should_transpile {
let parsed = deno_ast::parse_module(ParseParams {
specifier: module_specifier.clone(),
text: Arc::from(code),
media_type,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})?;
let res = parsed.transpile(
&TranspileOptions::default(),
&EmitOptions {
source_map: SourceMapOption::Separate,
inline_sources: true,
..Default::default()
},
)?;
let src = res.into_source();
let source_map = src.source_map.unwrap();
let source = src.source;
source_maps
.0
.borrow_mut()
.insert(module_specifier.to_string(), source_map);
String::from_utf8(source).unwrap()
} else {
code
};
Ok(ModuleSource::new(
module_type,
ModuleSourceCode::String(code.into()),
module_specifier,
None,
))
}
ModuleLoadResponse::Sync(load(source_maps, module_specifier))
}
}
pub fn run_plugin_deno_block(
plugin_index_file: &str,
fn_name: &str,
fn_args: Vec<serde_json::Value>,
) -> Result<serde_json::Value, Error> {
block_in_place(|| {
tauri::async_runtime::block_on(run_plugin_deno_2(plugin_index_file, fn_name, fn_args))
})
}
deno_core::extension!(
yaak_runtime,
ops = [ op_yaml_parse ],
esm_entry_point = "ext:yaak_runtime/yaml.js",
esm = [dir "src/plugin-runtime", "yaml.js"]
);
async fn run_plugin_deno_2(
plugin_index_file: &str,
fn_name: &str,
fn_args: Vec<serde_json::Value>,
) -> Result<serde_json::Value, Error> {
let source_map_store = SourceMapStore(Rc::new(RefCell::new(HashMap::new())));
let mut ext_console = deno_console::deno_console::init_ops_and_esm();
ext_console.esm_entry_point = Some("ext:deno_console/01_console.js");
let ext_yaak = yaak_runtime::init_ops_and_esm();
let mut js_runtime = JsRuntime::new(RuntimeOptions {
module_loader: Some(Rc::new(TypescriptModuleLoader {
source_maps: source_map_store.clone(),
})),
source_map_getter: Some(Rc::new(source_map_store)),
extensions: vec![ext_console, ext_yaak],
..Default::default()
});
let main_module = resolve_path(
plugin_index_file,
&std::env::current_dir().context("Unable to get CWD")?,
)?;
// Load the main module so we can do stuff with it
let mod_id = js_runtime.load_main_es_module(&main_module).await?;
let result = js_runtime.mod_evaluate(mod_id);
js_runtime.run_event_loop(Default::default()).await?;
result.await?;
let module_namespace = js_runtime.get_module_namespace(mod_id).unwrap();
let scope = &mut js_runtime.handle_scope();
let module_namespace = v8::Local::<v8::Object>::new(scope, module_namespace);
// Get the exported function we're calling
let func_key = v8::String::new(scope, fn_name).unwrap();
let func = module_namespace.get(scope, func_key.into()).unwrap();
let func = v8::Local::<v8::Function>::try_from(func).unwrap();
let tc_scope = &mut v8::TryCatch::new(scope);
// Create Yaak context object
let null = v8::null(tc_scope).into();
let name = v8::String::new(tc_scope, "foo").unwrap().into();
let value = v8::String::new(tc_scope, "bar").unwrap().into();
let yaak_ctx: v8::Local<v8::Value> =
v8::Object::with_prototype_and_properties(tc_scope, null, &[name], &[value]).into();
// Create the function arguments
let passed_args = &mut fn_args
.iter()
.map(|a| {
let v: v8::Local<v8::Value> = deno_core::serde_v8::to_v8(tc_scope, a).unwrap();
v
})
.collect::<Vec<v8::Local<v8::Value>>>();
let all_args = &mut vec![yaak_ctx];
all_args.append(passed_args);
// Call the function
let func_res = func.call(tc_scope, module_namespace.into(), all_args);
// Catch and return any thrown errors
if tc_scope.has_caught() {
let e = tc_scope.exception().unwrap();
let js_error = JsError::from_v8_exception(tc_scope, e);
return Err(Error::msg(js_error.stack.unwrap_or_default()));
}
// Handle the result
match func_res {
None => Ok(serde_json::Value::Null),
Some(res) => {
if res.is_null() || res.is_undefined() {
Ok(serde_json::Value::Null)
} else {
let value: serde_json::Value = deno_core::serde_v8::from_v8(tc_scope, res).unwrap();
Ok(value)
}
}
}
}

16
src-tauri/src/deno_ops.rs Normal file
View File

@@ -0,0 +1,16 @@
use deno_core::error::AnyError;
use deno_core::op2;
#[op2]
#[serde]
pub fn op_yaml_parse(#[string] text: String) -> Result<serde_json::Value, AnyError> {
let value = serde_yaml::from_str(&text)?;
Ok(value)
}
#[op2]
#[string]
pub fn op_yaml_stringify(#[serde] value: serde_json::Value) -> Result<String, AnyError> {
let value = serde_yaml::to_string(&value)?;
Ok(value)
}

View File

@@ -8,9 +8,10 @@ use std::time::Duration;
use base64::Engine;
use http::header::{ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use http::{HeaderMap, HeaderName, HeaderValue};
use log::{error, info, warn};
use reqwest::redirect::Policy;
use reqwest::Method;
use reqwest::{multipart, Url};
use sqlx::types::{Json, JsonValue};
use tauri::{Manager, WebviewWindow};
@@ -45,6 +46,7 @@ pub async fn send_http_request(
true => Policy::limited(10), // TODO: Handle redirects natively
false => Policy::none(),
})
.connection_verbose(true)
.gzip(true)
.brotli(true)
.deflate(true)
@@ -106,7 +108,7 @@ pub async fn send_http_request(
format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()),
window,
)
.await;
.await;
}
};
@@ -392,11 +394,11 @@ pub async fn send_http_request(
response.url = v.url().to_string();
response.remote_addr = v.remote_addr().map(|a| a.to_string());
response.version = match v.version() {
http::Version::HTTP_09 => Some("HTTP/0.9".to_string()),
http::Version::HTTP_10 => Some("HTTP/1.0".to_string()),
http::Version::HTTP_11 => Some("HTTP/1.1".to_string()),
http::Version::HTTP_2 => Some("HTTP/2".to_string()),
http::Version::HTTP_3 => Some("HTTP/3".to_string()),
reqwest::Version::HTTP_09 => Some("HTTP/0.9".to_string()),
reqwest::Version::HTTP_10 => Some("HTTP/1.0".to_string()),
reqwest::Version::HTTP_11 => Some("HTTP/1.1".to_string()),
reqwest::Version::HTTP_2 => Some("HTTP/2".to_string()),
reqwest::Version::HTTP_3 => Some("HTTP/3".to_string()),
_ => None,
};

View File

@@ -12,8 +12,6 @@ use std::process::exit;
use std::str::FromStr;
use std::time::Duration;
use ::http::uri::InvalidUri;
use ::http::Uri;
use base64::Engine;
use fern::colors::ColoredLevelConfig;
use log::{debug, error, info, warn};
@@ -61,6 +59,8 @@ use crate::updates::{UpdateMode, YaakUpdater};
use crate::window_menu::app_menu;
mod analytics;
mod deno;
mod deno_ops;
mod grpc;
mod http_request;
mod models;
@@ -118,7 +118,6 @@ async fn cmd_dismiss_notification(
notification_id: &str,
yaak_notifier: State<'_, Mutex<YaakNotifier>>,
) -> Result<(), String> {
info!("SEEN? {notification_id}");
yaak_notifier.lock().await.seen(&app, notification_id).await
}
@@ -132,14 +131,14 @@ async fn cmd_grpc_reflect(
let req = get_grpc_request(&window, request_id)
.await
.map_err(|e| e.to_string())?;
let uri = safe_uri(&req.url).map_err(|e| e.to_string())?;
let uri = safe_uri(req.url.as_str());
if proto_files.len() > 0 {
grpc_handle
.lock()
.await
.services_from_files(
&req.id,
&uri,
uri.as_str(),
proto_files
.iter()
.map(|p| PathBuf::from_str(p).unwrap())
@@ -150,7 +149,7 @@ async fn cmd_grpc_reflect(
grpc_handle
.lock()
.await
.services_from_reflection(&req.id, &uri)
.services_from_reflection(&req.id, uri.as_str())
.await
}
}
@@ -249,7 +248,7 @@ async fn cmd_grpc_go(
let maybe_in_msg_tx = std::sync::Mutex::new(Some(in_msg_tx.clone()));
let (cancelled_tx, mut cancelled_rx) = tokio::sync::watch::channel(false);
let uri = safe_uri(&req.url).map_err(|e| e.to_string())?;
let uri = safe_uri(&req.url);
let in_msg_stream = tokio_stream::wrappers::ReceiverStream::new(in_msg_rx);
@@ -267,7 +266,7 @@ async fn cmd_grpc_go(
.await
.connect(
&req.clone().id,
uri,
uri.as_str(),
proto_files
.iter()
.map(|p| PathBuf::from_str(p).unwrap())
@@ -736,7 +735,7 @@ async fn cmd_filter_response(
};
let body = read_to_string(response.body_path.unwrap()).unwrap();
let filter_result = plugin::run_plugin_filter(&w.app_handle(), plugin_name, filter, &body)
let filter_result = plugin::run_plugin_filter(plugin_name, filter, &body)
.await
.expect("Failed to run filter");
Ok(filter_result.filtered)
@@ -755,11 +754,11 @@ async fn cmd_import_data(
"importer-yaak",
"importer-curl",
];
let file = read_to_string(file_path)
.unwrap_or_else(|_| panic!("Unable to read file {}", file_path));
let file =
read_to_string(file_path).unwrap_or_else(|_| panic!("Unable to read file {}", file_path));
let file_contents = file.as_str();
for plugin_name in plugins {
let v = run_plugin_import(&w.app_handle(), plugin_name, file_contents)
let v = run_plugin_import(plugin_name, file_contents)
.await
.map_err(|e| e.to_string())?;
if let Some(r) = v {
@@ -808,13 +807,15 @@ async fn cmd_import_data(
}
};
info!("Importing resources");
for mut v in r.resources.workspaces {
v.id = maybe_gen_id(v.id.as_str(), ModelType::TypeWorkspace, &mut id_map);
let x = upsert_workspace(&w, v).await.map_err(|e| e.to_string())?;
imported_resources.workspaces.push(x.clone());
info!("Imported workspace: {}", x.name);
}
info!(
"Imported {} workspaces",
imported_resources.workspaces.len()
);
for mut v in r.resources.environments {
v.id = maybe_gen_id(v.id.as_str(), ModelType::TypeEnvironment, &mut id_map);
@@ -825,8 +826,11 @@ async fn cmd_import_data(
);
let x = upsert_environment(&w, v).await.map_err(|e| e.to_string())?;
imported_resources.environments.push(x.clone());
info!("Imported environment: {}", x.name);
}
info!(
"Imported {} environments",
imported_resources.environments.len()
);
for mut v in r.resources.folders {
v.id = maybe_gen_id(v.id.as_str(), ModelType::TypeFolder, &mut id_map);
@@ -838,8 +842,8 @@ async fn cmd_import_data(
v.folder_id = maybe_gen_id_opt(v.folder_id, ModelType::TypeFolder, &mut id_map);
let x = upsert_folder(&w, v).await.map_err(|e| e.to_string())?;
imported_resources.folders.push(x.clone());
info!("Imported folder: {}", x.name);
}
info!("Imported {} folders", imported_resources.folders.len());
for mut v in r.resources.http_requests {
v.id = maybe_gen_id(v.id.as_str(), ModelType::TypeHttpRequest, &mut id_map);
@@ -853,8 +857,11 @@ async fn cmd_import_data(
.await
.map_err(|e| e.to_string())?;
imported_resources.http_requests.push(x.clone());
info!("Imported request: {}", x.name);
}
info!(
"Imported {} http_requests",
imported_resources.http_requests.len()
);
for mut v in r.resources.grpc_requests {
v.id = maybe_gen_id(v.id.as_str(), ModelType::TypeGrpcRequest, &mut id_map);
@@ -868,8 +875,11 @@ async fn cmd_import_data(
.await
.map_err(|e| e.to_string())?;
imported_resources.grpc_requests.push(x.clone());
info!("Imported request: {}", x.name);
}
info!(
"Imported {} grpc_requests",
imported_resources.grpc_requests.len()
);
Ok(imported_resources)
}
@@ -893,16 +903,12 @@ async fn cmd_request_to_curl(
.await
.map_err(|e| e.to_string())?;
let rendered = render_request(&request, &workspace, environment.as_ref());
Ok(run_plugin_export_curl(&app, &rendered)?)
Ok(run_plugin_export_curl(&rendered)?)
}
#[tauri::command]
async fn cmd_curl_to_request(
app: AppHandle,
command: &str,
workspace_id: &str,
) -> Result<HttpRequest, String> {
let v = run_plugin_import(&app, "importer-curl", command)
async fn cmd_curl_to_request(command: &str, workspace_id: &str) -> Result<HttpRequest, String> {
let v = run_plugin_import("importer-curl", command)
.await
.map_err(|e| e.to_string());
match v {
@@ -952,6 +958,28 @@ async fn cmd_export_data(
Ok(())
}
#[tauri::command]
async fn cmd_save_response(
window: WebviewWindow,
response_id: &str,
filepath: &str,
) -> Result<(), String> {
let response = get_http_response(&window, response_id)
.await
.map_err(|e| e.to_string())?;
let body_path = match response.body_path {
None => {
return Err("Response does not have a body".to_string());
}
Some(p) => p,
};
fs::copy(body_path, filepath).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn cmd_send_http_request(
window: WebviewWindow,
@@ -1032,6 +1060,7 @@ async fn response_err(
error: String,
w: &WebviewWindow,
) -> Result<HttpResponse, String> {
warn!("Failed to send request: {}", error);
let mut response = response.clone();
response.elapsed = -1;
response.error = Some(error.clone());
@@ -1581,7 +1610,9 @@ pub fn run() {
.level_for("tokio_util", log::LevelFilter::Info)
.level_for("tonic", log::LevelFilter::Info)
.level_for("tower", log::LevelFilter::Info)
.level_for("tracing", log::LevelFilter::Info)
.level_for("tracing", log::LevelFilter::Warn)
.level_for("swc_ecma_codegen", log::LevelFilter::Off)
.level_for("swc_ecma_transforms_base", log::LevelFilter::Off)
.with_colors(ColoredLevelConfig::default())
.level(if is_dev() {
log::LevelFilter::Trace
@@ -1693,6 +1724,7 @@ pub fn run() {
cmd_new_window,
cmd_request_to_curl,
cmd_dismiss_notification,
cmd_save_response,
cmd_send_ephemeral_request,
cmd_send_http_request,
cmd_set_key_value,
@@ -1894,11 +1926,10 @@ async fn get_update_mode(h: &AppHandle) -> UpdateMode {
UpdateMode::new(settings.update_channel.as_str())
}
fn safe_uri(endpoint: &str) -> Result<Uri, InvalidUri> {
let uri = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
Uri::from_str(endpoint)?
fn safe_uri(endpoint: &str) -> String {
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint.into()
} else {
Uri::from_str(&format!("http://{}", endpoint))?
};
Ok(uri)
format!("http://{}", endpoint)
}
}

View File

@@ -59,6 +59,7 @@ pub struct Settings {
pub interface_scale: i64,
pub editor_font_size: i64,
pub editor_soft_wrap: bool,
pub open_workspace_new_window: Option<bool>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
@@ -891,7 +892,8 @@ async fn get_settings(mgr: &impl Manager<Wry>) -> Result<Settings, sqlx::Error>
SELECT
id, model, created_at, updated_at, theme, appearance,
theme_dark, theme_light, update_channel,
interface_font_size, interface_scale, editor_font_size, editor_soft_wrap
interface_font_size, interface_scale, editor_font_size, editor_soft_wrap,
open_workspace_new_window
FROM settings
WHERE id = 'default'
"#,
@@ -928,8 +930,9 @@ pub async fn update_settings(
r#"
UPDATE settings SET (
theme, appearance, theme_dark, theme_light, update_channel,
interface_font_size, interface_scale, editor_font_size, editor_soft_wrap
) = (?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE id = 'default';
interface_font_size, interface_scale, editor_font_size, editor_soft_wrap,
open_workspace_new_window
) = (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE id = 'default';
"#,
settings.theme,
settings.appearance,
@@ -940,6 +943,7 @@ pub async fn update_settings(
settings.interface_scale,
settings.editor_font_size,
settings.editor_soft_wrap,
settings.open_workspace_new_window,
)
.execute(&db)
.await?;

View File

@@ -1,8 +1,8 @@
use std::time::SystemTime;
use chrono::{Duration, NaiveDateTime, Utc};
use http::Method;
use log::debug;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};

View File

@@ -0,0 +1,7 @@
((globalThis) => {
const core = Deno.core;
globalThis.YAML = {
parse: core.ops.op_yaml_parse,
stringify: core.ops.op_yaml_stringify,
};
})(globalThis);

View File

@@ -1,17 +1,9 @@
use std::rc::Rc;
use std::path;
use boa_engine::builtins::promise::PromiseState;
use boa_engine::{
js_string, module::SimpleModuleLoader, property::Attribute, Context, JsNativeError, JsValue,
Module, Source,
};
use boa_runtime::Console;
use log::{debug, error};
use log::error;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::path::BaseDirectory;
use tauri::{AppHandle, Manager};
use crate::deno::run_plugin_deno_block;
use crate::models::{HttpRequest, WorkspaceExportResources};
#[derive(Default, Debug, Deserialize, Serialize)]
@@ -25,140 +17,68 @@ pub struct ImportResult {
}
pub async fn run_plugin_filter(
app_handle: &AppHandle,
plugin_name: &str,
response_body: &str,
filter: &str,
) -> Option<FilterResult> {
let result_json = run_plugin(
app_handle,
plugin_name,
"pluginHookResponseFilter",
&[js_string!(response_body).into(), js_string!(filter).into()],
);
let plugin_dir = path::Path::new("/Users/gschier/Workspace/yaak/plugins");
let plugin_index_file = plugin_dir.join(plugin_name).join("build/index.mjs");
if result_json.is_null() {
let result = run_plugin_deno_block(
plugin_index_file.to_str().unwrap(),
"pluginHookResponseFilter",
vec![
serde_json::to_value(response_body).unwrap(),
serde_json::to_value(filter).unwrap(),
],
)
.map_err(|e| e.to_string())
.expect("Failed to run plugin");
if result.is_null() {
error!("Plugin {} failed to run", plugin_name);
return None;
}
let resources: FilterResult =
serde_json::from_value(result_json).expect("failed to parse filter plugin result json");
serde_json::from_value(result).expect("failed to parse filter plugin result json");
Some(resources)
}
pub fn run_plugin_export_curl(
app_handle: &AppHandle,
request: &HttpRequest,
) -> Result<String, String> {
let mut context = Context::default();
let request_json = serde_json::to_value(request).map_err(|e| e.to_string())?;
let result_json = run_plugin(
app_handle,
"exporter-curl",
"pluginHookExport",
&[JsValue::from_json(&request_json, &mut context).map_err(|e| e.to_string())?],
);
pub fn run_plugin_export_curl(request: &HttpRequest) -> Result<String, String> {
let plugin_dir = path::Path::new("/Users/gschier/Workspace/yaak/plugins");
let plugin_index_file = plugin_dir.join("exporter-curl").join("build/index.mjs");
let resources: String = serde_json::from_value(result_json).map_err(|e| e.to_string())?;
Ok(resources)
let request_json = serde_json::to_value(request).map_err(|e| e.to_string())?;
let result = run_plugin_deno_block(
plugin_index_file.to_str().unwrap(),
"pluginHookExport",
vec![request_json],
)
.map_err(|e| e.to_string())?;
let export_str: String = serde_json::from_value(result).map_err(|e| e.to_string())?;
Ok(export_str)
}
pub async fn run_plugin_import(
app_handle: &AppHandle,
plugin_name: &str,
file_contents: &str,
) -> Result<Option<ImportResult>, String> {
let result_json = run_plugin(
app_handle,
plugin_name,
"pluginHookImport",
&[js_string!(file_contents).into()],
);
let plugin_dir = path::Path::new("/Users/gschier/Workspace/yaak/plugins");
let plugin_index_file = plugin_dir.join(plugin_name).join("build/index.mjs");
if result_json.is_null() {
let result = run_plugin_deno_block(
plugin_index_file.to_str().unwrap(),
"pluginHookImport",
vec![serde_json::to_value(file_contents).map_err(|e| e.to_string())?],
)
.map_err(|e| e.to_string())?;
if result.is_null() {
return Ok(None);
}
let resources: ImportResult = serde_json::from_value(result_json).map_err(|e| e.to_string())?;
let resources: ImportResult = serde_json::from_value(result).map_err(|e| e.to_string())?;
Ok(Some(resources))
}
fn run_plugin(
app_handle: &AppHandle,
plugin_name: &str,
entrypoint: &str,
js_args: &[JsValue],
) -> serde_json::Value {
let plugin_dir = app_handle
.path()
.resolve("plugins", BaseDirectory::Resource)
.expect("failed to resolve plugin directory resource")
.join(plugin_name);
let plugin_index_file = plugin_dir.join("index.mjs");
debug!(
"Running plugin dir={:?} file={:?}",
plugin_dir, plugin_index_file
);
let loader = Rc::new(SimpleModuleLoader::new(plugin_dir).unwrap());
let context = &mut Context::builder()
.module_loader(loader.clone())
.build()
.expect("failed to create context");
add_runtime(context);
let source = Source::from_filepath(&plugin_index_file).expect("Error opening file");
// Can also pass a `Some(realm)` if you need to execute the module in another realm.
let module = Module::parse(source, None, context).expect("failed to parse module");
// Insert parsed entrypoint into the module loader
loader.insert(plugin_index_file, module.clone());
let promise_result = module.load_link_evaluate(context);
// Very important to push forward the job queue after queueing promises.
context.run_jobs();
// Checking if the final promise didn't return an error.
match promise_result.state() {
PromiseState::Pending => {
panic!("Promise was pending");
}
PromiseState::Fulfilled(v) => {
assert_eq!(v, JsValue::undefined())
}
PromiseState::Rejected(err) => {
panic!("Failed to link: {}", err.display());
}
}
let namespace = module.namespace(context);
let result = namespace
.get(js_string!(entrypoint), context)
.expect("failed to get entrypoint")
.as_callable()
.cloned()
.ok_or_else(|| JsNativeError::typ().with_message("export wasn't a function!"))
.expect("Failed to get entrypoint")
.call(&JsValue::undefined(), js_args, context)
.expect("Failed to call entrypoint");
match result.is_undefined() {
true => json!(null), // to_json doesn't work with undefined (yet)
false => result
.to_json(context)
.expect("failed to convert result to json"),
}
}
fn add_runtime(context: &mut Context) {
let console = Console::init(context);
context
.register_global_property(js_string!(Console::NAME), console, Attribute::all())
.expect("the console builtin shouldn't exist");
}

View File

@@ -1,9 +1,11 @@
use std::collections::HashMap;
use regex::Regex;
use sqlx::types::{Json, JsonValue};
use crate::models::{Environment, HttpRequest, HttpRequestHeader, HttpUrlParameter, Workspace};
use crate::models::{
Environment, EnvironmentVariable, HttpRequest, HttpRequestHeader, HttpUrlParameter, Workspace,
};
use templates::parse_and_render;
pub fn render_request(r: &HttpRequest, w: &Workspace, e: Option<&Environment>) -> HttpRequest {
let r = r.clone();
@@ -64,30 +66,29 @@ pub fn render_request(r: &HttpRequest, w: &Workspace, e: Option<&Environment>) -
}
pub fn render(template: &str, workspace: &Workspace, environment: Option<&Environment>) -> String {
let mut map = HashMap::new();
let workspace_variables = &workspace.variables.0;
for variable in workspace_variables {
let mut variables = HashMap::new();
variables = add_variable_to_map(variables, &workspace.variables.0);
if let Some(e) = environment {
variables = add_variable_to_map(variables, &e.variables.0);
}
parse_and_render(template, variables, None)
}
fn add_variable_to_map<'a>(
m: HashMap<&'a str, &'a str>,
variables: &'a Vec<EnvironmentVariable>,
) -> HashMap<&'a str, &'a str> {
let mut map = m.clone();
for variable in variables {
if !variable.enabled || variable.value.is_empty() {
continue;
}
map.insert(variable.name.as_str(), variable.value.as_str());
let name = variable.name.as_str();
let value = variable.value.as_str();
map.insert(name, value);
}
if let Some(e) = environment {
let environment_variables = &e.variables.0;
for variable in environment_variables {
if !variable.enabled || variable.value.is_empty() {
continue;
}
map.insert(variable.name.as_str(), variable.value.as_str());
}
}
Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}")
.expect("Failed to create regex")
.replace_all(template, |caps: &regex::Captures| {
let key = caps.get(1).unwrap().as_str();
map.get(key).unwrap_or(&"")
})
.to_string()
map
}

View File

@@ -134,8 +134,11 @@ pub fn app_menu(app_handle: &AppHandle) -> tauri::Result<Menu<Wry>> {
.build(app_handle)?,
&MenuItemBuilder::with_id("dev.reset_size".to_string(), "Reset Size")
.build(app_handle)?,
&MenuItemBuilder::with_id("dev.generate_theme_css".to_string(), "Generate Theme CSS")
.build(app_handle)?,
&MenuItemBuilder::with_id(
"dev.generate_theme_css".to_string(),
"Generate Theme CSS",
)
.build(app_handle)?,
],
)?,
],

View File

@@ -1,6 +1,6 @@
{
"productName": "yaak",
"version": "2024.5.2",
"version": "2024.6.0",
"identifier": "app.yaak.desktop",
"build": {
"beforeBuildCommand": "npm run build",

View File

@@ -0,0 +1,6 @@
[package]
name = "templates"
version = "0.1.0"
edition = "2021"
[dependencies]

View File

@@ -0,0 +1,7 @@
pub mod parser;
pub mod renderer;
pub use parser::*;
pub use renderer::*;
pub fn template_foo() {}

View File

@@ -0,0 +1,370 @@
#[derive(Clone, PartialEq, Debug)]
pub enum Val {
Str(String),
Ident(String),
}
#[derive(Clone, PartialEq, Debug)]
pub enum Token {
Raw(String),
Var { name: String },
Fn { name: String, args: Vec<Val> },
Eof,
}
// Template Syntax
//
// ${[ my_var ]}
// ${[ my_fn() ]}
// ${[ my_fn(my_var) ]}
// ${[ my_fn(my_var, "A String") ]}
// default
#[derive(Default)]
pub struct Parser {
tokens: Vec<Token>,
chars: Vec<char>,
pos: usize,
curr_text: String,
}
impl Parser {
pub fn new(text: &str) -> Parser {
Parser {
chars: text.chars().collect(),
..Parser::default()
}
}
pub fn parse(&mut self) -> Vec<Token> {
let start_pos = self.pos;
while self.pos < self.chars.len() {
if self.match_str("${[") {
let start_curr = self.pos;
if let Some(t) = self.parse_tag() {
self.push_token(t);
} else {
self.pos = start_curr;
self.curr_text += "${[";
}
} else {
let ch = self.next_char();
self.curr_text.push(ch);
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
self.push_token(Token::Eof);
self.tokens.clone()
}
fn parse_tag(&mut self) -> Option<Token> {
// Parse up to first identifier
// ${[ my_var...
self.skip_whitespace();
let name = match self.parse_ident() {
None => return None,
Some(v) => v,
};
// Parse fn args if they exist
// ${[ my_var(a, b, c)
let args = if self.match_str("(") {
self.parse_fn_args()
} else {
None
};
// Parse to closing tag
// ${[ my_var(a, b, c) ]}
self.skip_whitespace();
if !self.match_str("]}") {
return None;
}
Some(match args {
Some(a) => Token::Fn { args: a, name },
None => Token::Var { name },
})
}
#[allow(dead_code)]
fn debug_pos(&self, x: &str) {
println!(
r#"Position: {x} -- [{}] = {} --> "{}"#,
self.pos,
self.chars[self.pos],
self.chars.iter().collect::<String>()
);
}
fn parse_fn_args(&mut self) -> Option<Vec<Val>> {
let start_pos = self.pos;
let mut args: Vec<Val> = Vec::new();
while self.pos < self.chars.len() {
self.skip_whitespace();
if let Some(v) = self.parse_ident_or_string() {
args.push(v);
}
self.skip_whitespace();
if self.match_str(")") {
break;
}
self.skip_whitespace();
// If we don't find a comma, that's bad
if !args.is_empty() && !self.match_str(",") {
return None;
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
return Some(args);
}
fn parse_ident_or_string(&mut self) -> Option<Val> {
if let Some(i) = self.parse_ident() {
Some(Val::Ident(i))
} else if let Some(s) = self.parse_string() {
Some(Val::Str(s))
} else {
None
}
}
fn parse_ident(&mut self) -> Option<String> {
let start_pos = self.pos;
let mut text = String::new();
while self.pos < self.chars.len() {
let ch = self.peek_char();
if ch.is_alphanumeric() || ch == '_' {
text.push(ch);
self.pos += 1;
} else {
break;
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
if text.is_empty() {
return None;
}
return Some(text);
}
fn parse_string(&mut self) -> Option<String> {
let start_pos = self.pos;
let mut text = String::new();
if !self.match_str("\"") {
return None;
}
let mut found_closing = false;
while self.pos < self.chars.len() {
let ch = self.next_char();
match ch {
'\\' => {
text.push(self.next_char());
}
'"' => {
found_closing = true;
break;
}
_ => {
text.push(ch);
}
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
if !found_closing {
self.pos = start_pos;
return None;
}
return Some(text);
}
fn skip_whitespace(&mut self) {
while self.pos < self.chars.len() {
if self.peek_char().is_whitespace() {
self.pos += 1;
} else {
break;
}
}
}
fn next_char(&mut self) -> char {
let ch = self.peek_char();
self.pos += 1;
ch
}
fn peek_char(&self) -> char {
let ch = self.chars[self.pos];
ch
}
fn push_token(&mut self, token: Token) {
// Push any text we've accumulated
if !self.curr_text.is_empty() {
let text_token = Token::Raw(self.curr_text.clone());
self.tokens.push(text_token);
self.curr_text.clear();
}
self.tokens.push(token);
}
fn match_str(&mut self, value: &str) -> bool {
if self.pos + value.len() > self.chars.len() {
return false;
}
let cmp = self.chars[self.pos..self.pos + value.len()]
.iter()
.collect::<String>();
if cmp == value {
// We have a match, so advance the current index
self.pos += value.len();
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn var_simple() {
let mut p = Parser::new("${[ foo ]}");
assert_eq!(
p.parse(),
vec![Token::Var { name: "foo".into() }, Token::Eof]
);
}
#[test]
fn var_multiple_names_invalid() {
let mut p = Parser::new("${[ foo bar ]}");
assert_eq!(
p.parse(),
vec![Token::Raw("${[ foo bar ]}".into()), Token::Eof]
);
}
#[test]
fn tag_string() {
let mut p = Parser::new(r#"${[ "foo \"bar\" baz" ]}"#);
assert_eq!(
p.parse(),
vec![Token::Raw(r#"${[ "foo \"bar\" baz" ]}"#.into()), Token::Eof]
);
}
#[test]
fn var_surrounded() {
let mut p = Parser::new("Hello ${[ foo ]}!");
assert_eq!(
p.parse(),
vec![
Token::Raw("Hello ".to_string()),
Token::Var { name: "foo".into() },
Token::Raw("!".to_string()),
Token::Eof,
]
);
}
#[test]
fn fn_simple() {
let mut p = Parser::new("${[ foo() ]}");
assert_eq!(
p.parse(),
vec![
Token::Fn {
name: "foo".into(),
args: Vec::new(),
},
Token::Eof
]
);
}
#[test]
fn fn_ident_arg() {
let mut p = Parser::new("${[ foo(bar) ]}");
assert_eq!(
p.parse(),
vec![
Token::Fn {
name: "foo".into(),
args: vec![Val::Ident("bar".into())],
},
Token::Eof
]
);
}
#[test]
fn fn_ident_args() {
let mut p = Parser::new("${[ foo(bar,baz, qux ) ]}");
assert_eq!(
p.parse(),
vec![
Token::Fn {
name: "foo".into(),
args: vec![
Val::Ident("bar".into()),
Val::Ident("baz".into()),
Val::Ident("qux".into()),
],
},
Token::Eof
]
);
}
#[test]
fn fn_mixed_args() {
let mut p = Parser::new(r#"${[ foo(bar,"baz \"hi\"", qux ) ]}"#);
assert_eq!(
p.parse(),
vec![
Token::Fn {
name: "foo".into(),
args: vec![
Val::Ident("bar".into()),
Val::Str(r#"baz "hi""#.into()),
Val::Ident("qux".into()),
],
},
Token::Eof
]
);
}
}

View File

@@ -0,0 +1,102 @@
use crate::{Parser, Token, Val};
use std::collections::HashMap;
type TemplateCallback = fn(name: &str, args: Vec<&str>) -> String;
pub fn parse_and_render(
template: &str,
vars: HashMap<&str, &str>,
cb: Option<TemplateCallback>,
) -> String {
let mut p = Parser::new(template);
let tokens = p.parse();
render(tokens, vars, cb)
}
pub fn render(
tokens: Vec<Token>,
vars: HashMap<&str, &str>,
cb: Option<TemplateCallback>,
) -> String {
let mut doc_str: Vec<String> = Vec::new();
for t in tokens {
match t {
Token::Raw(s) => doc_str.push(s),
Token::Var { name } => {
if let Some(v) = vars.get(name.as_str()) {
doc_str.push(v.to_string());
}
}
Token::Fn { name, args } => {
let empty = &"";
let resolved_args = args
.iter()
.map(|a| match a {
Val::Str(s) => s.as_str(),
Val::Ident(i) => vars.get(i.as_str()).unwrap_or(empty),
})
.collect();
let val = match cb {
Some(cb) => cb(name.as_str(), resolved_args),
None => "".into(),
};
doc_str.push(val);
}
Token::Eof => {}
}
}
return doc_str.join("");
}
#[cfg(test)]
mod tests {
use crate::*;
use std::collections::HashMap;
#[test]
fn render_empty() {
let template = "";
let vars = HashMap::new();
let result = "";
assert_eq!(parse_and_render(template, vars, None), result.to_string());
}
#[test]
fn render_text_only() {
let template = "Hello World!";
let vars = HashMap::new();
let result = "Hello World!";
assert_eq!(parse_and_render(template, vars, None), result.to_string());
}
#[test]
fn render_simple() {
let template = "${[ foo ]}";
let vars = HashMap::from([("foo", "bar")]);
let result = "bar";
assert_eq!(parse_and_render(template, vars, None), result.to_string());
}
#[test]
fn render_surrounded() {
let template = "hello ${[ word ]} world!";
let vars = HashMap::from([("word", "cruel")]);
let result = "hello cruel world!";
assert_eq!(parse_and_render(template, vars, None), result.to_string());
}
#[test]
fn render_valid_fn() {
let vars = HashMap::new();
let template = r#"${[ say_hello("John", "Kate") ]}"#;
let result = r#"say_hello: ["John", "Kate"]"#;
let cb: fn(&str, Vec<&str>) -> String =
|name: &str, args: Vec<&str>| format!("{name}: {:?}", args);
assert_eq!(
parse_and_render(template, vars, Some(cb)),
result.to_string()
);
}
}

View File

@@ -1,28 +1,243 @@
import { invoke } from '@tauri-apps/api/core';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { search } from 'fast-fuzzy';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { useEnvironments } from '../hooks/useEnvironments';
import type { HotkeyAction } from '../hooks/useHotKey';
import { useHotKey } from '../hooks/useHotKey';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRequests } from '../hooks/useRequests';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Input } from './core/Input';
import { CookieDialog } from './CookieDialog';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { HotKey } from './core/HotKey';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
import { HStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
interface CommandPaletteGroup {
key: string;
label: ReactNode;
items: CommandPaletteItem[];
}
type CommandPaletteItem = {
key: string;
onSelect: () => void;
action?: HotkeyAction;
} & ({ searchText: string; label: ReactNode } | { label: string });
const MAX_PER_GROUP = 8;
export function CommandPalette({ onClose }: { onClose: () => void }) {
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [command, setCommand] = useDebouncedState<string>('', 150);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const routes = useAppRoutes();
const activeEnvironmentId = useActiveEnvironmentId();
const activeRequestId = useActiveRequestId();
const active = useActiveWorkspaceId();
const workspaces = useWorkspaces();
const environments = useEnvironments();
const recentEnvironments = useRecentEnvironments();
const recentWorkspaces = useRecentWorkspaces();
const requests = useRequests();
const [command, setCommand] = useState<string>('');
const recentRequests = useRecentRequests();
const openWorkspace = useOpenWorkspace();
const createWorkspace = useCreateWorkspace();
const createHttpRequest = useCreateHttpRequest();
const { activeCookieJar } = useActiveCookieJar();
const createGrpcRequest = useCreateGrpcRequest();
const createEnvironment = useCreateEnvironment();
const dialog = useDialog();
const workspaceId = useActiveWorkspaceId();
const activeEnvironment = useActiveEnvironment();
const [, setSidebarHidden] = useSidebarHidden();
const items = useMemo<{ label: string; onSelect: () => void; key: string }[]>(() => {
const items = [];
for (const r of requests) {
items.push({
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
const commands: CommandPaletteItem[] = [
{
key: 'settings.open',
label: 'Open Settings',
action: 'settings.show',
onSelect: async () => {
if (workspaceId == null) return;
await invoke('cmd_new_nested_window', {
url: routes.paths.workspaceSettings({ workspaceId }),
label: 'settings',
title: 'Yaak Settings',
});
},
},
{
key: 'app.create',
label: 'Create Workspace',
onSelect: createWorkspace.mutate,
},
{
key: 'http_request.create',
label: 'Create HTTP Request',
onSelect: () => createHttpRequest.mutate({}),
},
{
key: 'cookies.show',
label: 'Show Cookies',
onSelect: async () => {
dialog.show({
id: 'cookies',
title: 'Manage Cookies',
size: 'full',
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
});
},
},
{
key: 'grpc_request.create',
label: 'Create GRPC Request',
onSelect: () => createGrpcRequest.mutate({}),
},
{
key: 'environment.edit',
label: 'Edit Environment',
action: 'environmentEditor.toggle',
onSelect: () => {
dialog.toggle({
id: 'environment-editor',
noPadding: true,
size: 'lg',
className: 'h-[80vh]',
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
});
},
},
{
key: 'environment.create',
label: 'Create Environment',
onSelect: createEnvironment.mutate,
},
{
key: 'sidebar.toggle',
label: 'Toggle Sidebar',
action: 'sidebar.focus',
onSelect: () => setSidebarHidden((h) => !h),
},
];
return commands.sort((a, b) =>
('searchText' in a ? a.searchText : a.label).localeCompare(
'searchText' in b ? b.searchText : b.label,
),
);
}, [
activeCookieJar,
activeEnvironment,
createEnvironment.mutate,
createGrpcRequest,
createHttpRequest,
createWorkspace.mutate,
dialog,
routes.paths,
setSidebarHidden,
workspaceId,
]);
const sortedRequests = useMemo(() => {
return [...requests].sort((a, b) => {
const aRecentIndex = recentRequests.indexOf(a.id);
const bRecentIndex = recentRequests.indexOf(b.id);
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
return aRecentIndex - bRecentIndex;
} else if (aRecentIndex >= 0 && bRecentIndex === -1) {
return -1;
} else if (aRecentIndex === -1 && bRecentIndex >= 0) {
return 1;
} else {
return a.createdAt.localeCompare(b.createdAt);
}
});
}, [recentRequests, requests]);
const sortedEnvironments = useMemo(() => {
return [...environments].sort((a, b) => {
const aRecentIndex = recentEnvironments.indexOf(a.id);
const bRecentIndex = recentEnvironments.indexOf(b.id);
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
return aRecentIndex - bRecentIndex;
} else if (aRecentIndex >= 0 && bRecentIndex === -1) {
return -1;
} else if (aRecentIndex === -1 && bRecentIndex >= 0) {
return 1;
} else {
return a.createdAt.localeCompare(b.createdAt);
}
});
}, [environments, recentEnvironments]);
const sortedWorkspaces = useMemo(() => {
return [...workspaces].sort((a, b) => {
const aRecentIndex = recentWorkspaces.indexOf(a.id);
const bRecentIndex = recentWorkspaces.indexOf(b.id);
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
return aRecentIndex - bRecentIndex;
} else if (aRecentIndex >= 0 && bRecentIndex === -1) {
return -1;
} else if (aRecentIndex === -1 && bRecentIndex >= 0) {
return 1;
} else {
return a.createdAt.localeCompare(b.createdAt);
}
});
}, [recentWorkspaces, workspaces]);
const groups = useMemo<CommandPaletteGroup[]>(() => {
const actionsGroup: CommandPaletteGroup = {
key: 'actions',
label: 'Actions',
items: workspaceCommands,
};
const requestGroup: CommandPaletteGroup = {
key: 'requests',
label: 'Requests',
items: [],
};
for (const r of sortedRequests) {
if (r.id === activeRequestId) {
continue;
}
requestGroup.items.push({
key: `switch-request-${r.id}`,
label: `Switch Request → ${fallbackRequestName(r)}`,
searchText: fallbackRequestName(r),
label: (
<HStack space={2}>
<HttpMethodTag className="text-fg-subtler" request={r} />
<div className="truncate">{fallbackRequestName(r)}</div>
</HStack>
),
onSelect: () => {
return routes.navigate('request', {
workspaceId: r.workspaceId,
@@ -32,25 +247,79 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
},
});
}
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,
});
},
const environmentGroup: CommandPaletteGroup = {
key: 'environments',
label: 'Environments',
items: [],
};
for (const e of sortedEnvironments) {
if (e.id === activeEnvironment?.id) {
continue;
}
environmentGroup.items.push({
key: `switch-environment-${e.id}`,
label: e.name,
onSelect: () => routes.setEnvironment(e),
});
}
return items;
}, [activeEnvironmentId, requests, routes, workspaces]);
const filteredItems = useMemo(() => {
return items.filter((v) => v.label.toLowerCase().includes(command.toLowerCase()));
}, [command, items]);
const workspaceGroup: CommandPaletteGroup = {
key: 'workspaces',
label: 'Workspaces',
items: [],
};
for (const w of sortedWorkspaces) {
if (w.id === active) {
continue;
}
workspaceGroup.items.push({
key: `switch-workspace-${w.id}`,
label: w.name,
onSelect: () => openWorkspace.mutate({ workspace: w, inNewWindow: false }),
});
}
return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];
}, [
workspaceCommands,
sortedRequests,
activeRequestId,
routes,
activeEnvironmentId,
sortedEnvironments,
activeEnvironment?.id,
sortedWorkspaces,
active,
openWorkspace,
]);
const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
useEffect(() => {
setSelectedItemKey(null);
}, [command]);
const { filteredGroups, filteredAllItems } = useMemo(() => {
const result = command
? search(command, allItems, {
threshold: 0.5,
keySelector: (v) => ('searchText' in v ? v.searchText : v.label),
})
: allItems;
const filteredGroups = groups
.map((g) => {
g.items = result.filter((i) => g.items.includes(i)).slice(0, MAX_PER_GROUP);
return g;
})
.filter((g) => g.items.length > 0);
const filteredAllItems = filteredGroups.flatMap((g) => g.items);
return { filteredAllItems, filteredGroups };
}, [allItems, command, groups]);
const handleSelectAndClose = useCallback(
(cb: () => void) => {
@@ -60,44 +329,73 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
[onClose],
);
const selectedItem = useMemo(() => {
let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;
if (selectedItem == null) {
selectedItem = filteredAllItems[0] ?? null;
}
return selectedItem;
}, [filteredAllItems, selectedItemKey]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
setSelectedIndex((prev) => prev + 1);
} else if (e.key === 'ArrowUp') {
setSelectedIndex((prev) => prev - 1);
(e: KeyboardEvent<HTMLInputElement>) => {
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
setSelectedItemKey(next?.key ?? null);
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
setSelectedItemKey(prev?.key ?? null);
} else if (e.key === 'Enter') {
const item = filteredItems[selectedIndex];
if (item) {
handleSelectAndClose(item.onSelect);
const selected = filteredAllItems[index];
setSelectedItemKey(selected?.key ?? null);
if (selected) {
handleSelectAndClose(selected.onSelect);
}
}
},
[filteredItems, handleSelectAndClose, selectedIndex],
[filteredAllItems, handleSelectAndClose, selectedItem?.key],
);
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div className="h-full w-[400px] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
<div className="px-2 py-2 w-full">
<Input
<PlainInput
hideLabel
leftSlot={
<div className="h-md w-10 flex justify-center items-center">
<Icon icon="search" className="text-fg-subtle" />
</div>
}
name="command"
label="Command"
placeholder="Type a command"
defaultValue=""
placeholder="Search or type a command"
className="font-sans !text-base"
defaultValue={command}
onChange={setCommand}
onKeyDown={handleKeyDown}
onKeyDownCapture={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 className="h-full px-1.5 overflow-y-auto pb-1">
{filteredGroups.map((g) => (
<div key={g.key} className="mb-1.5 w-full">
<Heading size={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
{g.label}
</Heading>
{g.items.map((v) => (
<CommandPaletteItem
active={v.key === selectedItem?.key}
key={v.key}
onClick={() => handleSelectAndClose(v.onSelect)}
rightSlot={
v.action && <CommandPaletteAction action={v.action} onAction={v.onSelect} />
}
>
{v.label}
</CommandPaletteItem>
))}
</div>
))}
</div>
</div>
@@ -108,20 +406,39 @@ function CommandPaletteItem({
children,
active,
onClick,
rightSlot,
}: {
children: ReactNode;
active: boolean;
onClick: () => void;
rightSlot?: ReactNode;
}) {
return (
<button
<Button
onClick={onClick}
tabIndex={active ? undefined : -1}
rightSlot={rightSlot}
color="custom"
justify="start"
className={classNames(
'w-full h-xs flex items-center rounded px-1.5 text-fg-subtle',
'w-full h-sm flex items-center rounded px-1.5',
'hover:text-fg',
active && 'bg-background-highlight-secondary text-fg',
!active && 'text-fg-subtle',
)}
>
{children}
</button>
<span className="truncate">{children}</span>
</Button>
);
}
function CommandPaletteAction({
action,
onAction,
}: {
action: HotkeyAction;
onAction: () => void;
}) {
useHotKey(action, onAction);
return <HotKey className="ml-auto" action={action} />;
}

View File

@@ -21,7 +21,7 @@ import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
@@ -185,18 +185,20 @@ const EnvironmentEditor = function ({
/>
</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}
onChange={handleChange}
/>
<div className="h-full pr-2 pb-2">
<PairOrBulkEditor
preferenceName="environment"
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}
onChange={handleChange}
/>
</div>
</VStack>
);
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react';
import type { HttpRequest } from '../lib/models';
import type { Pair, PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
type Props = {
forceUpdateKey: string;
@@ -27,7 +27,8 @@ export function FormUrlencodedEditor({ body, forceUpdateKey, onChange }: Props)
);
return (
<PairEditor
<PairOrBulkEditor
preferenceName="form_urlencoded"
valueAutocompleteVariables
nameAutocompleteVariables
namePlaceholder="entry_name"

View File

@@ -6,7 +6,6 @@ import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { environmentsQueryKey } from '../hooks/useEnvironments';
import { foldersQueryKey } from '../hooks/useFolders';
import { useGlobalCommands } from '../hooks/useGlobalCommands';
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
@@ -42,7 +41,6 @@ export function GlobalHooks() {
// Other useful things
useSyncThemeToDocument();
useGlobalCommands();
useCommandPalette();
useNotificationToast();

View File

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

View File

@@ -111,7 +111,7 @@ export function GrpcConnectionLayout({ style }: Props) {
) : messages.length >= 0 ? (
<GrpcConnectionMessagesPane activeRequest={activeRequest} methodType={methodType} />
) : (
<HotKeyList hotkeys={['grpc_request.send', 'sidebar.toggle', 'urlBar.focus']} />
<HotKeyList hotkeys={['grpc_request.send', 'sidebar.focus', 'urlBar.focus']} />
)}
</div>
)

View File

@@ -6,7 +6,7 @@ import { mimeTypes } from '../lib/data/mimetypes';
import type { HttpRequest } from '../lib/models';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
type Props = {
forceUpdateKey: string;
@@ -16,7 +16,8 @@ type Props = {
export function HeadersEditor({ headers, onChange, forceUpdateKey }: Props) {
return (
<PairEditor
<PairOrBulkEditor
preferenceName="headers"
valueAutocompleteVariables
nameAutocompleteVariables
pairs={headers}

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useSettings } from '../hooks/useSettings';
import { useUpdateSettings } from '../hooks/useUpdateSettings';
import type { Workspace } from '../lib/models';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { HStack, VStack } from './core/Stacks';
interface Props {
hide: () => void;
workspace: Workspace;
}
export function OpenWorkspaceDialog({ hide, workspace }: Props) {
const openWorkspace = useOpenWorkspace();
const settings = useSettings();
const updateSettings = useUpdateSettings();
const [remember, setRemember] = useState<boolean>(false);
return (
<VStack space={3}>
<p>
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
</p>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={() => {
hide();
openWorkspace.mutate({ workspace, inNewWindow: false });
if (remember) {
updateSettings.mutate({ openWorkspaceNewWindow: false });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="externalLink" />}
onClick={() => {
hide();
openWorkspace.mutate({ workspace, inNewWindow: true });
if (remember) {
updateSettings.mutate({ openWorkspaceNewWindow: true });
}
}}
>
New Window
</Button>
</HStack>
{settings && (
<HStack justifyContent="end">
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
</HStack>
)}
</VStack>
);
}

View File

@@ -47,13 +47,13 @@ export function Overlay({
variant === 'default' && 'bg-background-backdrop backdrop-blur-sm',
)}
/>
{children}
{/* Show draggable region at the top */}
{/* TODO: Figure out tauri drag region and also make clickable still */}
{variant === 'default' && (
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
)}
{children}
</motion.div>
</FocusTrap>
)}

View File

@@ -53,7 +53,7 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
key: request.id,
label: fallbackRequestName(request),
// leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length} />,
leftSlot: <HttpMethodTag request={request} />,
leftSlot: <HttpMethodTag className="text-right" shortNames request={request} />,
onSelect: () => {
routes.navigate('request', {
requestId: request.id,

View File

@@ -1,6 +1,7 @@
import classNames from 'classnames';
import { useDeleteHttpResponse } from '../hooks/useDeleteHttpResponse';
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses';
import { useSaveResponse } from '../hooks/useSaveResponse';
import type { HttpResponse } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import { Dropdown } from './core/Dropdown';
@@ -25,30 +26,49 @@ export const RecentResponsesDropdown = function ResponsePane({
const deleteResponse = useDeleteHttpResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const latestResponseId = responses[0]?.id ?? 'n/a';
const saveResponse = useSaveResponse(activeResponse);
return (
<Dropdown
items={[
{
key: 'save',
label: 'Save to File',
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{
key: 'clear-single',
label: 'Clear Response',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
key: 'unpin',
label: 'Unpin Response',
onSelect: () => onPinnedResponseId(activeResponse.id),
leftSlot: <Icon icon="unpin" />,
hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
{
key: 'clear-all',
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
label: `Delete ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length <= 1,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
{ type: 'separator' },
...responses.slice(0, 20).map((r: HttpResponse) => ({
key: r.id,
label: (
<HStack space={2}>
<StatusTag className="text-sm" response={r} />
<span>&rarr;</span>{' '}
<span className="text-fg-subtle">&rarr;</span>{' '}
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : 'n/a'}</span>
</HStack>
),

View File

@@ -262,13 +262,15 @@ export const RequestPane = memo(function RequestPane({
options:
requests.length > 0
? [
...requests.map(
(r) =>
({
type: 'constant',
label: r.url,
} as GenericCompletionOption),
),
...requests
.filter((r) => r.id !== activeRequestId)
.map(
(r) =>
({
type: 'constant',
label: r.url,
} as GenericCompletionOption),
),
]
: [
{ label: 'http://', type: 'constant' },

View File

@@ -5,6 +5,7 @@ import { createGlobalState } from 'react-use';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { isBinaryContentType } from '../lib/data/mimetypes';
import type { HttpRequest } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { Banner } from './core/Banner';
@@ -21,8 +22,10 @@ import { EmptyStateText } from './EmptyStateText';
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
import { ResponseHeaders } from './ResponseHeaders';
import { AudioViewer } from './responseViewers/AudioViewer';
import { BinaryViewer } from './responseViewers/BinaryViewer';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { PdfViewer } from './responseViewers/PdfViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
@@ -83,7 +86,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
>
{activeResponse == null ? (
<HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.toggle', 'urlBar.focus']}
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'urlBar.focus']}
/>
) : isResponseLoading(activeResponse) ? (
<div className="h-full w-full flex items-center justify-center">
@@ -158,12 +161,16 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
<AudioViewer response={activeResponse} />
) : contentType?.startsWith('video') ? (
<VideoViewer response={activeResponse} />
) : contentType?.match(/pdf/) ? (
<PdfViewer response={activeResponse} />
) : isBinaryContentType(contentType) ? (
<BinaryViewer response={activeResponse} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : activeResponse.contentLength > 2 * 1000 * 1000 ? (
<EmptyStateText>Cannot preview text responses larger than 2MB</EmptyStateText>
) : viewMode === 'pretty' && contentType?.includes('html') ? (
<WebPageViewer response={activeResponse} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<TextViewer
className="-mr-2" // Pull to the right

View File

@@ -50,6 +50,33 @@ export function SettingsGeneral() {
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
<Select
name="openWorkspace"
label="Open Workspace"
labelPosition="left"
size="sm"
value={
settings.openWorkspaceNewWindow === true
? 'new'
: settings.openWorkspaceNewWindow === false
? 'current'
: 'ask'
}
onChange={(v) => {
if (v === 'current') {
updateSettings.mutate({ openWorkspaceNewWindow: false });
} else if (v === 'new') {
updateSettings.mutate({ openWorkspaceNewWindow: true });
} else {
updateSettings.mutate({ openWorkspaceNewWindow: null });
}
}}
options={[
{ label: 'Always Ask', value: 'ask' },
{ label: 'Current Window', value: 'current' },
{ label: 'New Window', value: 'new' },
]}
/>
<Separator className="my-4" />
<Heading size={2}>

View File

@@ -101,7 +101,7 @@ export function Sidebar({ className }: Props) {
[collapsed.value],
);
const { tree, treeParentMap, selectableRequests, selectedRequest } = useMemo<{
const { tree, treeParentMap, selectableRequests } = useMemo<{
tree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
selectedRequest: HttpRequest | GrpcRequest | null;
@@ -159,8 +159,6 @@ export function Sidebar({ className }: Props) {
return { tree, treeParentMap, selectableRequests, selectedRequest };
}, [activeWorkspace, selectedId, requests, folders, collapsed.value]);
const deleteSelectedRequest = useDeleteRequest(selectedRequest);
const focusActiveRequest = useCallback(
(
args: {
@@ -192,7 +190,7 @@ export function Sidebar({ className }: Props) {
);
const handleSelect = useCallback(
async (id: string) => {
async (id: string, opts: { noFocus?: boolean } = {}) => {
const tree = treeParentMap[id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const node = children.find((m) => m.item.id === id) ?? null;
@@ -212,7 +210,7 @@ export function Sidebar({ className }: Props) {
});
setSelectedId(id);
setSelectedTree(tree);
focusActiveRequest({ forced: { id, tree } });
if (!opts.noFocus) focusActiveRequest({ forced: { id, tree } });
}
},
[treeParentMap, collapsed, routes, activeEnvironmentId, focusActiveRequest],
@@ -230,21 +228,6 @@ export function Sidebar({ className }: Props) {
const handleBlur = useCallback(() => setHasFocus(false), []);
const handleDeleteKey = useCallback(
async (e: KeyboardEvent) => {
if (!hasFocus) return;
e.preventDefault();
const selected = selectableRequests.find((r) => r.id === selectedId);
if (selected == null) return;
await deleteSelectedRequest.mutateAsync();
},
[hasFocus, selectableRequests, deleteSelectedRequest, selectedId],
);
useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey);
useHotKey('sidebar.focus', async () => {
// Hide the sidebar if it's already focused
if (!hidden && hasFocus) {
@@ -620,7 +603,7 @@ const SidebarItem = forwardRef(function SidebarItem(
) {
const activeRequest = useActiveRequest();
const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(activeRequest ?? null);
const deleteRequest = useDeleteRequest(itemId);
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
const copyAsCurl = useCopyAsCurl(itemId);
@@ -839,7 +822,7 @@ const SidebarItem = forwardRef(function SidebarItem(
)}
/>
)}
<div className="flex items-end gap-2 min-w-0">
<div className="flex items-center gap-2 min-w-0">
{itemPrefix}
{editing ? (
<input

View File

@@ -31,7 +31,6 @@ export function SidebarActions() {
className="pointer-events-auto"
size="sm"
title="Show sidebar"
hotkeyAction="sidebar.toggle"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
<CreateDropdown hotKeyAction="http_request.create">

View File

@@ -1,5 +1,5 @@
import type { HttpRequest } from '../lib/models';
import { PairEditor } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
type Props = {
forceUpdateKey: string;
@@ -9,7 +9,8 @@ type Props = {
export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange }: Props) {
return (
<PairEditor
<PairOrBulkEditor
preferenceName="url_parameters"
valueAutocompleteVariables
nameAutocompleteVariables
namePlaceholder="param_name"

View File

@@ -186,7 +186,7 @@ export default function Workspace() {
</div>
) : activeRequest == null ? (
<HotKeyList
hotkeys={['http_request.create', 'sidebar.toggle', 'settings.show']}
hotkeys={['http_request.create', 'sidebar.focus', 'settings.show']}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">
<Button variant="border" size="sm" onClick={() => importData.mutate()}>

View File

@@ -1,13 +1,11 @@
import { invoke } from '@tauri-apps/api/core';
import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCommand } from '../hooks/useCommands';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { getRecentRequests } from '../hooks/useRecentRequests';
import { useSettings } from '../hooks/useSettings';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import type { ButtonProps } from './core/Button';
@@ -16,8 +14,8 @@ import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { HStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { OpenWorkspaceDialog } from './OpenWorkspaceDialog';
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown' | 'leftSlot'>;
@@ -30,10 +28,12 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const activeWorkspaceId = activeWorkspace?.id ?? null;
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const createWorkspace = useCommand('workspace.create');
const createWorkspace = useCreateWorkspace();
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const settings = useSettings();
const openWorkspace = useOpenWorkspace();
const openWorkspaceNewWindow = settings?.openWorkspaceNewWindow ?? null;
const items: DropdownItem[] = useMemo(() => {
const workspaceItems: DropdownItem[] = workspaces.map((w) => ({
@@ -41,58 +41,16 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
label: w.name,
leftSlot: w.id === activeWorkspaceId ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: async () => {
if (typeof openWorkspaceNewWindow === 'boolean') {
openWorkspace.mutate({ workspace: w, inNewWindow: openWorkspaceNewWindow });
return;
}
dialog.show({
id: 'open-workspace',
size: 'sm',
title: 'Open Workspace',
description: (
<>
Where would you like to open <InlineCode>{w.name}</InlineCode>?
</>
),
render: ({ hide }) => {
return (
<HStack space={2} justifyContent="start" className="mt-4 mb-6 flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
const requestId = (await getRecentRequests(w.id))[0];
if (requestId != null) {
routes.navigate('request', { workspaceId: w.id, environmentId, requestId });
} else {
routes.navigate('workspace', { workspaceId: w.id, environmentId });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="externalLink" />}
onClick={async () => {
hide();
const environmentId = (await getRecentEnvironments(w.id))[0];
const requestId = (await getRecentRequests(w.id))[0];
const path =
requestId != null
? routes.paths.request({
workspaceId: w.id,
environmentId,
requestId,
})
: routes.paths.workspace({ workspaceId: w.id, environmentId });
await invoke('cmd_new_window', { url: path });
}}
>
New Window
</Button>
</HStack>
);
},
render: ({ hide }) => <OpenWorkspaceDialog workspace={w} hide={hide} />,
});
},
}));
@@ -143,7 +101,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
key: 'create-workspace',
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: () => createWorkspace.mutate({}),
onSelect: createWorkspace.mutate,
},
];
}, [
@@ -152,8 +110,9 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
createWorkspace,
deleteWorkspace.mutate,
dialog,
openWorkspace,
prompt,
routes,
openWorkspaceNewWindow,
updateWorkspace,
workspaces,
]);

View File

@@ -0,0 +1,48 @@
import { useCallback, useMemo } from 'react';
import { Editor } from './Editor';
import type { PairEditorProps } from './PairEditor';
type Props = Pick<
PairEditorProps,
'onChange' | 'pairs' | 'namePlaceholder' | 'valuePlaceholder'
> & {
foo?: string;
};
export function BulkPairEditor({ pairs, onChange, namePlaceholder, valuePlaceholder }: Props) {
const pairsText = useMemo(() => {
return pairs
.filter((p) => !(p.name.trim() === '' && p.value.trim() === ''))
.map((p) => `${p.name}: ${p.value}`)
.join('\n');
}, [pairs]);
const handleChange = useCallback(
(text: string) => {
const pairs = text
.split('\n')
.filter((l: string) => l.trim())
.map(lineToPair);
onChange(pairs);
},
[onChange],
);
return (
<Editor
placeholder={`${namePlaceholder ?? 'name'}: ${valuePlaceholder ?? 'value'}`}
defaultValue={pairsText}
contentType="pairs"
onChange={handleChange}
/>
);
}
function lineToPair(l: string): PairEditorProps['pairs'][0] {
const [name, ...values] = l.split(':');
const pair: PairEditorProps['pairs'][0] = {
name: (name ?? '').trim(),
value: values.join(':').trim(),
};
return pair;
}

View File

@@ -18,6 +18,7 @@ export interface DialogProps {
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
vAlign?: 'top' | 'center';
}
export function Dialog({
@@ -31,6 +32,7 @@ export function Dialog({
hideX,
noPadding,
noScroll,
vAlign = 'center',
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
@@ -50,13 +52,14 @@ export function Dialog({
return (
<Overlay open={open} onClose={onClose} portalName="dialog">
<div className="x-theme-dialog absolute inset-0 flex items-center justify-center pointer-events-none">
<div
role="dialog"
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="pointer-events-auto"
>
<div
className={classNames(
'x-theme-dialog absolute inset-0 flex flex-col items-center pointer-events-none',
vAlign === 'top' && 'justify-start',
vAlign === 'center' && 'justify-center',
)}
>
<div role="dialog" aria-labelledby={titleId} aria-describedby={descriptionId}>
<motion.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
@@ -66,12 +69,12 @@ export function Dialog({
'relative bg-background pointer-events-auto',
'rounded-lg',
'border border-background-highlight-secondary shadow-lg shadow-[rgba(0,0,0,0.1)]',
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-6rem)]',
size === 'sm' && 'w-[25rem] max-h-[80vh]',
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-4rem)]',
size === 'sm' && 'w-[28rem] max-h-[80vh]',
size === 'md' && 'w-[45rem] max-h-[80vh]',
size === 'lg' && 'w-[65rem] max-h-[80vh]',
size === 'full' && 'w-[100vw] h-[100vh]',
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
size === 'dynamic' && 'min-w-[20rem] max-w-[100vw] w-full mt-8',
)}
>
{title ? (

View File

@@ -35,6 +35,7 @@ import { HStack, VStack } from './Stacks';
export type DropdownItemSeparator = {
type: 'separator';
label?: string;
hidden?: boolean;
};
export type DropdownItemDefault = {
@@ -273,7 +274,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
let nextIndex = (currIndex ?? 0) - 1;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.type === 'separator') {
if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') {
nextIndex--;
} else if (nextIndex < 0) {
nextIndex = items.length - 1;
@@ -290,7 +291,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
let nextIndex = (currIndex ?? -1) + 1;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.type === 'separator') {
if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') {
nextIndex++;
} else if (nextIndex >= items.length) {
nextIndex = 0;
@@ -373,6 +374,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
right: onRight ? docRect.width - triggerShape.right : undefined,
left: !onRight ? triggerShape.left : undefined,
minWidth: fullWidth ? triggerWidth : undefined,
maxWidth: '40rem',
};
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
const triangleStyles = onRight
@@ -455,6 +457,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
<span className="text-fg-subtler text-center px-2 py-1">No matches</span>
)}
{filteredItems.map((item, i) => {
if (item.hidden) {
return null;
}
if (item.type === 'separator') {
return (
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
@@ -462,9 +467,6 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
</Separator>
);
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
@@ -537,20 +539,12 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
'h-xs', // More compact
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
'focus:bg-background-highlight focus:text-fg rounded',
item.variant === 'default' && 'text-fg-subtle',
item.variant === 'danger' && 'text-fg-danger',
item.variant === 'notify' && 'text-fg-primary',
item.variant === 'danger' && '!text-fg-danger',
item.variant === 'notify' && '!text-fg-primary',
)}
{...props}
>
<div
className={classNames(
// Add padding on right when no right slot, for some visual balance
!item.rightSlot && 'pr-4',
)}
>
{item.label}
</div>
<div className={classNames('truncate')}>{item.label}</div>
</Button>
);
}

View File

@@ -102,7 +102,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
// Use ref so we can update the handler without re-initializing the editor
const handleChange = useRef<EditorProps['onChange']>(onChange);
useEffect(() => {
handleChange.current = onChange;
handleChange.current = onChange ? onChange : onChange;
}, [onChange]);
// Use ref so we can update the handler without re-initializing the editor
@@ -332,6 +332,20 @@ function getExtensions({
return [
...baseExtensions, // Must be first
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
},
blur: () => {
onBlur.current?.();
},
keydown: (e) => {
onKeyDown.current?.(e);
},
paste: (e) => {
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
},
}),
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
@@ -349,21 +363,6 @@ function getExtensions({
onChange.current?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
},
blur: () => {
onBlur.current?.();
},
keydown: (e) => {
onKeyDown.current?.(e);
},
paste: (e) => {
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
},
}),
];
}

View File

@@ -35,6 +35,7 @@ import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import { EditorView } from 'codemirror';
import type { Environment, Workspace } from '../../../lib/models';
import type { EditorProps } from './index';
import { pairs } from './pairs/extension';
import { text } from './text/extension';
import { twig } from './twig/extension';
import { url } from './url/extension';
@@ -71,6 +72,7 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
'application/xml': xml(),
'text/xml': xml(),
url: url(),
pairs: pairs(),
};
export function getLanguageExtension({

View File

@@ -0,0 +1,11 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './pairs';
const urlLanguage = LRLanguage.define({
parser,
languageData: {},
});
export function pairs() {
return new LanguageSupport(urlLanguage, []);
}

View File

@@ -0,0 +1,7 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
Sep: t.bracket,
Key: t.attributeName,
Value: t.string,
});

View File

@@ -0,0 +1,9 @@
@top pairs { (Key? Sep Value)* }
@tokens {
Sep { ":" }
Key { ![:]+ }
Value { ![\n]+ }
}
@external propSource highlight from "./highlight"

View File

@@ -0,0 +1,19 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "!QQQOPOOOYOQO'#CaO_OPO'#CaQQOPOOOOOO,58{,58{OdOQO,58{OOOO-E6_-E6_OOOO1G.g1G.g",
stateData: "i~OQQORPO~OSSO~ORTO~OSVO~O",
goto: "]UPPPPPVQRORUR",
nodeNames: "⚠ pairs Key Sep Value",
maxTerm: 6,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "#oRRVOYhYZ!UZ![h![!]#[!];'Sh;'S;=`#U<%lOhRoVQPSQOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!UQ!rSSQOY!mZ;'S!m;'S;=`#O<%lO!mQ#RP;=`<%l!mR#XP;=`<%lhR#cSRPSQOY!mZ;'S!m;'S;=`#O<%lO!m",
tokenizers: [0, 1],
topRules: {"pairs":[0,1]},
tokenPrec: 0
})

View File

@@ -9,7 +9,7 @@ export function FormattedError({ children }: Props) {
return (
<pre
className={classNames(
'w-full select-auto cursor-text bg-background-highlight-secondary p-3 rounded',
'font-mono text-sm w-full select-auto cursor-text bg-background-highlight-secondary p-3 rounded',
'whitespace-pre-wrap border border-fg-danger border-dashed overflow-x-auto',
)}
>

View File

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

View File

@@ -1,26 +1,27 @@
import React from 'react';
import classNames from 'classnames';
import React, { Fragment } from 'react';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { HotKey } from './HotKey';
import { HotKeyLabel } from './HotKeyLabel';
import { HStack, VStack } from './Stacks';
interface Props {
hotkeys: HotkeyAction[];
bottomSlot?: React.ReactNode;
className?: string;
}
export const HotKeyList = ({ hotkeys, bottomSlot }: Props) => {
export const HotKeyList = ({ hotkeys, bottomSlot, className }: Props) => {
return (
<div className="h-full flex items-center justify-center">
<VStack space={2}>
<div className={classNames(className, 'h-full flex items-center justify-center')}>
<div className="px-4 grid gap-2 grid-cols-[auto_auto]">
{hotkeys.map((hotkey) => (
<HStack key={hotkey} className="grid grid-cols-2">
<HotKeyLabel action={hotkey} />
<HotKey className="ml-auto" action={hotkey} />
</HStack>
<Fragment key={hotkey}>
<HotKeyLabel className="truncate" action={hotkey} />
<HotKey className="ml-4" action={hotkey} />
</Fragment>
))}
{bottomSlot}
</VStack>
</div>
</div>
);
};

View File

@@ -4,9 +4,10 @@ import type { GrpcRequest, HttpRequest } from '../../lib/models';
interface Props {
request: HttpRequest | GrpcRequest;
className?: string;
shortNames?: boolean;
}
const methodMap: Record<string, string> = {
const longMethodMap = {
get: 'GET',
put: 'PUT',
post: 'POST',
@@ -15,9 +16,20 @@ const methodMap: Record<string, string> = {
options: 'OPTIONS',
head: 'HEAD',
grpc: 'GRPC',
} as const;
const shortMethodMap: Record<keyof typeof longMethodMap, string> = {
get: 'GET',
put: 'PUT',
post: 'POST',
patch: 'PTCH',
delete: 'DEL',
options: 'OPTS',
head: 'HEAD',
grpc: 'GRPC',
};
export function HttpMethodTag({ request, className }: Props) {
export function HttpMethodTag({ shortNames, request, className }: Props) {
const method =
request.model === 'http_request' && request.bodyType === 'graphql'
? 'GQL'
@@ -26,9 +38,17 @@ export function HttpMethodTag({ request, className }: Props) {
: request.method;
const m = method.toLowerCase();
const methodMap: Record<string, string> = shortNames ? shortMethodMap : longMethodMap;
return (
<span className={classNames(className, 'text-xs font-mono text-fg-subtle')}>
{methodMap[m] ?? m.slice(0, 3).toUpperCase()}
<span
className={classNames(
className,
'text-xs font-mono text-fg-subtle',
'pt-[0.25em]', // Fix for monospace font not vertically centering
shortNames && 'w-[2.5em]',
)}
>
{methodMap[m] ?? m.slice(0, 4).toUpperCase()}
</span>
);
}

View File

@@ -5,6 +5,9 @@ import { memo } from 'react';
const icons = {
alert: lucide.AlertTriangleIcon,
text: lucide.FileTextIcon,
table: lucide.TableIcon,
fileCode: lucide.FileCodeIcon,
archive: lucide.ArchiveIcon,
arrowBigDownDash: lucide.ArrowBigDownDashIcon,
arrowBigLeftDash: lucide.ArrowBigLeftDashIcon,
@@ -51,13 +54,15 @@ const icons = {
plusCircle: lucide.PlusCircleIcon,
question: lucide.ShieldQuestionIcon,
refresh: lucide.RefreshCwIcon,
save: lucide.SaveIcon,
search: lucide.SearchIcon,
sendHorizontal: lucide.SendHorizonalIcon,
settings2: lucide.Settings2Icon,
settings: lucide.SettingsIcon,
sparkles: lucide.SparklesIcon,
sun: lucide.SunIcon,
trash: lucide.TrashIcon,
trash: lucide.Trash2Icon,
unpin: lucide.PinOffIcon,
update: lucide.RefreshCcwIcon,
upload: lucide.UploadIcon,
x: lucide.XIcon,

View File

@@ -6,7 +6,7 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
<code
className={classNames(
className,
'font-mono text-shrink bg-background-highlight-secondary border border-background-highlight',
'font-mono text-shrink bg-background-highlight-secondary border border-background-highlight-secondary',
'px-1.5 py-0.5 rounded text-fg shadow-inner',
)}
{...props}

View File

@@ -1,7 +1,7 @@
import { open } from '@tauri-apps/plugin-dialog';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { v4 as uuid } from 'uuid';
@@ -49,7 +49,7 @@ type PairContainer = {
id: string;
};
export const PairEditor = memo(function PairEditor({
export function PairEditor({
className,
forceUpdateKey,
nameAutocomplete,
@@ -163,8 +163,8 @@ export const PairEditor = memo(function PairEditor({
<div
className={classNames(
className,
'@container',
'pb-2 mb-auto',
'@container relative',
'pb-2 mb-auto h-full',
!noScroll && 'overflow-y-auto max-h-full',
// Move over the width of the drag handle
'-ml-3',
@@ -204,7 +204,7 @@ export const PairEditor = memo(function PairEditor({
})}
</div>
);
});
}
enum ItemTypes {
ROW = 'pair-row',

View File

@@ -0,0 +1,37 @@
import classNames from 'classnames';
import { useKeyValue } from '../../hooks/useKeyValue';
import { BulkPairEditor } from './BulkPairEditor';
import { IconButton } from './IconButton';
import type { PairEditorProps } from './PairEditor';
import { PairEditor } from './PairEditor';
interface Props extends PairEditorProps {
preferenceName: string;
}
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
const { value: useBulk, set: setUseBulk } = useKeyValue<boolean>({
namespace: 'global',
key: ['bulk_edit', preferenceName],
fallback: false,
});
return (
<div className="relative h-full w-full group/wrapper">
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
<div className="absolute right-0 bottom-0">
<IconButton
size="sm"
variant="border"
title={useBulk ? 'Enable form edit' : 'Enable bulk edit'}
className={classNames(
'transition-opacity opacity-0 group-hover:opacity-80 hover:!opacity-100 shadow',
'bg-background text-fg-subtle hover:text-fg group-hover/wrapper:opacity-100',
)}
onClick={() => setUseBulk((b) => !b)}
icon={useBulk ? 'table' : 'fileCode'}
/>
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import type { InputProps } from './Input';
import { HStack } from './Stacks';
export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type'> & {
type: 'text' | 'password' | 'number';
type?: 'text' | 'password' | 'number';
step?: number;
};
@@ -54,7 +54,7 @@ export const PlainInput = forwardRef<HTMLInputElement, PlainInputProps>(function
const inputClassName = classNames(
className,
'!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder',
'px-1.5 text-xs font-mono',
'px-1.5 text-xs font-mono cursor-text',
);
const isValid = useMemo(() => {

View File

@@ -3,18 +3,19 @@ import type { ReactNode } from 'react';
interface Props {
orientation?: 'horizontal' | 'vertical';
variant?: 'primary' | 'secondary';
dashed?: boolean;
className?: string;
children?: ReactNode;
}
export function Separator({ className, orientation = 'horizontal', children }: Props) {
export function Separator({ className, dashed, orientation = 'horizontal', children }: Props) {
return (
<div role="separator" className={classNames(className, 'flex items-center')}>
{children && <div className="text-sm text-fg-subtler mr-2 whitespace-nowrap">{children}</div>}
<div
className={classNames(
'bg-background-highlight',
'h-0 border-t border-t-background-highlight',
dashed && 'border-dashed',
orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]',
)}

View File

@@ -0,0 +1,27 @@
import { useSaveResponse } from '../../hooks/useSaveResponse';
import type { HttpResponse } from '../../lib/models';
import { getContentTypeHeader } from '../../lib/models';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import { InlineCode } from '../core/InlineCode';
interface Props {
response: HttpResponse;
}
export function BinaryViewer({ response }: Props) {
const saveResponse = useSaveResponse(response);
const contentType = getContentTypeHeader(response.headers) ?? 'unknown';
return (
<Banner color="primary" className="h-full flex flex-col gap-3">
<p>
Content type <InlineCode>{contentType}</InlineCode> cannot be previewed
</p>
<div>
<Button variant="border" size="sm" onClick={() => saveResponse.mutate()}>
Save to File
</Button>
</div>
</Banner>
);
}

View File

@@ -0,0 +1,3 @@
.react-pdf__Document * {
@apply select-text;
}

View File

@@ -0,0 +1,61 @@
import useResizeObserver from '@react-hook/resize-observer';
import 'react-pdf/dist/Page/TextLayer.css';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import { convertFileSrc } from '@tauri-apps/api/core';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import React, { useRef, useState } from 'react';
import { Document, Page } from 'react-pdf';
import { useDebouncedState } from '../../hooks/useDebouncedState';
import type { HttpResponse } from '../../lib/models';
import './PdfViewer.css';
interface Props {
response: HttpResponse;
}
const options = {
cMapUrl: '/cmaps/',
standardFontDataUrl: '/standard_fonts/',
};
export function PdfViewer({ response }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useDebouncedState<number>(0, 100);
const [numPages, setNumPages] = useState<number>();
useResizeObserver(containerRef.current ?? null, (v) => {
setContainerWidth(v.contentRect.width);
});
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
};
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document
file={src}
options={options}
onLoadSuccess={onDocumentLoadSuccess}
externalLinkTarget="_blank"
externalLinkRel="noopener noreferrer"
>
{Array.from(new Array(numPages), (_, index) => (
<Page
className="mb-6 select-all"
renderTextLayer
renderAnnotationLayer
key={`page_${index + 1}`}
pageNumber={index + 1}
width={containerWidth}
/>
))}
</Document>
</div>
);
}

View File

@@ -35,21 +35,14 @@ export function TextViewer({ response, pretty, className }: Props) {
);
const contentType = useContentTypeFromHeaders(response.headers);
const rawBody = useResponseBodyText(response) ?? '';
const rawBody = useResponseBodyText(response) ?? null;
const isSearching = filterText != null;
const formattedBody =
pretty && contentType?.includes('json')
? tryFormatJson(rawBody)
: pretty && contentType?.includes('xml')
? tryFormatXml(rawBody)
: rawBody;
const filteredResponse = useFilterResponse({
filter: debouncedFilterText ?? '',
responseId: response.id,
});
const body = isSearching && filterText?.length > 0 ? filteredResponse : formattedBody;
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
@@ -102,7 +95,19 @@ export function TextViewer({ response, pretty, className }: Props) {
);
return result;
}, [canFilter, filterText, isJson, isSearching, setFilterText, toggleSearch]);
}, [canFilter, filterText, isJson, isSearching, response.id, setFilterText, toggleSearch]);
if (rawBody == null) {
return 'bad';
}
const formattedBody =
pretty && contentType?.includes('json')
? tryFormatJson(rawBody)
: pretty && contentType?.includes('xml')
? tryFormatXml(rawBody)
: rawBody;
const body = isSearching && filterText?.length > 0 ? filteredResponse : formattedBody;
return (
<Editor

View File

@@ -7,12 +7,22 @@ import { Alert } from './Alert';
export function useAlert() {
const dialog = useDialog();
return useCallback(
({ id, title, body }: { id: string; title: DialogProps['title']; body: AlertProps['body'] }) =>
({
id,
title,
body,
size = 'sm',
}: {
id: string;
title: DialogProps['title'];
body: AlertProps['body'];
size?: DialogProps['size'];
}) =>
dialog.show({
id,
title,
hideX: true,
size: 'sm',
size,
render: ({ hide }) => Alert({ onHide: hide, body }),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -1,21 +1,16 @@
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?.isDev) {
return;
}
dialog.toggle({
id: 'command_palette',
size: 'md',
size: 'dynamic',
hideX: true,
className: '!max-h-[min(30rem,calc(100vh-4rem))]',
vAlign: 'top',
noPadding: true,
noScroll: true,
render: ({ hide }) => <CommandPalette onClose={hide} />,

View File

@@ -1,41 +0,0 @@
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

@@ -14,7 +14,7 @@ export function useCreateHttpRequest() {
const routes = useAppRoutes();
return useMutation<HttpRequest, unknown, Partial<HttpRequest>>({
mutationFn: (patch) => {
mutationFn: (patch = {}) => {
if (workspaceId === null) {
throw new Error("Cannot create request when there's no active workspace");
}
@@ -28,7 +28,6 @@ export function useCreateHttpRequest() {
}
}
patch.folderId = patch.folderId || activeRequest?.folderId;
console.log('PATCH', patch);
return invoke('cmd_create_http_request', { request: { workspaceId, ...patch } });
},
onSettled: () => trackEvent('http_request', 'create'),

View File

@@ -0,0 +1,27 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';
import type { Workspace } from '../lib/models';
import { useAppRoutes } from './useAppRoutes';
import { usePrompt } from './usePrompt';
export function useCreateWorkspace() {
const routes = useAppRoutes();
const prompt = usePrompt();
return useMutation<Workspace, void, void>({
mutationFn: async () => {
const name = await prompt({
id: 'new-workspace',
name: 'name',
label: 'Name',
defaultValue: 'My Workspace',
title: 'New Workspace',
confirmLabel: 'Create',
placeholder: 'My Workspace',
});
return invoke('cmd_create_workspace', { name });
},
onSuccess: async (workspace) => {
routes.navigate('workspace', { workspaceId: workspace.id });
},
});
}

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