Compare commits

...

37 Commits

Author SHA1 Message Date
Gregory Schier
aeda72f13e Fix plugin (again) 2023-11-08 13:33:15 -08:00
Gregory Schier
83aa9041cb Bundle plugin in Git 2023-11-08 13:06:49 -08:00
Gregory Schier
d51913509d Move plugins back 2023-11-08 12:34:14 -08:00
Gregory Schier
5106f28ba5 Fix permissions 2023-11-08 12:02:17 -08:00
Gregory Schier
0c55c6eaab Bump version 2023-11-08 10:12:01 -08:00
Gregory Schier
b0edbd19c8 Tweak theme 2023-11-08 10:11:29 -08:00
Gregory Schier
7630db79b7 Tweak theme 2023-11-08 09:56:13 -08:00
Gregory Schier
55a7b82567 Track screen size, os, and version 2023-11-08 09:49:29 -08:00
Gregory Schier
b5cb46918a Rust analytics and a few tweaks 2023-11-08 09:13:32 -08:00
Gregory Schier
a793ece1a5 Add basic analytics 2023-11-07 09:53:59 -08:00
Gregory Schier
0f6e4b641a Cancel responses on startup 2023-11-06 13:06:15 -08:00
Gregory Schier
5ac5fab0c6 Send all in a folder 2023-11-06 10:53:39 -08:00
Gregory Schier
8030a8a235 Rework workspace header 2023-11-06 10:42:59 -08:00
Gregory Schier
d98426cad3 Fix sidebar focus max recursion 2023-11-06 10:40:02 -08:00
Gregory Schier
06034a8fc4 Save after formatting GraphQL (Closes #9) 2023-11-06 07:20:47 -08:00
Gregory Schier
1ee9f9bb51 Move plugins back to root dir 2023-11-06 07:18:53 -08:00
Gregory Schier
4b99d1405e Persist sidebar collapsed state (Closes #10) 2023-11-06 07:18:42 -08:00
Gregory Schier
8480e52195 Vite to bundle insomnia plugin 2023-11-05 22:13:22 -08:00
Gregory Schier
243e65a992 Better import conversion 2023-11-05 14:46:08 -08:00
Gregory Schier
b82304a233 Basic import of request body and bearer auth 2023-11-05 14:35:25 -08:00
Gregory Schier
f7a4ea9735 Recursive Insomnia import! 2023-11-05 13:33:23 -08:00
Gregory Schier
33d1a84ecd Remove numbers from recent requests 2023-11-04 16:19:21 -07:00
Gregory Schier
f4a071ee05 Fix drop marker indent 2023-11-04 11:16:41 -07:00
Gregory Schier
e26ba0f9d0 Folder actions 2023-11-04 10:48:18 -07:00
Gregory Schier
b4e2a12375 Folder chevron icon 2023-11-03 23:10:44 -07:00
Gregory Schier
5e7aacd31a Fix arrow navigation for nested sidebar 2023-11-03 17:29:33 -07:00
Gregory Schier
00718df49e Folder-based drag-n-drop complete! 2023-11-03 16:29:21 -07:00
Gregory Schier
bb9025ab07 Sidebar ordering 95% done! 2023-11-03 15:02:17 -07:00
Gregory Schier
867f3908ed Nested sidebar ordering almost working 2023-11-03 14:08:46 -07:00
Gregory Schier
30e1ecac39 Add folder model 2023-11-03 07:49:44 -07:00
Gregory Schier
7eb2abe9b2 Even better focus state 2023-11-02 22:23:21 -07:00
Gregory Schier
a5ac8fa035 Remove focus on env sidebar buttons 2023-11-02 22:18:13 -07:00
Gregory Schier
dd705de155 Rearrange menus 2023-11-02 21:41:35 -07:00
Gregory Schier
b15cdec701 Refactor environment edit dialog 2023-11-02 20:38:33 -07:00
Gregory Schier
a99a36b5cc Base environments fully working 2023-11-02 18:43:39 -07:00
Gregory Schier
e0b0e3d781 Initial "plugin" system with importer (#7) 2023-11-02 18:08:43 -07:00
Gregory Schier
98a4834d4f Fix cursor color in single-line mode 2023-10-31 15:10:33 -07:00
76 changed files with 3045 additions and 1110 deletions

View File

@@ -12,7 +12,7 @@ module.exports = {
parserOptions: {
project: ["./tsconfig.json"]
},
ignorePatterns: ["src-tauri/**/*"],
ignorePatterns: ["src-tauri/**/*", "plugins/**/*"],
settings: {
react: {
version: "detect"

View File

@@ -3,6 +3,8 @@ on:
push:
tags: [ v* ]
permissions: write-all
jobs:
build-artifacts:
strategy:

View File

@@ -1,7 +1,13 @@
.PHONY: sqlx-prepare, dev
.PHONY: sqlx-prepare, dev, migrate, build
sqlx-prepare:
cd src-tauri && cargo sqlx prepare --database-url 'sqlite://db.sqlite'
dev:
npm run tauri-dev
migrate:
cd src-tauri && cargo sqlx migrate run --database-url 'sqlite://db.sqlite?mode=rw'
build:
./node_modules/.bin/tauri build

Binary file not shown.

68
src-tauri/Cargo.lock generated
View File

@@ -133,6 +133,17 @@ dependencies = [
"critical-section",
]
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi 0.1.19",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.1.0"
@@ -509,6 +520,30 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "clap"
version = "3.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
dependencies = [
"atty",
"bitflags 1.3.2",
"clap_lex",
"indexmap 1.9.3",
"strsim",
"termcolor",
"textwrap",
]
[[package]]
name = "clap_lex"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
dependencies = [
"os_str_bytes",
]
[[package]]
name = "cobs"
version = "0.2.3"
@@ -1577,6 +1612,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "hermit-abi"
version = "0.3.3"
@@ -2392,7 +2436,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"hermit-abi 0.3.3",
"libc",
]
@@ -2561,6 +2605,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "os_str_bytes"
version = "6.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
[[package]]
name = "overload"
version = "0.1.1"
@@ -4012,6 +4062,7 @@ dependencies = [
"anyhow",
"base64 0.21.5",
"bytes",
"clap",
"cocoa 0.24.1",
"dirs-next",
"embed_plist",
@@ -4238,6 +4289,21 @@ dependencies = [
"utf-8",
]
[[package]]
name = "termcolor"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64"
dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d"
[[package]]
name = "thin-slice"
version = "0.1.1"

View File

@@ -29,7 +29,19 @@ reqwest = { version = "0.11.14", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
tauri = { version = "1.3", features = ["config-toml", "devtools", "fs-read-file", "os-all", "protocol-asset", "shell-open", "system-tray", "updater", "window-start-dragging"] }
tauri = { version = "1.3", features = [
"cli",
"config-toml",
"devtools",
"fs-read-file",
"os-all",
"protocol-asset",
"shell-open",
"system-tray",
"updater",
"window-start-dragging",
"dialog-open",
] }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tokio = { version = "1.25.0", features = ["sync"] }
uuid = "1.3.0"

View File

@@ -0,0 +1 @@
ALTER TABLE workspaces ADD COLUMN variables DEFAULT '[]' NOT NULL;

View File

@@ -0,0 +1,19 @@
CREATE TABLE folders
(
id TEXT NOT NULL
PRIMARY KEY,
model TEXT DEFAULT 'folder' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at DATETIME,
workspace_id TEXT NOT NULL
REFERENCES workspaces
ON DELETE CASCADE,
folder_id TEXT NULL
REFERENCES folders
ON DELETE CASCADE,
name TEXT NOT NULL,
sort_priority REAL DEFAULT 0 NOT NULL
);
ALTER TABLE http_requests ADD COLUMN folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE;

View File

@@ -0,0 +1,4 @@
export function greet() {
// Call Rust-provided fn!
sayHello('Plugin');
}

View File

@@ -1,3 +0,0 @@
export function hello() {
sayHello('Plugin');
}

View File

@@ -1,7 +1,7 @@
import { hello } from './hello.js';
import { greet } from './greet.js';
export function entrypoint() {
hello();
export function hello() {
greet();
console.log('Try JSON parse', JSON.parse(`{ "hello": 123 }`).hello);
console.log('Try RegExp', '123'.match(/[\d]+/));
}

View File

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

View File

@@ -0,0 +1,23 @@
export function isWorkspace(obj) {
return isJSObject(obj) && obj._type === 'workspace';
}
export function isRequestGroup(obj) {
return isJSObject(obj) && obj._type === 'request_group';
}
export function isRequest(obj) {
return isJSObject(obj) && obj._type === 'request';
}
export function isEnvironment(obj) {
return isJSObject(obj) && obj._type === 'environment';
}
export function isJSObject(obj) {
return Object.prototype.toString.call(obj) === '[object Object]';
}
export function isJSString(obj) {
return Object.prototype.toString.call(obj) === '[object String]';
}

View File

@@ -0,0 +1,18 @@
import { isJSString } from './types.js';
export function parseVariables(data) {
return Object.entries(data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`,
}));
}
/**
* Convert Insomnia syntax to Yaak syntax
* @param {string} variable - Text to convert
*/
export function convertSyntax(variable) {
if (!isJSString(variable)) return variable;
return variable.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, '${[$2]}');
}

View File

@@ -0,0 +1,21 @@
/**
* Import an Insomnia environment object.
* @param {Object} e - The environment object to import.
* @param workspaceId - Workspace to import into.
*/
export function importEnvironment(e, workspaceId) {
console.log('IMPORTING Environment', e._id, e.name, JSON.stringify(e, null, 2));
return {
id: e._id,
createdAt: new Date(e.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(e.updated ?? Date.now()).toISOString().replace('Z', ''),
workspaceId,
model: 'environment',
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`,
})),
};
}

View File

@@ -0,0 +1,17 @@
/**
* Import an Insomnia folder object.
* @param {Object} f - The environment object to import.
* @param workspaceId - Workspace to import into.
*/
export function importFolder(f, workspaceId) {
console.log('IMPORTING FOLDER', f._id, f.name, JSON.stringify(f, null, 2));
return {
id: f._id,
createdAt: new Date(f.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(f.updated ?? Date.now()).toISOString().replace('Z', ''),
folderId: f.parentId === workspaceId ? null : f.parentId,
workspaceId,
model: 'folder',
name: f.name,
};
}

View File

@@ -0,0 +1,58 @@
import { convertSyntax } from '../helpers/variables.js';
/**
* Import an Insomnia request object.
* @param {Object} r - The request object to import.
* @param workspaceId - The workspace ID to use for the request.
* @param {number} sortPriority - The sort priority to use for the request.
*/
export function importRequest(r, workspaceId, sortPriority = 0) {
console.log('IMPORTING REQUEST', r._id, r.name, JSON.stringify(r, null, 2));
let bodyType = null;
let body = null;
if (r.body?.mimeType === 'application/graphql') {
bodyType = 'graphql';
body = convertSyntax(r.body.text);
} else if (r.body?.mimeType === 'application/json') {
bodyType = 'application/json';
body = convertSyntax(r.body.text);
}
let authenticationType = null;
let authentication = {};
if (r.authentication.type === 'bearer') {
authenticationType = 'bearer';
authentication = {
token: convertSyntax(r.authentication.token),
};
} else if (r.authentication.type === 'basic') {
authenticationType = 'basic';
authentication = {
username: convertSyntax(r.authentication.username),
password: convertSyntax(r.authentication.password),
};
}
return {
id: r._id,
createdAt: new Date(r.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(r.updated ?? Date.now()).toISOString().replace('Z', ''),
workspaceId,
folderId: r.parentId === workspaceId ? null : r.parentId,
model: 'http_request',
sortPriority,
name: r.name,
url: convertSyntax(r.url),
body,
bodyType,
authentication,
authenticationType,
method: r.method,
headers: (r.headers ?? []).map(({ name, value, disabled }) => ({
enabled: !disabled,
name,
value,
})),
};
}

View File

@@ -0,0 +1,15 @@
/**
* Import an Insomnia workspace object.
* @param {Object} w - The workspace object to import.
*/
export function importWorkspace(w, variables) {
console.log('IMPORTING Workspace', w._id, w.name, JSON.stringify(w, null, 2));
return {
id: w._id,
createdAt: new Date(w.created ?? Date.now()).toISOString().replace('Z', ''),
updatedAt: new Date(w.updated ?? Date.now()).toISOString().replace('Z', ''),
model: 'workspace',
name: w.name,
variables,
};
}

View File

@@ -0,0 +1,78 @@
import { importEnvironment } from './importers/environment.js';
import { importRequest } from './importers/request.js';
import { importWorkspace } from './importers/workspace.js';
import {
isEnvironment,
isJSObject,
isRequest,
isRequestGroup,
isWorkspace,
} from './helpers/types.js';
import { parseVariables } from './helpers/variables.js';
import { importFolder } from './importers/folder.js';
export function pluginHookImport(contents) {
const parsed = JSON.parse(contents);
if (!isJSObject(parsed)) {
return;
}
const { _type, __export_format } = parsed;
if (_type !== 'export' || __export_format !== 4 || !Array.isArray(parsed.resources)) {
return;
}
const resources = {
workspaces: [],
requests: [],
environments: [],
folders: [],
};
// Import workspaces
const workspacesToImport = parsed.resources.filter(isWorkspace);
for (const workspaceToImport of workspacesToImport) {
console.log('IMPORTING WORKSPACE', workspaceToImport.name);
const baseEnvironment = parsed.resources.find(
(r) => isEnvironment(r) && r.parentId === workspaceToImport._id,
);
console.log('FOUND BASE ENV', baseEnvironment.name);
resources.workspaces.push(
importWorkspace(
workspaceToImport,
baseEnvironment ? parseVariables(baseEnvironment.data) : [],
),
);
console.log('IMPORTING ENVIRONMENTS', baseEnvironment.name);
const environmentsToImport = parsed.resources.filter(
(r) => isEnvironment(r) && r.parentId === baseEnvironment?._id,
);
console.log('FOUND', environmentsToImport.length, 'ENVIRONMENTS');
resources.environments.push(
...environmentsToImport.map((r) => importEnvironment(r, workspaceToImport._id)),
);
const nextFolder = (parentId) => {
const children = parsed.resources.filter((r) => r.parentId === parentId);
let sortPriority = 0;
for (const child of children) {
if (isRequestGroup(child)) {
resources.folders.push(importFolder(child, workspaceToImport._id));
nextFolder(child._id);
} else if (isRequest(child)) {
resources.requests.push(importRequest(child, workspaceToImport._id, sortPriority++));
}
}
};
// Import folders
nextFolder(workspaceToImport._id);
}
// Filter out any `null` values
resources.requests = resources.requests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return resources;
}

View File

@@ -0,0 +1,13 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.js'),
fileName: 'index',
formats: ['es'],
},
outDir: resolve(__dirname, 'out'),
},
});

View File

@@ -1,5 +1,15 @@
{
"db": "SQLite",
"02506ad41cc94cd937422ef1977a97174431f008a9fb4ce39667d587a858b876": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 5
}
},
"query": "\n INSERT INTO folders (\n id,\n workspace_id,\n folder_id,\n name,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n sort_priority = excluded.sort_priority\n "
},
"06aaf8f4a17566f1d25da2a60f0baf4b5fc28c3cf0c001a84e25edf9eab3c7e3": {
"describe": {
"columns": [
@@ -58,6 +68,126 @@
},
"query": "\n DELETE FROM http_responses\n WHERE id = ?\n "
},
"1428d25b6aa3d6ec55742a968571fa951da0406d7bb32408883c584eae7dd53c": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "folder_id",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 7,
"type_info": "Float"
}
],
"nullable": [
false,
false,
false,
false,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n folder_id,\n name,\n sort_priority\n FROM folders\n WHERE id = ?\n "
},
"1517b0f86c841b5f1247bd40c3a9b38ab001d846a410b6e3cd36f9e844d50ddb": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "folder_id",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 7,
"type_info": "Float"
}
],
"nullable": [
false,
false,
false,
false,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n folder_id,\n name,\n sort_priority\n FROM folders\n WHERE workspace_id = ?\n "
},
"26072725d536c3cfdffd9a681d17c0ee2f246ca98e0459630a2430236d3bbdd2": {
"describe": {
"columns": [
@@ -160,16 +290,6 @@
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE workspace_id = ?\n ORDER BY created_at DESC\n "
},
"3ec4710d28a7f38608c96798d971217ac97788bcb639089d0c5750c0d339bc9a": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"query": "\n UPDATE environments\n SET (name, variables, updated_at) = (?, ?, CURRENT_TIMESTAMP)\n WHERE id = ?;\n "
},
"448a1d1f1866ab42c0f81fcf8eb2930bf21dfdd43ca4831bc1a198cf45ac3732": {
"describe": {
"columns": [],
@@ -180,6 +300,60 @@
},
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
},
"5588db23df7f30dc75857e05395ebbcf2384e2ac0d7cb87f76d74c6d50781d7b": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"ordinal": 6,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "\n SELECT id, model, created_at, updated_at, name, description,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM workspaces\n "
},
"5aa070e61995f8b1724efaa94c5f0cef5a4be6efda5d70354ad449d7d4b5aee4": {
"describe": {
"columns": [
@@ -282,6 +456,16 @@
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE request_id = ?\n ORDER BY created_at DESC\n "
},
"610223ad10b6e25926d486ba775a74b55625fcc4e6637d8a805d44ec3f3b9532": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"query": "\n INSERT INTO workspaces (id, name, description, variables)\n VALUES (?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n description = excluded.description,\n variables = excluded.variables\n "
},
"62475fd9483fb5eda01c937949da2ef66ac7005b4be06b87aa6210d462348aca": {
"describe": {
"columns": [],
@@ -346,7 +530,17 @@
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM environments\n WHERE id = ?\n "
},
"6f0cb5a6d1e8dbc8cdfcc3c7e7944b2c83c22cb795b9d6b98fe067dabec9680b": {
"84be2b954870ab181738656ecd4d03fca2ff21012947014c79626abfce8e999b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
},
"854536c80af3f86bb9a63b8ce059ad724374b545cb23481bb3b2ce07d7414220": {
"describe": {
"columns": [
{
@@ -365,58 +559,63 @@
"type_info": "Text"
},
{
"name": "created_at",
"name": "folder_id",
"ordinal": 3,
"type_info": "Datetime"
"type_info": "Text"
},
{
"name": "updated_at",
"name": "created_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"name": "updated_at",
"ordinal": 5,
"type_info": "Text"
"type_info": "Datetime"
},
{
"name": "url",
"name": "name",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"name": "url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"name": "method",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body_type",
"name": "body",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"name": "body_type",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication_type",
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "sort_priority",
"name": "authentication_type",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 13,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 13,
"ordinal": 14,
"type_info": "Text"
}
],
@@ -424,6 +623,7 @@
false,
false,
false,
true,
false,
false,
false,
@@ -440,27 +640,7 @@
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
},
"84be2b954870ab181738656ecd4d03fca2ff21012947014c79626abfce8e999b": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM workspaces\n WHERE id = ?\n "
},
"86e32d6a6fadf35436f19b577a659c203a8d143cb3a8d6122951c5bf54a0888d": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"query": "\n INSERT INTO environments (id, workspace_id, name, variables)\n VALUES (?, ?, ?, ?)\n "
"query": "\n SELECT\n id,\n model,\n workspace_id,\n folder_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"8947a2a90478277c42fe9b06bc1fa98197642a4d281a3dbc101be2c9c1fec36c": {
"describe": {
@@ -472,6 +652,128 @@
},
"query": "\n INSERT INTO http_responses (\n id,\n request_id,\n workspace_id,\n elapsed,\n url,\n status,\n status_reason,\n content_length,\n body,\n body_path,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);\n "
},
"93aea3881dffb70a82325263740a0bb6477e78f27991ce7456b394e84383acb6": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "\n DELETE FROM folders\n WHERE id = ?\n "
},
"a558e182f40286fe52bed5f03b2dc367b7229ab6bd9cda0a7ce219a438ccd5fd": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "folder_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "authentication_type",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 13,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 14,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n folder_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
},
"ac1b4ffbd98b67f0a1a74e3525387d679dd6f44c561d55c7bbea747053e53671": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 0
}
},
"query": "\n UPDATE http_responses\n SET (elapsed, status_reason) = (-1, 'Cancelled')\n WHERE elapsed = 0;\n "
},
"aeb0712785a9964d516dc8939bc54aa8206ad852e608b362d014b67a0f21b0ed": {
"describe": {
"columns": [],
@@ -482,16 +784,6 @@
},
"query": "\n DELETE FROM environments\n WHERE id = ?\n "
},
"b19c275180909a39342b13c3cdcf993781636913ae590967f5508c46a56dc961": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 11
}
},
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n name,\n url,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n "
},
"ba2b34a77723f24f86e4c3c45274dbfec6ca130e16e592f948844c037bdc0593": {
"describe": {
"columns": [
@@ -648,198 +940,6 @@
},
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at, url,\n status, status_reason, content_length, body, body_path, elapsed, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
},
"caf3f21bf291dfbd36446592066e96c1f83abe96f6ea9211a3e049eb9c58a8c8": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces WHERE id = ?\n "
},
"cea4cae52f16ec78aca9a47b17117422d4f165e5a3b308c70fd1a180382475ea": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "\n SELECT id, model, created_at, updated_at, name, description\n FROM workspaces\n "
},
"ced098adb79c0ee64e223b6e02371ef253920a2c342275de0fa9c181529a4adc": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "authentication!: Json<HashMap<String, JsonValue>>",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "authentication_type",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 12,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 13,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n authentication AS \"authentication!: Json<HashMap<String, JsonValue>>\",\n authentication_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"d80c09497771e3641022e73ec6c6a87e73a551f88a948a5445d754922b82b50b": {
"describe": {
"columns": [],
@@ -850,24 +950,78 @@
},
"query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n "
},
"e0f41023d877d94b7609ce910a71bd89c4827a558654b8ae14d85e6ba86990cf": {
"dbe457087a7bccbca4c1d673aa8e547df04530a7f860a6ccd4e20126a7cdfa4f": {
"describe": {
"columns": [],
"nullable": [],
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>",
"ordinal": 6,
"type_info": "Null"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 2
"Right": 1
}
},
"query": "\n UPDATE workspaces SET (name, updated_at) =\n (?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
"query": "\n SELECT id, model, created_at, updated_at, name, description,\n variables AS \"variables!: sqlx::types::Json<Vec<EnvironmentVariable>>\"\n FROM workspaces WHERE id = ?\n "
},
"f116d8cf9aad828135bb8c3a4c8b8e6b857ae13303989e9133a33b2d1cf20e96": {
"dcc2f405f8e29d0599d86bcde509187e9cc5fc647067eaa5c738cb24e2f081e5": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
"Right": 4
}
},
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
"query": "\n INSERT INTO environments (\n id,\n workspace_id,\n name,\n variables\n )\n VALUES (?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n variables = excluded.variables\n "
},
"e5b410442b00ee354bb58eb0e8fb2af06e9dea4bb24763d717c72a840450a759": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 12
}
},
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n folder_id,\n name,\n url,\n method,\n body,\n body_type,\n authentication,\n authentication_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n folder_id = excluded.folder_id,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n authentication = excluded.authentication,\n authentication_type = excluded.authentication_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n "
}
}

118
src-tauri/src/analytics.rs Normal file
View File

@@ -0,0 +1,118 @@
use sqlx::types::JsonValue;
use tauri::{async_runtime, AppHandle, Manager};
use crate::is_dev;
pub enum AnalyticsResource {
App,
// Workspace,
// Environment,
// Folder,
// HttpRequest,
// HttpResponse,
}
pub enum AnalyticsAction {
Launch,
// Create,
// Update,
// Upsert,
// Delete,
// Send,
// Duplicate,
}
fn resource_name(resource: AnalyticsResource) -> &'static str {
match resource {
AnalyticsResource::App => "app",
// AnalyticsResource::Workspace => "workspace",
// AnalyticsResource::Environment => "environment",
// AnalyticsResource::Folder => "folder",
// AnalyticsResource::HttpRequest => "http_request",
// AnalyticsResource::HttpResponse => "http_response",
}
}
fn action_name(action: AnalyticsAction) -> &'static str {
match action {
AnalyticsAction::Launch => "launch",
// AnalyticsAction::Create => "create",
// AnalyticsAction::Update => "update",
// AnalyticsAction::Upsert => "upsert",
// AnalyticsAction::Delete => "delete",
// AnalyticsAction::Send => "send",
// AnalyticsAction::Duplicate => "duplicate",
}
}
pub fn track_event(
app_handle: &AppHandle,
resource: AnalyticsResource,
action: AnalyticsAction,
attributes: Option<JsonValue>,
) {
async_runtime::block_on(async move {
let event = format!("{}.{}", resource_name(resource), action_name(action));
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
let info = app_handle.package_info();
let params = vec![
("e", event.clone()),
("a", attributes_json.clone()),
("id", "site_zOK0d7jeBy2TLxFCnZ".to_string()),
("v", info.version.clone().to_string()),
("os", get_os().to_string()),
("xy", get_window_size(app_handle)),
];
let url = format!("https://t.yaak.app/t/e");
let req = reqwest::Client::builder()
.build()
.unwrap()
.get(&url)
.query(&params);
if is_dev() {
println!("Ignore dev analytics event: {} {:?}", event, params);
} else {
if let Err(e) = req.send().await {
println!("Error sending analytics event: {}", e);
} else {
println!("Sent analytics event: {}", event);
}
}
});
}
fn get_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}
fn get_window_size(app_handle: &AppHandle) -> String {
let window = match app_handle.windows().into_values().next() {
Some(w) => w,
None => return "unknown".to_string(),
};
let current_monitor = match window.current_monitor() {
Ok(Some(m)) => m,
_ => return "unknown".to_string(),
};
let scale_factor = current_monitor.scale_factor();
let size = current_monitor.size();
let width: f64 = size.width as f64 / scale_factor;
let height: f64 = size.height as f64 / scale_factor;
format!(
"{}x{}",
(width / 100.0).round() * 100.0,
(height / 100.0).round() * 100.0
)
}

View File

@@ -7,6 +7,12 @@
#[macro_use]
extern crate objc;
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::{create_dir_all, File};
use std::io::Write;
use std::process::exit;
use base64::Engine;
use http::header::{HeaderName, ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method};
@@ -17,22 +23,23 @@ use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::types::Json;
use sqlx::{Pool, Sqlite};
use std::collections::HashMap;
use std::env::current_dir;
use std::fs::{create_dir_all, File};
use std::io::Write;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, Window, WindowUrl, Wry};
use tauri::{AppHandle, Menu, RunEvent, State, Submenu, Window, WindowUrl, Wry};
use tauri::{CustomMenuItem, Manager, WindowEvent};
use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex;
use window_ext::TrafficLightWindowExt;
use crate::analytics::{track_event, AnalyticsAction, AnalyticsResource};
mod analytics;
mod models;
mod plugin;
mod render;
mod window_ext;
mod plugin;
mod window_menu;
#[derive(serde::Serialize)]
pub struct CustomResponse {
@@ -64,19 +71,19 @@ async fn migrate_db(
#[tauri::command]
async fn send_ephemeral_request(
request: models::HttpRequest,
mut request: models::HttpRequest,
environment_id: Option<&str>,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let response = models::HttpResponse::default();
let response = models::HttpResponse::new();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
return actually_send_ephemeral_request(request, &response, &environment_id2, &app_handle, pool)
.await;
request.id = "".to_string();
return actually_send_request(request, &response, &environment_id2, &app_handle, pool).await;
}
async fn actually_send_ephemeral_request(
async fn actually_send_request(
request: models::HttpRequest,
response: &models::HttpResponse,
environment_id: &str,
@@ -86,8 +93,11 @@ async fn actually_send_ephemeral_request(
let start = std::time::Instant::now();
let environment = models::get_environment(environment_id, pool).await.ok();
let environment_ref = environment.as_ref();
let workspace = models::get_workspace(&request.workspace_id, pool)
.await
.expect("Failed to get Workspace");
let mut url_string = render::render(&request.url, environment.as_ref());
let mut url_string = render::render(&request.url, &workspace, environment.as_ref());
if !url_string.starts_with("http://") && !url_string.starts_with("https://") {
url_string = format!("http://{}", url_string);
@@ -112,8 +122,8 @@ async fn actually_send_ephemeral_request(
continue;
}
let name = render::render(&h.name, environment_ref);
let value = render::render(&h.value, environment_ref);
let name = render::render(&h.name, &workspace, environment_ref);
let value = render::render(&h.value, &workspace, environment_ref);
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
Ok(n) => n,
@@ -148,8 +158,8 @@ async fn actually_send_ephemeral_request(
.unwrap_or(empty_value)
.as_str()
.unwrap_or("");
let username = render::render(raw_username, environment_ref);
let password = render::render(raw_password, environment_ref);
let username = render::render(raw_username, &workspace, environment_ref);
let password = render::render(raw_password, &workspace, environment_ref);
let auth = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(auth);
@@ -159,7 +169,7 @@ async fn actually_send_ephemeral_request(
);
} else if b == "bearer" {
let raw_token = a.get("token").unwrap_or(empty_value).as_str().unwrap_or("");
let token = render::render(raw_token, environment_ref);
let token = render::render(raw_token, &workspace, environment_ref);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
@@ -173,7 +183,7 @@ async fn actually_send_ephemeral_request(
let sendable_req_result = match (request.body, request.body_type) {
(Some(raw_body), Some(_)) => {
let body = render::render(&raw_body, environment_ref);
let body = render::render(&raw_body, &workspace, environment_ref);
builder.body(body).build()
}
_ => builder.build(),
@@ -248,6 +258,22 @@ async fn actually_send_ephemeral_request(
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
}
}
#[tauri::command]
async fn import_data(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
file_paths: Vec<&str>,
) -> Result<plugin::ImportedResources, String> {
let pool = &*db_instance.lock().await;
let imported = plugin::run_plugin_import(
&window.app_handle(),
pool,
"insomnia-importer",
file_paths.first().unwrap(),
)
.await;
Ok(imported)
}
#[tauri::command]
async fn send_request(
@@ -272,7 +298,7 @@ async fn send_request(
let pool2 = pool.clone();
tokio::spawn(async move {
actually_send_ephemeral_request(req, &response2, &environment_id2, &app_handle2, &pool2)
actually_send_request(req, &response2, &environment_id2, &app_handle2, &pool2)
.await
.expect("Failed to send request");
});
@@ -287,6 +313,7 @@ async fn response_err(
pool: &Pool<Sqlite>,
) -> Result<models::HttpResponse, String> {
let mut response = response.clone();
response.elapsed = -1;
response.error = Some(error.clone());
response = models::update_response_if_id(&response, pool)
.await
@@ -331,9 +358,15 @@ async fn create_workspace(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let created_workspace = models::create_workspace(name, "", pool)
.await
.expect("Failed to create workspace");
let created_workspace = models::upsert_workspace(
pool,
models::Workspace {
name: name.to_string(),
..Default::default()
},
)
.await
.expect("Failed to create Workspace");
emit_and_return(&window, "created_model", created_workspace)
}
@@ -347,9 +380,17 @@ async fn create_environment(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let created_environment = models::create_environment(workspace_id, name, variables, pool)
.await
.expect("Failed to create environment");
let created_environment = models::upsert_environment(
pool,
models::Environment {
workspace_id: workspace_id.to_string(),
name: name.to_string(),
variables: Json(variables),
..Default::default()
},
)
.await
.expect("Failed to create environment");
emit_and_return(&window, "created_model", created_environment)
}
@@ -359,24 +400,21 @@ async fn create_request(
workspace_id: &str,
name: &str,
sort_priority: f64,
folder_id: Option<&str>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
let headers = Vec::new();
let created_request = models::upsert_request(
None,
workspace_id,
name,
"GET",
None,
None,
HashMap::new(),
None,
"",
headers,
sort_priority,
pool,
models::HttpRequest {
workspace_id: workspace_id.to_string(),
name: name.to_string(),
method: "GET".to_string(),
folder_id: folder_id.map(|s| s.to_string()),
sort_priority,
..Default::default()
},
)
.await
.expect("Failed to create request");
@@ -405,7 +443,7 @@ async fn update_workspace(
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let updated_workspace = models::update_workspace(workspace, pool)
let updated_workspace = models::upsert_workspace(pool, workspace)
.await
.expect("Failed to update request");
@@ -420,14 +458,9 @@ async fn update_environment(
) -> Result<models::Environment, String> {
let pool = &*db_instance.lock().await;
let updated_environment = models::update_environment(
environment.id.as_str(),
environment.name.as_str(),
environment.variables.0,
pool,
)
.await
.expect("Failed to update request");
let updated_environment = models::upsert_environment(pool, environment)
.await
.expect("Failed to update environment");
emit_and_return(&window, "updated_model", updated_environment)
}
@@ -439,35 +472,9 @@ async fn update_request(
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpRequest, String> {
let pool = &*db_instance.lock().await;
// TODO: Figure out how to make this better
let b2;
let body = match request.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
None => None,
};
// TODO: Figure out how to make this better
let updated_request = models::upsert_request(
Some(request.id.as_str()),
request.workspace_id.as_str(),
request.name.as_str(),
request.method.as_str(),
body,
request.body_type,
request.authentication.0,
request.authentication_type,
request.url.as_str(),
request.headers.0,
request.sort_priority,
pool,
)
.await
.expect("Failed to update request");
let updated_request = models::upsert_request(pool, request)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_request)
}
@@ -484,6 +491,69 @@ async fn delete_request(
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
async fn list_folders(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::Folder>, String> {
let pool = &*db_instance.lock().await;
models::find_folders(workspace_id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn create_folder(
workspace_id: &str,
name: &str,
sort_priority: f64,
folder_id: Option<&str>,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Folder, String> {
let pool = &*db_instance.lock().await;
let created_request = models::upsert_folder(
pool,
models::Folder {
workspace_id: workspace_id.to_string(),
name: name.to_string(),
folder_id: folder_id.map(|s| s.to_string()),
sort_priority,
..Default::default()
},
)
.await
.expect("Failed to create folder");
emit_and_return(&window, "created_model", created_request)
}
#[tauri::command]
async fn update_folder(
folder: models::Folder,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Folder, String> {
let pool = &*db_instance.lock().await;
let updated_folder = models::upsert_folder(pool, folder)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_folder)
}
#[tauri::command]
async fn delete_folder(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
folder_id: &str,
) -> Result<models::Folder, String> {
let pool = &*db_instance.lock().await;
let req = models::delete_folder(folder_id, pool)
.await
.expect("Failed to delete folder");
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
async fn delete_environment(
window: Window<Wry>,
@@ -521,6 +591,17 @@ async fn list_environments(
Ok(environments)
}
#[tauri::command]
async fn get_folder(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Folder, String> {
let pool = &*db_instance.lock().await;
models::get_folder(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_request(
id: &str,
@@ -598,10 +679,15 @@ async fn list_workspaces(
.await
.expect("Failed to find workspaces");
if workspaces.is_empty() {
let workspace =
models::create_workspace("My Project", "This is the default workspace", pool)
.await
.expect("Failed to create workspace");
let workspace = models::upsert_workspace(
pool,
models::Workspace {
name: "My Project".to_string(),
..Default::default()
},
)
.await
.expect("Failed to create Workspace");
Ok(vec![workspace])
} else {
Ok(workspaces)
@@ -623,7 +709,7 @@ async fn delete_workspace(
let pool = &*db_instance.lock().await;
let workspace = models::delete_workspace(workspace_id, pool)
.await
.expect("Failed to delete workspace");
.expect("Failed to delete Workspace");
emit_and_return(&window, "deleted_model", workspace)
}
@@ -641,6 +727,7 @@ fn main() {
let p_string = p.to_string_lossy().replace(' ', "%20");
let url = format!("sqlite://{}?mode=rwc", p_string);
println!("Connecting to database at {}", url);
tauri::async_runtime::block_on(async move {
let pool = SqlitePoolOptions::new()
.connect(url.as_str())
@@ -648,30 +735,68 @@ fn main() {
.expect("Failed to connect to database");
// Setup the DB handle
let m = Mutex::new(pool);
let m = Mutex::new(pool.clone());
migrate_db(app.handle(), &m)
.await
.expect("Failed to migrate database");
app.manage(m);
let _ = models::cancel_pending_responses(&pool).await;
// TODO: Move this somewhere better
match app.get_cli_matches() {
Ok(matches) => {
let cmd = matches.subcommand.unwrap_or_default();
if cmd.name == "import" {
let arg_file = cmd
.matches
.args
.get("file")
.unwrap()
.value
.as_str()
.unwrap();
plugin::run_plugin_import(
&app.handle(),
&pool,
"insomnia-importer",
arg_file,
)
.await;
exit(0);
} else if cmd.name == "hello" {
plugin::run_plugin_hello(&app.handle(), "hello-world");
exit(0);
}
}
Err(e) => {
println!("Nothing found: {}", e);
}
}
Ok(())
})
})
.invoke_handler(tauri::generate_handler![
create_environment,
create_folder,
create_request,
create_workspace,
delete_all_responses,
delete_environment,
delete_folder,
delete_request,
delete_response,
delete_workspace,
duplicate_request,
get_key_value,
get_environment,
get_folder,
get_request,
get_workspace,
import_data,
list_environments,
list_folders,
list_requests,
list_responses,
list_workspaces,
@@ -680,6 +805,7 @@ fn main() {
send_request,
set_key_value,
update_environment,
update_folder,
update_request,
update_workspace,
])
@@ -690,7 +816,13 @@ fn main() {
let w = create_window(app_handle, None);
w.restore_state(StateFlags::all())
.expect("Failed to restore window state");
plugin::test_plugins(&app_handle);
track_event(
app_handle,
AnalyticsResource::App,
AnalyticsAction::Launch,
None,
);
}
// ExitRequested { api, .. } => {
@@ -705,70 +837,31 @@ fn is_dev() -> bool {
}
fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
let default_menu = Menu::os_default("Yaak".to_string().as_str());
let mut test_menu = Menu::new()
.add_item(
CustomMenuItem::new("send_request".to_string(), "Send Request")
.accelerator("CmdOrCtrl+r"),
)
.add_item(
CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size")
.accelerator("CmdOrCtrl+0"),
)
.add_item(
CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"),
)
.add_item(
CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"),
)
.add_item(
CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
.accelerator("CmdOrCtrl+b"),
)
.add_item(
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
)
.add_item(
CustomMenuItem::new("new_request".to_string(), "New Request")
.accelerator("CmdOrCtrl+n"),
)
.add_item(
CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
.accelerator("CmdOrCtrl+,"),
)
.add_item(
CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
.accelerator("CmdOrCtrl+d"),
)
.add_item(
CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
.accelerator("CmdOrCtrl+1"),
)
.add_item(CustomMenuItem::new("new_window".to_string(), "New Window"));
let mut app_menu = window_menu::os_default("Yaak".to_string().as_str());
if is_dev() {
test_menu = test_menu
.add_native_item(MenuItem::Separator)
.add_item(
CustomMenuItem::new("refresh".to_string(), "Refresh")
.accelerator("CmdOrCtrl + Shift + r"),
)
.add_item(
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
.accelerator("CmdOrCtrl + Option + i"),
);
let submenu = Submenu::new(
"Developer",
Menu::new()
.add_item(
CustomMenuItem::new("refresh".to_string(), "Refresh")
.accelerator("CmdOrCtrl + Shift + r"),
)
.add_item(
CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools")
.accelerator("CmdOrCtrl + Option + i"),
),
);
app_menu = app_menu.add_submenu(submenu);
}
let submenu = Submenu::new("Test Menu", test_menu);
let window_num = handle.windows().len();
let window_id = format!("wnd_{}", window_num);
let menu = default_menu.add_submenu(submenu);
let mut win_builder = tauri::WindowBuilder::new(
handle,
window_id,
WindowUrl::App(url.unwrap_or_default().into()),
)
.menu(menu)
.menu(app_menu)
.fullscreen(false)
.resizable(true)
.inner_size(1100.0, 600.0)
@@ -795,7 +888,7 @@ fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
let win2 = win.clone();
let handle2 = handle.clone();
win.on_menu_event(move |event| match event.menu_item_id() {
"quit" => std::process::exit(0),
"quit" => exit(0),
"close" => win2.close().unwrap(),
"zoom_reset" => win2.emit("zoom", 0).unwrap(),
"zoom_in" => win2.emit("zoom", 1).unwrap(),

View File

@@ -7,8 +7,8 @@ use sqlx::types::chrono::NaiveDateTime;
use sqlx::types::{Json, JsonValue};
use sqlx::{Pool, Sqlite};
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Workspace {
pub id: String,
pub model: String,
@@ -16,10 +16,11 @@ pub struct Workspace {
pub updated_at: NaiveDateTime,
pub name: String,
pub description: String,
pub variables: Json<Vec<EnvironmentVariable>>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Environment {
pub id: String,
pub workspace_id: String,
@@ -30,35 +31,45 @@ pub struct Environment {
pub variables: Json<Vec<EnvironmentVariable>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct EnvironmentVariable {
#[serde(default)]
#[serde(default = "default_enabled")]
pub enabled: bool,
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct HttpRequestHeader {
#[serde(default)]
#[serde(default = "default_enabled")]
pub enabled: bool,
pub name: String,
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
fn default_http_request_method() -> String {
"GET".to_string()
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct HttpRequest {
pub id: String,
pub workspace_id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub id: String,
pub workspace_id: String,
pub folder_id: Option<String>,
pub model: String,
pub sort_priority: f64,
pub name: String,
pub url: String,
#[serde(default = "default_http_request_method")]
pub method: String,
pub body: Option<String>,
pub body_type: Option<String>,
@@ -67,15 +78,28 @@ pub struct HttpRequest {
pub headers: Json<Vec<HttpRequestHeader>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Folder {
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub id: String,
pub workspace_id: String,
pub folder_id: Option<String>,
pub model: String,
pub name: String,
pub sort_priority: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct HttpResponseHeader {
pub name: String,
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[serde(default, rename_all = "camelCase")]
pub struct HttpResponse {
pub id: String,
pub model: String,
@@ -94,8 +118,17 @@ pub struct HttpResponse {
pub headers: Json<Vec<HttpResponseHeader>>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
impl HttpResponse {
pub(crate) fn new() -> Self {
Self {
model: "http_response".to_string(),
..Default::default()
}
}
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct KeyValue {
pub model: String,
pub created_at: NaiveDateTime,
@@ -153,7 +186,8 @@ pub async fn find_workspaces(pool: &Pool<Sqlite>) -> Result<Vec<Workspace>, sqlx
sqlx::query_as!(
Workspace,
r#"
SELECT id, model, created_at, updated_at, name, description
SELECT id, model, created_at, updated_at, name, description,
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM workspaces
"#,
)
@@ -165,7 +199,8 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
sqlx::query_as!(
Workspace,
r#"
SELECT id, model, created_at, updated_at, name, description
SELECT id, model, created_at, updated_at, name, description,
variables AS "variables!: sqlx::types::Json<Vec<EnvironmentVariable>>"
FROM workspaces WHERE id = ?
"#,
id,
@@ -193,27 +228,6 @@ pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace
Ok(workspace)
}
pub async fn create_workspace(
name: &str,
description: &str,
pool: &Pool<Sqlite>,
) -> Result<Workspace, sqlx::Error> {
let id = generate_id(Some("wk"));
sqlx::query!(
r#"
INSERT INTO workspaces (id, name, description)
VALUES (?, ?, ?)
"#,
id,
name,
description,
)
.execute(pool)
.await?;
get_workspace(&id, pool).await
}
pub async fn find_environments(
workspace_id: &str,
pool: &Pool<Sqlite>,
@@ -232,30 +246,6 @@ pub async fn find_environments(
.await
}
pub async fn create_environment(
workspace_id: &str,
name: &str,
variables: Vec<EnvironmentVariable>,
pool: &Pool<Sqlite>,
) -> Result<Environment, sqlx::Error> {
let id = generate_id(Some("en"));
let trimmed_name = name.trim();
let variables_json = Json(variables);
sqlx::query!(
r#"
INSERT INTO environments (id, workspace_id, name, variables)
VALUES (?, ?, ?, ?)
"#,
id,
workspace_id,
trimmed_name,
variables_json,
)
.execute(pool)
.await?;
get_environment(&id, pool).await
}
pub async fn delete_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environment, sqlx::Error> {
let env = get_environment(id, pool).await?;
let _ = sqlx::query!(
@@ -271,26 +261,37 @@ pub async fn delete_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environ
Ok(env)
}
pub async fn update_environment(
id: &str,
name: &str,
variables: Vec<EnvironmentVariable>,
pub async fn upsert_environment(
pool: &Pool<Sqlite>,
environment: Environment,
) -> Result<Environment, sqlx::Error> {
let variables_json = Json(variables);
let id = match environment.id.as_str() {
"" => generate_id(Some("ev")),
_ => environment.id.to_string(),
};
let trimmed_name = environment.name.trim();
sqlx::query!(
r#"
UPDATE environments
SET (name, variables, updated_at) = (?, ?, CURRENT_TIMESTAMP)
WHERE id = ?;
INSERT INTO environments (
id,
workspace_id,
name,
variables
)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
variables = excluded.variables
"#,
name,
variables_json,
id,
environment.workspace_id,
trimmed_name,
environment.variables,
)
.execute(pool)
.await?;
get_environment(id, pool).await
get_environment(&id, pool).await
}
pub async fn get_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environment, sqlx::Error> {
@@ -314,66 +315,127 @@ pub async fn get_environment(id: &str, pool: &Pool<Sqlite>) -> Result<Environmen
.await
}
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let existing = get_request(id, pool).await?;
// TODO: Figure out how to make this better
let b2;
let body = match existing.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
None => None,
};
upsert_request(
None,
existing.workspace_id.as_str(),
existing.name.as_str(),
existing.method.as_str(),
body,
existing.body_type,
existing.authentication.0,
existing.authentication_type,
existing.url.as_str(),
existing.headers.0,
existing.sort_priority + 0.001,
pool,
pub async fn get_folder(id: &str, pool: &Pool<Sqlite>) -> Result<Folder, sqlx::Error> {
sqlx::query_as!(
Folder,
r#"
SELECT
id,
model,
workspace_id,
created_at,
updated_at,
folder_id,
name,
sort_priority
FROM folders
WHERE id = ?
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn upsert_request(
id: Option<&str>,
pub async fn find_folders(
workspace_id: &str,
name: &str,
method: &str,
body: Option<&str>,
body_type: Option<String>,
authentication: HashMap<String, JsonValue>,
authentication_type: Option<String>,
url: &str,
headers: Vec<HttpRequestHeader>,
sort_priority: f64,
pool: &Pool<Sqlite>,
) -> Result<HttpRequest, sqlx::Error> {
let generated_id;
let id = match id {
Some(v) => v,
None => {
generated_id = generate_id(Some("rq"));
generated_id.as_str()
}
) -> Result<Vec<Folder>, sqlx::Error> {
sqlx::query_as!(
Folder,
r#"
SELECT
id,
model,
workspace_id,
created_at,
updated_at,
folder_id,
name,
sort_priority
FROM folders
WHERE workspace_id = ?
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn delete_folder(id: &str, pool: &Pool<Sqlite>) -> Result<Folder, sqlx::Error> {
let env = get_folder(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM folders
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(env)
}
pub async fn upsert_folder(pool: &Pool<Sqlite>, r: Folder) -> Result<Folder, sqlx::Error> {
let id = match r.id.as_str() {
"" => generate_id(Some("fl")),
_ => r.id.to_string(),
};
let headers_json = Json(headers);
let auth_json = Json(authentication);
let trimmed_name = name.trim();
let trimmed_name = r.name.trim();
sqlx::query!(
r#"
INSERT INTO folders (
id,
workspace_id,
folder_id,
name,
sort_priority
)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
folder_id = excluded.folder_id,
sort_priority = excluded.sort_priority
"#,
id,
r.workspace_id,
r.folder_id,
trimmed_name,
r.sort_priority,
)
.execute(pool)
.await?;
get_folder(&id, pool).await
}
pub async fn duplicate_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, sqlx::Error> {
let mut request = get_request(id, pool).await?.clone();
request.id = "".to_string();
upsert_request(pool, request).await
}
pub async fn upsert_request(
pool: &Pool<Sqlite>,
r: HttpRequest,
) -> Result<HttpRequest, sqlx::Error> {
let id = match r.id.as_str() {
"" => generate_id(Some("rq")),
_ => r.id.to_string(),
};
let headers_json = Json(r.headers);
let auth_json = Json(r.authentication);
let trimmed_name = r.name.trim();
sqlx::query!(
r#"
INSERT INTO http_requests (
id,
workspace_id,
folder_id,
name,
url,
method,
@@ -384,10 +446,11 @@ pub async fn upsert_request(
headers,
sort_priority
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
folder_id = excluded.folder_id,
method = excluded.method,
headers = excluded.headers,
body = excluded.body,
@@ -398,20 +461,22 @@ pub async fn upsert_request(
sort_priority = excluded.sort_priority
"#,
id,
workspace_id,
r.workspace_id,
r.folder_id,
trimmed_name,
url,
method,
body,
body_type,
r.url,
r.method,
r.body,
r.body_type,
auth_json,
authentication_type,
r.authentication_type,
headers_json,
sort_priority,
r.sort_priority,
)
.execute(pool)
.await?;
get_request(id, pool).await
get_request(&id, pool).await
}
pub async fn find_requests(
@@ -425,6 +490,7 @@ pub async fn find_requests(
id,
model,
workspace_id,
folder_id,
created_at,
updated_at,
name,
@@ -453,6 +519,7 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
id,
model,
workspace_id,
folder_id,
created_at,
updated_at,
name,
@@ -542,6 +609,19 @@ pub async fn create_response(
get_response(&id, pool).await
}
pub async fn cancel_pending_responses(pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
UPDATE http_responses
SET (elapsed, status_reason) = (-1, 'Cancelled')
WHERE elapsed = 0;
"#,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_response_if_id(
response: &HttpResponse,
pool: &Pool<Sqlite>,
@@ -552,23 +632,34 @@ pub async fn update_response_if_id(
return update_response(response, pool).await;
}
pub async fn update_workspace(
workspace: Workspace,
pub async fn upsert_workspace(
pool: &Pool<Sqlite>,
workspace: Workspace,
) -> Result<Workspace, sqlx::Error> {
let id = match workspace.id.as_str() {
"" => generate_id(Some("wk")),
_ => workspace.id.to_string(),
};
let trimmed_name = workspace.name.trim();
sqlx::query!(
r#"
UPDATE workspaces SET (name, updated_at) =
(?, CURRENT_TIMESTAMP) WHERE id = ?;
INSERT INTO workspaces (id, name, description, variables)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
description = excluded.description,
variables = excluded.variables
"#,
id,
trimmed_name,
workspace.id,
workspace.description,
workspace.variables,
)
.execute(pool)
.await?;
get_workspace(&workspace.id, pool).await
get_workspace(&id, pool).await
}
pub async fn update_response(

View File

@@ -1,3 +1,6 @@
use std::fs;
use boa_engine::builtins::promise::PromiseState;
use boa_engine::{
js_string,
module::{ModuleLoader, SimpleModuleLoader},
@@ -5,17 +8,98 @@ use boa_engine::{
Context, JsArgs, JsNativeError, JsValue, Module, NativeFunction, Source,
};
use boa_runtime::Console;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{Pool, Sqlite};
use tauri::AppHandle;
pub fn test_plugins(app_handle: &AppHandle) {
use crate::models::{self, Environment, Folder, HttpRequest, Workspace};
pub fn run_plugin_hello(app_handle: &AppHandle, plugin_name: &str) {
run_plugin(app_handle, plugin_name, "hello", &[]);
}
#[derive(Default, Debug, Deserialize, Serialize)]
pub struct ImportedResources {
workspaces: Vec<Workspace>,
environments: Vec<Environment>,
folders: Vec<Folder>,
requests: Vec<HttpRequest>,
}
pub async fn run_plugin_import(
app_handle: &AppHandle,
pool: &Pool<Sqlite>,
plugin_name: &str,
file_path: &str,
) -> ImportedResources {
let file = fs::read_to_string(file_path)
.expect(format!("Unable to read file {}", file_path.to_string()).as_str());
let file_contents = file.as_str();
let result_json = run_plugin(
app_handle,
plugin_name,
"pluginHookImport",
&[js_string!(file_contents).into()],
);
let resources: ImportedResources =
serde_json::from_value(result_json).expect("failed to parse result json");
let mut imported_resources = ImportedResources::default();
println!("Importing resources");
for w in resources.workspaces {
println!("Importing workspace: {:?}", w);
let x = models::upsert_workspace(&pool, w)
.await
.expect("Failed to create workspace");
imported_resources.workspaces.push(x.clone());
println!("Imported workspace: {}", x.name);
}
for e in resources.environments {
println!("Importing environment: {:?}", e);
let x = models::upsert_environment(&pool, e)
.await
.expect("Failed to create environment");
imported_resources.environments.push(x.clone());
println!("Imported environment: {}", x.name);
}
for f in resources.folders {
println!("Importing folder: {:?}", f);
let x = models::upsert_folder(&pool, f)
.await
.expect("Failed to create folder");
imported_resources.folders.push(x.clone());
println!("Imported folder: {}", x.name);
}
for r in resources.requests {
println!("Importing request: {:?}", r);
let x = models::upsert_request(&pool, r)
.await
.expect("Failed to create request");
imported_resources.requests.push(x.clone());
println!("Imported request: {}", x.name);
}
imported_resources
}
fn run_plugin(
app_handle: &AppHandle,
plugin_name: &str,
entrypoint: &str,
js_args: &[JsValue],
) -> serde_json::Value {
let plugin_dir = app_handle
.path_resolver()
.resolve_resource("plugins/hello-world")
.expect("failed to resolve plugin directory resource");
let plugin_entry_file = app_handle
.path_resolver()
.resolve_resource("plugins/hello-world/index.js")
.expect("failed to resolve plugin entry point resource");
.resolve_resource("plugins")
.expect("failed to resolve plugin directory resource")
.join(plugin_name);
let plugin_index_file = plugin_dir.join("out/index.js");
println!("Plugin dir={:?} file={:?}", plugin_dir, plugin_index_file);
// Module loader for the specific plugin
let loader = &SimpleModuleLoader::new(plugin_dir).expect("failed to create module loader");
@@ -29,47 +113,53 @@ pub fn test_plugins(app_handle: &AppHandle) {
add_runtime(context);
add_globals(context);
let source = Source::from_filepath(&plugin_entry_file).expect("Error opening file");
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
// TODO: Is this needed if loaded from file already?
loader.insert(plugin_entry_file, module.clone());
loader.insert(plugin_index_file, module.clone());
let _promise_result = module
let promise_result = module
.load_link_evaluate(context)
.expect("failed to evaluate module");
// 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 => return Err("module didn't execute!".into()),
// PromiseState::Fulfilled(v) => {
// assert_eq!(v, JsValue::undefined())
// }
// PromiseState::Rejected(err) => {
// return Err(JsError::from_opaque(err).try_native(context)?.into())
// }
// }
// Checking if the final promise didn't return an error.
match promise_result.state().expect("failed to get promise 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 entrypoint_fn = namespace
.get(js_string!("entrypoint"), 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");
// Actually call the entrypoint function
let _result = entrypoint_fn
.call(&JsValue::undefined(), &[], context)
.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<'_>) {

View File

@@ -1,24 +1,27 @@
use crate::models::Environment;
use crate::models::{Environment, Workspace};
use std::collections::HashMap;
use tauri::regex::Regex;
pub fn render(template: &str, environment: Option<&Environment>) -> String {
match environment {
Some(environment) => render_with_environment(template, environment),
None => template.to_string(),
}
}
fn render_with_environment(template: &str, environment: &Environment) -> String {
pub fn render(template: &str, workspace: &Workspace, environment: Option<&Environment>) -> String {
let mut map = HashMap::new();
let variables = &environment.variables.0;
for variable in variables {
if !variable.enabled {
let workspace_variables = &workspace.variables.0;
for variable in workspace_variables {
if !variable.enabled || variable.value.is_empty() {
continue;
}
map.insert(variable.name.as_str(), variable.value.as_str());
}
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: &tauri::regex::Captures| {

View File

@@ -0,0 +1,119 @@
use tauri::{AboutMetadata, CustomMenuItem, Menu, MenuItem, Submenu};
pub fn os_default(#[allow(unused)] app_name: &str) -> Menu {
let mut menu = Menu::new();
#[cfg(target_os = "macos")]
{
menu = menu.add_submenu(Submenu::new(
app_name,
Menu::new()
.add_native_item(MenuItem::About(
app_name.to_string(),
AboutMetadata::default(),
))
.add_native_item(MenuItem::Separator)
.add_native_item(MenuItem::Services)
.add_native_item(MenuItem::Separator)
.add_native_item(MenuItem::Hide)
.add_native_item(MenuItem::HideOthers)
.add_native_item(MenuItem::ShowAll)
.add_native_item(MenuItem::Separator)
.add_native_item(MenuItem::Quit),
));
}
let mut file_menu = Menu::new();
file_menu = file_menu.add_native_item(MenuItem::CloseWindow);
#[cfg(not(target_os = "macos"))]
{
file_menu = file_menu.add_native_item(MenuItem::Quit);
}
menu = menu.add_submenu(Submenu::new("File", file_menu));
#[cfg(not(target_os = "linux"))]
let mut edit_menu = Menu::new();
#[cfg(target_os = "macos")]
{
edit_menu = edit_menu.add_native_item(MenuItem::Undo);
edit_menu = edit_menu.add_native_item(MenuItem::Redo);
edit_menu = edit_menu.add_native_item(MenuItem::Separator);
}
#[cfg(not(target_os = "linux"))]
{
edit_menu = edit_menu.add_native_item(MenuItem::Cut);
edit_menu = edit_menu.add_native_item(MenuItem::Copy);
edit_menu = edit_menu.add_native_item(MenuItem::Paste);
}
#[cfg(target_os = "macos")]
{
edit_menu = edit_menu.add_native_item(MenuItem::SelectAll);
}
#[cfg(not(target_os = "linux"))]
{
menu = menu.add_submenu(Submenu::new("Edit", edit_menu));
}
let mut view_menu = Menu::new();
#[cfg(target_os = "macos")]
{
view_menu = view_menu
.add_native_item(MenuItem::EnterFullScreen)
.add_native_item(MenuItem::Separator);
}
view_menu = view_menu
.add_item(
CustomMenuItem::new("zoom_reset".to_string(), "Zoom to Actual Size")
.accelerator("CmdOrCtrl+0"),
)
.add_item(
CustomMenuItem::new("zoom_in".to_string(), "Zoom In").accelerator("CmdOrCtrl+Plus"),
)
.add_item(
CustomMenuItem::new("zoom_out".to_string(), "Zoom Out").accelerator("CmdOrCtrl+-"),
)
.add_native_item(MenuItem::Separator)
.add_item(
CustomMenuItem::new("toggle_sidebar".to_string(), "Toggle Sidebar")
.accelerator("CmdOrCtrl+b"),
)
.add_item(
CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
.accelerator("CmdOrCtrl+1"),
)
.add_item(
CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
.accelerator("CmdOrCtrl+,"),
)
.add_item(
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
);
menu = menu.add_submenu(Submenu::new("View", view_menu));
let mut window_menu = Menu::new();
window_menu = window_menu.add_native_item(MenuItem::Minimize);
#[cfg(target_os = "macos")]
{
window_menu = window_menu.add_native_item(MenuItem::Zoom);
window_menu = window_menu.add_native_item(MenuItem::Separator);
}
window_menu = window_menu.add_native_item(MenuItem::CloseWindow);
menu = menu.add_submenu(Submenu::new("Window", window_menu));
menu = menu.add_submenu(Submenu::new(
"Workspace",
Menu::new()
.add_item(
CustomMenuItem::new("send_request".to_string(), "Send Request")
.accelerator("CmdOrCtrl+r"),
)
.add_item(
CustomMenuItem::new("new_request".to_string(), "New Request")
.accelerator("CmdOrCtrl+n"),
)
.add_item(
CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
.accelerator("CmdOrCtrl+d"),
),
));
menu
}

View File

@@ -8,10 +8,27 @@
},
"package": {
"productName": "Yaak",
"version": "2023.1.7"
"version": "2023.2.0"
},
"tauri": {
"windows": [],
"cli": {
"description": "Yaak CLI",
"longDescription": "This is the Yaak CLI, yo",
"beforeHelp": "u can use it to build, develop and manage your Yaak application.",
"afterHelp": "Have fun!",
"args": [],
"subcommands": {
"import": {
"args": [{
"name": "file",
"short": "f",
"takesValue": true
}]
},
"hello": {}
}
},
"allowlist": {
"all": false,
"os": {
@@ -36,6 +53,10 @@
},
"window": {
"startDragging": true
},
"dialog": {
"all": false,
"open": true
}
},
"bundle": {

View File

@@ -1,85 +1,76 @@
import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useEnvironments } from '../hooks/useEnvironments';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { useEnvironments } from '../hooks/useEnvironments';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useDialog } from './DialogContext';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { usePrompt } from '../hooks/usePrompt';
type Props = {
className?: string;
};
} & Pick<ButtonProps, 'forDropdown' | 'leftSlot'>;
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
className,
...buttonProps
}: Props) {
const environments = useEnvironments();
const activeEnvironment = useActiveEnvironment();
const createEnvironment = useCreateEnvironment();
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const showEnvironmentDialog = useCallback(() => {
dialog.show({
title: 'Manage Environments',
render: () => <EnvironmentEditDialog />,
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
});
}, [dialog]);
}, [dialog, activeEnvironment]);
const items: DropdownItem[] = useMemo(
() =>
environments.length === 0
? [
{
key: 'create',
label: 'Create Environment',
leftSlot: <Icon icon="plusCircle" />,
onSelect: async () => {
await createEnvironment.mutateAsync();
showEnvironmentDialog();
},
},
]
: [
...environments.map(
(e) => ({
key: e.id,
label: e.name,
rightSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : undefined,
onSelect: async () => {
routes.setEnvironment(e);
},
}),
[activeEnvironment?.id],
),
{ type: 'separator', label: 'Environments' },
{
key: 'edit',
label: 'Manage Environments',
leftSlot: <Icon icon="gear" />,
onSelect: showEnvironmentDialog,
},
],
[activeEnvironment, environments, routes, createEnvironment, showEnvironmentDialog],
() => [
...environments.map(
(e) => ({
key: e.id,
label: e.name,
rightSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : undefined,
onSelect: async () => {
if (e.id !== activeEnvironment?.id) {
routes.setEnvironment(e);
} else {
routes.setEnvironment(null);
}
},
}),
[activeEnvironment?.id],
),
...((environments.length > 0
? [{ type: 'separator', label: 'Environments' }]
: []) as DropdownItem[]),
{
key: 'edit',
label: 'Manage Environments',
leftSlot: <Icon icon="gear" />,
onSelect: showEnvironmentDialog,
},
],
[activeEnvironment, environments, routes, showEnvironmentDialog],
);
return (
<Dropdown items={items}>
<Button
forDropdown
size="sm"
className={classNames(
className,
'text-gray-800 !px-2 truncate',
activeEnvironment == null && 'text-opacity-disabled italic',
)}
{...buttonProps}
>
{activeEnvironment?.name ?? 'No Environment'}
</Button>

View File

@@ -1,34 +1,48 @@
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import type { Environment } from '../lib/models';
import { Button } from './core/Button';
import classNames from 'classnames';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { PairEditor } from './core/PairEditor';
import type { PairEditorProps } from './core/PairEditor';
import { useCallback, useMemo } from 'react';
import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
import { HStack, VStack } from './core/Stacks';
import { IconButton } from './core/IconButton';
import { useCallback, useMemo, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useDeleteEnvironment } from '../hooks/useDeleteEnvironment';
import { useEnvironments } from '../hooks/useEnvironments';
import { usePrompt } from '../hooks/usePrompt';
import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import type { Environment, Workspace } from '../lib/models';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import type {
GenericCompletionConfig,
GenericCompletionOption,
} from './core/Editor/genericCompletion';
import { Icon } from './core/Icon';
import { usePrompt } from '../hooks/usePrompt';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { useWindowSize } from 'react-use';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
import { HStack, VStack } from './core/Stacks';
export const EnvironmentEditDialog = function () {
const routes = useAppRoutes();
interface Props {
initialEnvironment: Environment | null;
}
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
);
const environments = useEnvironments();
const createEnvironment = useCreateEnvironment();
const activeEnvironment = useActiveEnvironment();
const activeWorkspace = useActiveWorkspace();
const windowSize = useWindowSize();
const showSidebar = windowSize.width > 500;
const selectedEnvironment = useMemo(
() => environments.find((e) => e.id === selectedEnvironmentId) ?? null,
[environments, selectedEnvironmentId],
);
return (
<div
className={classNames(
@@ -37,39 +51,39 @@ export const EnvironmentEditDialog = function () {
)}
>
{showSidebar && (
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-4 border-r border-gray-100">
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-3 border-r border-gray-100 -ml-2">
<div className="min-w-0 h-full w-full overflow-y-scroll">
{environments.map((e) => (
<Button
size="xs"
color="custom"
className={classNames(
'w-full',
'text-gray-600 hocus:text-gray-800',
activeEnvironment?.id === e.id && 'bg-highlightSecondary !text-gray-900',
)}
justify="start"
key={e.id}
onClick={() => {
routes.setEnvironment(e);
}}
>
{e.name}
</Button>
))}
<SidebarButton
active={selectedEnvironment == null}
onClick={() => setSelectedEnvironmentId(null)}
>
Base Environment
</SidebarButton>
<div className="ml-3 pl-2 border-l border-highlight">
{environments.map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id === e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
>
{e.name}
</SidebarButton>
))}
</div>
</div>
<Button
size="sm"
className="w-full"
className="w-full text-center"
color="gray"
justify="center"
onClick={() => createEnvironment.mutate()}
>
New Environment
</Button>
</aside>
)}
{activeEnvironment != null ? (
<EnvironmentEditor environment={activeEnvironment} />
{activeWorkspace != null ? (
<EnvironmentEditor environment={selectedEnvironment} workspace={activeWorkspace} />
) : (
<div className="flex w-full h-full items-center justify-center text-gray-400 italic">
select an environment
@@ -79,57 +93,90 @@ export const EnvironmentEditDialog = function () {
);
};
const EnvironmentEditor = function ({ environment }: { environment: Environment }) {
const EnvironmentEditor = function ({
environment,
workspace,
}: {
environment: Environment | null;
workspace: Workspace;
}) {
const environments = useEnvironments();
const updateEnvironment = useUpdateEnvironment(environment.id);
const updateEnvironment = useUpdateEnvironment(environment?.id ?? 'n/a');
const updateWorkspace = useUpdateWorkspace(workspace.id);
const deleteEnvironment = useDeleteEnvironment(environment);
const variables = environment == null ? workspace.variables : environment.variables;
const handleChange = useCallback<PairEditorProps['onChange']>(
(variables) => {
updateEnvironment.mutate({ variables });
if (environment != null) {
updateEnvironment.mutate({ variables });
} else {
updateWorkspace.mutate({ variables });
}
},
[updateEnvironment],
[updateWorkspace, updateEnvironment, environment],
);
// Gather a list of env names from other environments, to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const allVariableNames = environments.flatMap((e) => e.variables.map((v) => v.name));
// Filter out empty strings and variables that already exist in the active environment
const otherEnvironments = environments.filter((e) => e.id !== environment?.id);
const allVariableNames =
environment == null
? [
// Nothing to autocomplete if we're in the base environment
]
: [
...workspace.variables.map((v) => v.name),
...otherEnvironments.flatMap((e) => e.variables.map((v) => v.name)),
];
// Filter out empty strings and variables that already exist
const variableNames = allVariableNames.filter(
(name) => name != '' && !environment.variables.find((v) => v.name === name),
(name) => name != '' && !variables.find((v) => v.name === name),
);
return { options: variableNames.map((name) => ({ label: name, type: 'constant' })) };
}, [environments, environment.variables]);
const uniqueVariableNames = [...new Set(variableNames)];
const options = uniqueVariableNames.map(
(name): GenericCompletionOption => ({
label: name,
type: 'constant',
}),
);
return { options };
}, [environments, variables, workspace, environment]);
const prompt = usePrompt();
const items = useMemo<DropdownItem[]>(
() => [
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" size="sm" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: environment.name,
});
updateEnvironment.mutate({ name });
},
},
{
key: 'delete',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,
onSelect: () => deleteEnvironment.mutate(),
},
],
[deleteEnvironment, updateEnvironment, environment.name, prompt],
const items = useMemo<DropdownItem[] | null>(
() =>
environment == null
? null
: [
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" size="sm" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: environment.name,
});
updateEnvironment.mutate({ name });
},
},
{
key: 'delete',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,
onSelect: () => deleteEnvironment.mutate(),
},
],
[deleteEnvironment, updateEnvironment, prompt, environment],
);
const validateName = useCallback((name: string) => {
@@ -141,10 +188,12 @@ const EnvironmentEditor = function ({ environment }: { environment: Environment
return (
<VStack space={2}>
<HStack space={2} className="justify-between">
<h1 className="text-xl">{environment.name}</h1>
<Dropdown items={items}>
<IconButton icon="gear" title="Environment Actions" size="sm" className="!h-auto w-8" />
</Dropdown>
<h1 className="text-xl">{environment?.name ?? 'Base Environment'}</h1>
{items != null && (
<Dropdown items={items}>
<IconButton icon="gear" title="Environment Actions" size="sm" className="!h-auto w-8" />
</Dropdown>
)}
</HStack>
<PairEditor
nameAutocomplete={nameAutocomplete}
@@ -153,10 +202,37 @@ const EnvironmentEditor = function ({ environment }: { environment: Environment
valuePlaceholder="variable value"
nameValidate={validateName}
valueAutocompleteVariables={false}
forceUpdateKey={environment.id}
pairs={environment.variables}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}
pairs={variables}
onChange={handleChange}
/>
</VStack>
);
};
function SidebarButton({
children,
className,
active,
onClick,
}: {
className?: string;
children: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
tabIndex={active ? 0 : -1}
onClick={onClick}
className={classNames(
className,
'flex items-center text-sm text-left w-full mb-1 h-xs rounded px-2',
'text-gray-600 hocus:text-gray-800 focus:bg-highlightSecondary outline-none',
active && '!text-gray-900',
)}
>
{children}
</button>
);
}

View File

@@ -1,20 +1,22 @@
import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { requestsQueryKey } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { trackPage } from '../lib/analytics';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';
import { setPathname } from '../lib/persistPathname';
export function GlobalHooks() {
@@ -30,9 +32,13 @@ export function GlobalHooks() {
// Listen for location changes and update the pathname
const location = useLocation();
useEffect(() => {
setPathname(location.pathname);
setPathname(location.pathname).catch(console.error);
}, [location.pathname]);
useEffectOnce(() => {
trackPage('/');
});
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
@@ -82,9 +88,8 @@ export function GlobalHooks() {
}
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(
queryKey,
(values) => values?.map((v) => (modelsEq(v, payload) ? payload : v)),
queryClient.setQueryData<Model[]>(queryKey, (values) =>
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
);
}
});

View File

@@ -32,7 +32,7 @@ export function Overlay({
return (
<Portal name={portalName}>
{open && (
<FocusTrap>
<FocusTrap>
<motion.div
className={classNames('fixed inset-0', zIndexes[zIndex])}
initial={{ opacity: 0 }}
@@ -46,10 +46,6 @@ export function Overlay({
variant === 'default' && 'bg-gray-600/30 dark:bg-black/30 backdrop-blur-sm',
)}
/>
{/* Add region to still be able to drag the window */}
{variant !== 'transparent' && (
<div data-tauri-drag-region className="absolute top-0 left-0 right-0 h-md" />
)}
<div className="bg-red-100">{children}</div>
</motion.div>
</FocusTrap>

View File

@@ -1,18 +1,18 @@
import classNames from 'classnames';
import { useMemo, useRef } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import type { DropdownItem, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import classNames from 'classnames';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
export function RecentRequestsDropdown() {
export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'className'>) {
const dropdownRef = useRef<DropdownRef>(null);
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
@@ -63,7 +63,7 @@ export function RecentRequestsDropdown() {
recentRequestItems.push({
key: request.id,
label: request.name,
leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length + 1} />,
// leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length + 1} />,
onSelect: () => {
routes.navigate('request', {
requestId: request.id,
@@ -76,7 +76,12 @@ export function RecentRequestsDropdown() {
// No recent requests to show
if (recentRequestItems.length === 0) {
return [];
return [
{
label: 'No recent requests',
disabled: true,
},
] as DropdownItem[];
}
return recentRequestItems.slice(0, 20);
@@ -85,9 +90,11 @@ export function RecentRequestsDropdown() {
return (
<Dropdown ref={dropdownRef} items={items}>
<Button
data-tauri-drag-region
size="sm"
className={classNames(
'flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none',
className,
'text-gray-800 text-sm truncate pointer-events-auto',
activeRequest === null && 'text-opacity-disabled italic',
)}
>

View File

@@ -1,24 +1,21 @@
import type { HTMLAttributes, ReactElement } from 'react';
import React, { useRef } from 'react';
import { useRef } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useTheme } from '../hooks/useTheme';
import type { DropdownRef } from './core/Dropdown';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import type { DropdownProps, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { HotKey } from './core/HotKey';
import { Icon } from './core/Icon';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
interface Props {
requestId: string;
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
requestId: string | null;
children: DropdownProps['children'];
}
export function RequestActionsDropdown({ requestId, children }: Props) {
const deleteRequest = useDeleteRequest(requestId);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
const dropdownRef = useRef<DropdownRef>(null);
const { appearance, toggleAppearance } = useTheme();
useListenToTauriEvent('toggle_settings', () => {
dropdownRef.current?.toggle();
@@ -29,6 +26,10 @@ export function RequestActionsDropdown({ requestId, children }: Props) {
duplicateRequest.mutate();
});
if (requestId == null) {
return null;
}
return (
<Dropdown
ref={dropdownRef}
@@ -47,13 +48,6 @@ export function RequestActionsDropdown({ requestId, children }: Props) {
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Yaak Settings' },
{
key: 'appearance',
label: appearance === 'dark' ? 'Light Theme' : 'Dark Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
]}
>
{children}

View File

@@ -4,9 +4,10 @@ import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest } from '../lib/models';
@@ -30,7 +31,6 @@ import { GraphQLEditor } from './GraphQLEditor';
import { HeaderEditor } from './HeaderEditor';
import { ParametersEditor } from './ParameterEditor';
import { UrlBar } from './UrlBar';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
interface Props {
style?: CSSProperties;
@@ -88,7 +88,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
await updateRequest.mutate(patch);
updateRequest.mutate(patch);
},
},
},
@@ -125,7 +125,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
token: authentication.token ?? '',
};
}
await updateRequest.mutate({ authenticationType, authentication });
updateRequest.mutate({ authenticationType, authentication });
},
},
},
@@ -146,7 +146,10 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
'send_request',
async ({ windowLabel }) => {
if (windowLabel !== appWindow.label) return;
await invoke('send_request', { requestId: activeRequestId, environmentId: activeEnvironmentId });
await invoke('send_request', {
requestId: activeRequestId,
environmentId: activeEnvironmentId,
});
},
[activeRequestId, activeEnvironmentId],
);

View File

@@ -1,26 +1,38 @@
import classNames from 'classnames';
import type { ForwardedRef } from 'react';
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import type { ForwardedRef, ReactNode } from 'react';
import React, { forwardRef, Fragment, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useFolders } from '../hooks/useFolders';
import { useKeyValue } from '../hooks/useKeyValue';
import { useLatestResponse } from '../hooks/useLatestResponse';
import { useRequests } from '../hooks/useRequests';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { usePrompt } from '../hooks/usePrompt';
import { useRequests } from '../hooks/useRequests';
import { useSendAnyRequest } from '../hooks/useSendAnyRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Folder, HttpRequest, Workspace } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { DropMarker } from './DropMarker';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { VStack } from './core/Stacks';
interface Props {
className?: string;
@@ -30,59 +42,131 @@ enum ItemTypes {
REQUEST = 'request',
}
export const Sidebar = memo(function Sidebar({ className }: Props) {
interface TreeNode {
item: Workspace | Folder | HttpRequest;
children: TreeNode[];
depth: number;
}
export function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden();
const createRequest = useCreateRequest();
const sidebarRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequestId = useActiveRequestId();
const activeEnvironmentId = useActiveEnvironmentId();
const unorderedRequests = useRequests();
const requests = useRequests();
const folders = useFolders();
const deleteAnyRequest = useDeleteAnyRequest();
const activeWorkspace = useActiveWorkspace();
const routes = useAppRoutes();
const requests = useMemo(
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
[unorderedRequests],
);
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedIndex, setSelectedIndex] = useState<number>();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedTree, setSelectedTree] = useState<TreeNode | null>(null);
const updateAnyRequest = useUpdateAnyRequest();
const updateAnyFolder = useUpdateAnyFolder();
const [draggingId, setDraggingId] = useState<string | null>(null);
const [hoveredTree, setHoveredTree] = useState<TreeNode | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const collapsed = useKeyValue<Record<string, boolean>>({
key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'],
defaultValue: {},
namespace: NAMESPACE_NO_SYNC,
});
const { tree, treeParentMap, selectableRequests } = useMemo<{
tree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
selectableRequests: { id: string; index: number; tree: TreeNode }[];
}>(() => {
const treeParentMap: Record<string, TreeNode> = {};
const selectableRequests: { id: string; index: number; tree: TreeNode }[] = [];
if (activeWorkspace == null) {
return { tree: null, treeParentMap, selectableRequests };
}
let selectableRequestIndex = 0;
// Put requests and folders into a tree structure
const next = (node: TreeNode): TreeNode => {
const childItems = [...requests, ...folders].filter((f) =>
node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id,
);
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
const depth = node.depth + 1;
for (const item of childItems) {
treeParentMap[item.id] = node;
node.children.push(next({ item, children: [], depth }));
if (item.model === 'http_request') {
selectableRequests.push({ id: item.id, index: selectableRequestIndex++, tree: node });
}
}
return node;
};
const tree = next({ item: activeWorkspace, children: [], depth: 0 });
return { tree, treeParentMap, selectableRequests };
}, [activeWorkspace, requests, folders]);
// TODO: Move these listeners to a central place
useListenToTauriEvent('new_request', async () => createRequest.mutate({}));
const focusActiveRequest = useCallback(
(forcedIndex?: number) => {
const index = forcedIndex ?? requests.findIndex((r) => r.id === activeRequestId);
if (index < 0) return;
setSelectedIndex(index >= 0 ? index : undefined);
(args: { forced?: { id: string; tree: TreeNode }; noFocusSidebar?: boolean } = {}) => {
const { forced, noFocusSidebar } = args;
const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const id = forced?.id ?? children.find((m) => m.item.id === activeRequestId)?.item.id ?? null;
if (id == null) {
return;
}
setSelectedId(id);
setSelectedTree(tree);
setHasFocus(true);
sidebarRef.current?.focus();
if (!noFocusSidebar) {
sidebarRef.current?.focus();
}
},
[activeRequestId, requests],
[activeRequestId, treeParentMap],
);
const handleSelect = useCallback(
(requestId: string) => {
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
if (!request) return;
routes.navigate('request', {
requestId,
workspaceId: request.workspaceId,
environmentId: activeEnvironmentId ?? undefined,
});
setSelectedIndex(index);
focusActiveRequest(index);
(id: string) => {
const tree = treeParentMap[id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const node = children.find((m) => m.item.id === id) ?? null;
if (node == null || tree == null || node.item.model === 'workspace') {
return;
}
const { item } = node;
if (item.model === 'folder') {
collapsed.set((c) => ({ ...c, [item.id]: !c[item.id] }));
} else {
routes.navigate('request', {
requestId: id,
workspaceId: item.workspaceId,
environmentId: activeEnvironmentId ?? undefined,
});
setSelectedId(id);
setSelectedTree(tree);
focusActiveRequest({ forced: { id, tree } });
}
},
[focusActiveRequest, requests, routes, activeEnvironmentId],
[treeParentMap, collapsed, routes, activeEnvironmentId, focusActiveRequest],
);
const handleClearSelected = useCallback(() => {
setSelectedIndex(undefined);
}, [setSelectedIndex]);
setSelectedId(null);
setSelectedTree(null);
}, []);
const handleFocus = useCallback(() => {
if (hasFocus) return;
focusActiveRequest();
focusActiveRequest({ noFocusSidebar: true });
}, [focusActiveRequest, hasFocus]);
const handleBlur = useCallback(() => setHasFocus(false), []);
@@ -92,11 +176,11 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
if (!hasFocus) return;
e.preventDefault();
const selectedRequest = requests[selectedIndex ?? -1];
if (selectedRequest === undefined) return;
deleteAnyRequest.mutate(selectedRequest.id);
const selected = selectableRequests.find((r) => r.id === selectedId);
if (selected == null) return;
deleteAnyRequest.mutate(selected.id);
},
[deleteAnyRequest, hasFocus, requests, selectedIndex],
[deleteAnyRequest, hasFocus, selectableRequests, selectedId],
);
useKeyPressEvent('Backspace', handleDeleteKey);
@@ -107,19 +191,26 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
() => {
if (hidden || hasFocus) return;
// Select 0 index on focus if none selected
focusActiveRequest(selectedIndex ?? 0);
focusActiveRequest(
selectedTree != null && selectedId != null
? { forced: { id: selectedId, tree: selectedTree } }
: undefined,
);
},
[focusActiveRequest, hidden, activeRequestId],
);
useKeyPressEvent('Enter', (e) => {
if (!hasFocus) return;
const request = requests[selectedIndex ?? -1];
if (!request || request.id === activeRequestId) return;
const selected = selectableRequests.find((r) => r.id === selectedId);
if (!selected || selected.id === activeRequestId || activeWorkspace == null) {
return;
}
e.preventDefault();
routes.navigate('request', {
requestId: request.id,
workspaceId: request.workspaceId,
requestId: selected.id,
workspaceId: activeWorkspace?.id,
environmentId: activeEnvironmentId ?? undefined,
});
});
@@ -128,160 +219,287 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
'ArrowUp',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? requests.length) - 1;
if (newIndex < 0) {
newIndex = requests.length - 1;
const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newSelectable = selectableRequests[i - 1];
if (newSelectable == null) {
return;
}
setSelectedIndex(newIndex);
setSelectedId(newSelectable.id);
setSelectedTree(newSelectable.tree);
},
undefined,
[hasFocus, requests, selectedIndex],
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
);
useKey(
'ArrowDown',
() => {
if (!hasFocus) return;
let newIndex = (selectedIndex ?? -1) + 1;
if (newIndex > requests.length - 1) {
newIndex = 0;
const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newSelectable = selectableRequests[i + 1];
if (newSelectable == null) {
return;
}
setSelectedIndex(newIndex);
setSelectedId(newSelectable.id);
setSelectedTree(newSelectable.tree);
},
undefined,
[hasFocus, requests, selectedIndex],
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
);
return (
<div aria-hidden={hidden} className="h-full">
<VStack
as="ul"
role="menu"
aria-orientation="vertical"
dir="ltr"
ref={sidebarRef}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
className={classNames(
className,
'h-full pb-3 overflow-y-scroll overflow-x-visible hide-scrollbars pt-2',
)}
>
<SidebarItems
selectedIndex={selectedIndex}
requests={requests}
focused={hasFocus}
onSelect={handleSelect}
onClearSelected={handleClearSelected}
/>
</VStack>
</div>
);
});
interface SidebarItemsProps {
requests: HttpRequest[];
focused: boolean;
selectedIndex?: number;
onSelect: (requestId: string) => void;
onClearSelected: () => void;
}
function SidebarItems({
requests,
focused,
selectedIndex,
onSelect,
onClearSelected,
}: SidebarItemsProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const updateRequest = useUpdateAnyRequest();
const handleMove = useCallback<DraggableSidebarItemProps['onMove']>(
(id, side) => {
const dragIndex = requests.findIndex((r) => r.id === id);
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
const hoveredTree = treeParentMap[id] ?? null;
const dragIndex = hoveredTree?.children.findIndex((n) => n.item.id === id) ?? -99;
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
setHoveredTree(hoveredTree);
setHoveredIndex(hoveredIndex);
},
[requests],
[treeParentMap],
);
const handleDragStart = useCallback<DraggableSidebarItemProps['onDragStart']>((id: string) => {
setDraggingId(id);
}, []);
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
(requestId) => {
if (hoveredIndex === null) return;
setHoveredIndex(null);
onClearSelected();
async (itemId) => {
setHoveredTree(null);
handleClearSelected();
const index = requests.findIndex((r) => r.id === requestId);
const request = requests[index];
if (request === undefined) return;
if (hoveredTree == null || hoveredIndex == null) {
return;
}
const newRequests = requests.filter((r) => r.id !== requestId);
if (hoveredIndex > index) newRequests.splice(hoveredIndex - 1, 0, request);
else newRequests.splice(hoveredIndex, 0, request);
const parentTree = treeParentMap[itemId] ?? null;
const index = parentTree?.children.findIndex((n) => n.item.id === itemId) ?? -1;
const child = parentTree?.children[index ?? -1];
if (child == null || parentTree == null) return;
// Do a simple find because the math is too hard
const newIndex = newRequests.findIndex((r) => r.id === requestId) ?? 0;
const beforePriority = newRequests[newIndex - 1]?.sortPriority ?? 0;
const afterPriority = newRequests[newIndex + 1]?.sortPriority ?? 0;
const movedToDifferentTree = hoveredTree.item.id !== parentTree.item.id;
const movedUpInSameTree = !movedToDifferentTree && hoveredIndex < index;
const newChildren = hoveredTree.children.filter((c) => c.item.id !== itemId);
if (movedToDifferentTree || movedUpInSameTree) {
// Moving up or into a new tree is simply inserting before the hovered item
newChildren.splice(hoveredIndex, 0, child);
} else {
// Moving down has to account for the fact that the original item will be removed
newChildren.splice(hoveredIndex - 1, 0, child);
}
const prev = newChildren[hoveredIndex - 1]?.item;
const next = newChildren[hoveredIndex + 1]?.item;
const beforePriority = prev == null || prev.model === 'workspace' ? 0 : prev.sortPriority;
const afterPriority = next == null || next.model === 'workspace' ? 0 : next.sortPriority;
const folderId = hoveredTree.item.model === 'folder' ? hoveredTree.item.id : null;
const shouldUpdateAll = afterPriority - beforePriority < 1;
if (shouldUpdateAll) {
newRequests.forEach(({ id }, i) => {
const sortPriority = i * 1000;
const update = (r: HttpRequest) => ({ ...r, sortPriority });
updateRequest.mutate({ id, update });
});
await Promise.all(
newChildren.map((child, i) => {
const sortPriority = i * 1000;
if (child.item.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
return updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder });
} else if (child.item.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
return updateAnyRequest.mutateAsync({ id: child.item.id, update: updateRequest });
}
}),
);
} else {
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
const update = (r: HttpRequest) => ({ ...r, sortPriority });
updateRequest.mutate({ id: requestId, update });
if (child.item.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
await updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder });
} else if (child.item.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
await updateAnyRequest.mutateAsync({ id: child.item.id, update: updateRequest });
}
}
setDraggingId(null);
},
[hoveredIndex, requests, updateRequest, onClearSelected],
[
hoveredIndex,
hoveredTree,
handleClearSelected,
treeParentMap,
updateAnyFolder,
updateAnyRequest,
],
);
if (tree == null) {
return null;
}
if (collapsed.value == null) {
return null;
}
return (
<>
{requests.map((r, i) => (
<Fragment key={r.id}>
{hoveredIndex === i && <DropMarker />}
<aside
aria-hidden={hidden}
ref={sidebarRef}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
className={classNames(
className,
'h-full pb-3 overflow-y-scroll overflow-x-visible hide-scrollbars pt-2',
)}
>
<SidebarItems
treeParentMap={treeParentMap}
selectedId={selectedId}
selectedTree={selectedTree}
collapsed={collapsed.value}
tree={tree}
focused={hasFocus}
draggingId={draggingId}
onSelect={handleSelect}
hoveredIndex={hoveredIndex}
hoveredTree={hoveredTree}
handleMove={handleMove}
handleEnd={handleEnd}
handleDragStart={handleDragStart}
/>
</aside>
);
}
interface SidebarItemsProps {
tree: TreeNode;
focused: boolean;
draggingId: string | null;
selectedId: string | null;
selectedTree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
hoveredTree: TreeNode | null;
hoveredIndex: number | null;
handleMove: (id: string, side: 'above' | 'below') => void;
handleEnd: (id: string) => void;
handleDragStart: (id: string) => void;
onSelect: (requestId: string) => void;
collapsed: Record<string, boolean>;
}
function SidebarItems({
tree,
focused,
selectedId,
selectedTree,
draggingId,
onSelect,
treeParentMap,
collapsed,
hoveredTree,
hoveredIndex,
handleEnd,
handleMove,
handleDragStart,
}: SidebarItemsProps) {
return (
<VStack
as="ul"
role="menu"
aria-orientation="vertical"
dir="ltr"
className={classNames(
tree.depth > 0 && 'border-l border-highlight',
tree.depth === 0 && 'ml-0',
tree.depth >= 1 && 'ml-[1.3em]',
)}
>
{tree.children.map((child, i) => (
<Fragment key={child.item.id}>
{hoveredIndex === i && hoveredTree?.item.id === tree.item.id && <DropMarker />}
<DraggableSidebarItem
key={r.id}
selected={selectedIndex === i}
requestId={r.id}
requestName={r.name}
selected={selectedId === child.item.id}
itemId={child.item.id}
itemName={child.item.name}
itemModel={child.item.model}
onMove={handleMove}
onEnd={handleEnd}
useProminentStyles={focused}
onSelect={onSelect}
/>
onDragStart={handleDragStart}
useProminentStyles={focused}
collapsed={collapsed}
child={child}
>
{child.item.model === 'folder' &&
!collapsed[child.item.id] &&
draggingId !== child.item.id && (
<SidebarItems
treeParentMap={treeParentMap}
tree={child}
collapsed={collapsed}
draggingId={draggingId}
hoveredTree={hoveredTree}
hoveredIndex={hoveredIndex}
focused={focused}
selectedId={selectedId}
selectedTree={selectedTree}
onSelect={onSelect}
handleMove={handleMove}
handleEnd={handleEnd}
handleDragStart={handleDragStart}
/>
)}
</DraggableSidebarItem>
</Fragment>
))}
{hoveredIndex === requests.length && <DropMarker />}
</>
{hoveredIndex === tree.children.length && hoveredTree?.item.id === tree.item.id && (
<DropMarker />
)}
</VStack>
);
}
type SidebarItemProps = {
className?: string;
requestId: string;
requestName: string;
itemId: string;
itemName: string;
itemModel: string;
useProminentStyles?: boolean;
selected?: boolean;
onSelect: (requestId: string) => void;
onSelect: (id: string) => void;
draggable?: boolean;
children?: ReactNode;
collapsed: Record<string, boolean>;
child: TreeNode;
};
const _SidebarItem = forwardRef(function SidebarItem(
{ className, requestName, requestId, useProminentStyles, selected, onSelect }: SidebarItemProps,
const SidebarItem = forwardRef(function SidebarItem(
{
children,
className,
itemName,
itemId,
itemModel,
useProminentStyles,
selected,
onSelect,
collapsed,
child,
}: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const latestResponse = useLatestResponse(requestId);
const updateRequest = useUpdateRequest(requestId);
const sendAnyRequest = useSendAnyRequest();
const createRequest = useCreateRequest();
const createFolder = useCreateFolder();
const deleteRequest = useDeleteFolder(itemId);
const latestResponse = useLatestResponse(itemId);
const updateRequest = useUpdateRequest(itemId);
const updateAnyFolder = useUpdateAnyFolder();
const prompt = usePrompt();
const [editing, setEditing] = useState<boolean>(false);
const activeRequestId = useActiveRequestId();
const isActive = activeRequestId === requestId;
const isActive = activeRequestId === itemId;
const handleSubmitNameEdit = useCallback(
(el: HTMLInputElement) => {
@@ -313,7 +531,10 @@ const _SidebarItem = forwardRef(function SidebarItem(
[handleSubmitNameEdit],
);
const handleStartEditing = useCallback(() => setEditing(true), [setEditing]);
const handleStartEditing = useCallback(() => {
if (itemModel !== 'http_request') return;
setEditing(true);
}, [setEditing, itemModel]);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
@@ -323,70 +544,153 @@ const _SidebarItem = forwardRef(function SidebarItem(
);
const handleSelect = useCallback(() => {
onSelect(requestId);
}, [onSelect, requestId]);
onSelect(itemId);
}, [onSelect, itemId]);
return (
<li ref={ref} className={classNames(className, 'block group/item px-2 pb-0.5')}>
<button
// tabIndex={-1} // Will prevent drag-n-drop
onClick={handleSelect}
disabled={editing}
onDoubleClick={handleStartEditing}
data-active={isActive}
data-selected={selected}
className={classNames(
'w-full flex items-center text-sm h-xs px-2 rounded-md transition-colors',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-highlight text-gray-800',
!isActive && 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
selected && useProminentStyles && '!bg-violet-500/20 text-gray-900',
<li ref={ref}>
<div className={classNames(className, 'block relative group/item px-2 pb-0.5')}>
{itemModel === 'folder' && (
<Dropdown
items={[
{
key: 'sendAll',
label: 'Send All',
leftSlot: <Icon icon="paperPlane" />,
onSelect: () => {
for (const { item } of child.children) {
if (item.model === 'http_request') {
sendAnyRequest.mutate(item.id);
}
}
},
},
{ type: 'separator', label: itemName },
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Folder',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: itemName,
});
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
},
},
{
key: 'deleteFolder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteRequest.mutate(),
},
{ type: 'separator' },
{
key: 'createRequest',
label: 'New Request',
leftSlot: <Icon icon="plus" />,
onSelect: () => createRequest.mutate({ folderId: itemId, sortPriority: -1 }),
},
{
key: 'createFolder',
label: 'New Folder',
leftSlot: <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }),
},
]}
>
<IconButton
title="Folder options"
size="xs"
icon="dotsV"
className="ml-auto !bg-transparent absolute right-2 opacity-0 group-hover/item:opacity-70 transition-opacity"
/>
</Dropdown>
)}
>
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={handleBlur}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className={classNames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
{latestResponse && (
<div className="ml-auto">
{isResponseLoading(latestResponse) ? (
<Icon spin size="sm" icon="update" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
)}
</div>
)}
</button>
<button
// tabIndex={-1} // Will prevent drag-n-drop
onClick={handleSelect}
disabled={editing}
onDoubleClick={handleStartEditing}
data-active={isActive}
data-selected={selected}
className={classNames(
'w-full flex items-center text-sm h-xs px-2 rounded-md transition-colors',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-highlightSecondary text-gray-800',
!isActive &&
'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
selected && useProminentStyles && '!bg-violet-400/20 text-gray-950',
)}
>
{itemModel === 'folder' && (
<Icon
size="sm"
icon="chevronRight"
className={classNames(
'-ml-0.5 mr-2 transition-transform',
!collapsed[itemId] && 'transform rotate-90',
)}
/>
)}
{editing ? (
<input
ref={handleFocus}
defaultValue={itemName}
className="bg-transparent outline-none w-full"
onBlur={handleBlur}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className={classNames('truncate', !itemName && 'text-gray-400 italic')}>
{itemName || 'New Request'}
</span>
)}
{latestResponse && (
<div className="ml-auto">
{isResponseLoading(latestResponse) ? (
<Icon spin size="sm" icon="update" />
) : (
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
)}
</div>
)}
</button>
</div>
{children}
</li>
);
});
const SidebarItem = memo(_SidebarItem);
type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onDragStart: (id: string) => void;
children?: ReactNode;
child?: TreeNode;
};
type DragItem = {
id: string;
requestName: string;
itemName: string;
};
const DraggableSidebarItem = memo(function DraggableSidebarItem({
requestName,
requestId,
function DraggableSidebarItem({
itemName,
itemId,
itemModel,
child,
onMove,
onEnd,
onDragStart,
...props
}: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
@@ -400,7 +704,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below');
onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
@@ -409,25 +713,29 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
() => ({
type: ItemTypes.REQUEST,
item: () => ({ id: requestId, requestName }),
item: () => {
onDragStart(itemId);
return { id: itemId, itemName };
},
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(requestId),
end: () => onEnd(itemId),
}),
[onEnd],
);
connectDrag(ref);
connectDrop(ref);
connectDrag(connectDrop(ref));
return (
<SidebarItem
ref={ref}
draggable
className={classNames(isDragging && 'opacity-20')}
requestName={requestName}
requestId={requestId}
itemName={itemName}
itemId={itemId}
itemModel={itemModel}
child={child}
{...props}
/>
);
});
}

View File

@@ -1,11 +1,14 @@
import { memo } from 'react';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { IconButton } from './core/IconButton';
import { useCreateFolder } from '../hooks/useCreateFolder';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { Dropdown } from './core/Dropdown';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
export const SidebarActions = memo(function SidebarActions() {
const createRequest = useCreateRequest();
const createFolder = useCreateFolder();
const { hidden, toggle } = useSidebarHidden();
return (
@@ -19,12 +22,22 @@ export const SidebarActions = memo(function SidebarActions() {
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
)}
<IconButton
size="sm"
icon="plusCircle"
title="Create Request"
onClick={() => createRequest.mutate({})}
/>
<Dropdown
items={[
{
key: 'create-request',
label: 'Create Request',
onSelect: () => createRequest.mutate({}),
},
{
key: 'create-folder',
label: 'Create Folder',
onSelect: () => createFolder.mutate({}),
},
]}
>
<IconButton size="sm" icon="plusCircle" title="Add Resource" />
</Dropdown>
</HStack>
);
});

View File

@@ -3,9 +3,9 @@ import type { EditorView } from 'codemirror';
import type { FormEvent } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendRequest } from '../hooks/useSendRequest';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { IconButton } from './core/IconButton';
@@ -35,7 +35,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
sendRequest();
sendRequest.mutate();
},
[sendRequest],
);

View File

@@ -1,24 +1,28 @@
import { invoke } from '@tauri-apps/api';
import { open } from '@tauri-apps/api/dialog';
import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useTheme } from '../hooks/useTheme';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button';
import type { Environment, Folder, HttpRequest, Workspace } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
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 { HStack, VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown'>;
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown' | 'leftSlot'>;
export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
className,
@@ -30,10 +34,72 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const { appearance, toggleAppearance } = useTheme();
const dialog = useDialog();
const prompt = usePrompt();
const routes = useAppRoutes();
const importData = useCallback(async () => {
const selected = await open({
multiple: true,
filters: [
{
name: 'Export File',
extensions: ['json'],
},
],
});
if (selected == null || selected.length === 0) return;
const imported: {
workspaces: Workspace[];
environments: Environment[];
folders: Folder[];
requests: HttpRequest[];
} = await invoke('import_data', {
filePaths: selected,
});
const importedWorkspace = imported.workspaces[0];
dialog.show({
title: 'Import Complete',
size: 'dynamic',
hideX: true,
render: ({ hide }) => {
const { workspaces, environments, folders, requests } = imported;
return (
<VStack space={3}>
<ul className="list-disc pl-6">
<li>
{workspaces.length} {pluralize('Workspace', workspaces.length)}
</li>
<li>
{environments.length} {pluralize('Environment', environments.length)}
</li>
<li>
{folders.length} {pluralize('Folder', folders.length)}
</li>
<li>
{requests.length} {pluralize('Request', requests.length)}
</li>
</ul>
<div>
<Button className="ml-auto" onClick={hide} color="primary">
Done
</Button>
</div>
</VStack>
);
},
});
if (importedWorkspace != null) {
routes.navigate('workspace', {
workspaceId: importedWorkspace.id,
environmentId: imported.environments[0]?.id,
});
}
}, [routes, dialog]);
const items: DropdownItem[] = useMemo(() => {
const workspaceItems: DropdownItem[] = workspaces.map((w) => ({
key: w.id,
@@ -51,7 +117,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
),
render: ({ hide }) => {
return (
<HStack space={2} justifyContent="end" className="mt-6">
<HStack space={2} justifyContent="end" alignItems="center" className="mt-6">
<Button
className="focus"
color="gray"
@@ -133,22 +199,36 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const name = await prompt({
name: 'name',
label: 'Name',
defaultValue: '',
description: 'Enter a name for the new workspace',
defaultValue: 'My Workspace',
title: 'Create Workspace',
});
createWorkspace.mutate({ name });
},
},
{
key: 'import',
label: 'Import Data',
onSelect: importData,
leftSlot: <Icon icon="download" />,
},
{
key: 'appearance',
label: 'Toggle Theme',
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
];
}, [
activeWorkspace?.name,
activeWorkspaceId,
appearance,
createWorkspace,
deleteWorkspace.mutate,
dialog,
importData,
prompt,
routes,
toggleAppearance,
updateWorkspace,
workspaces,
]);
@@ -156,7 +236,6 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
return (
<Dropdown items={items}>
<Button
forDropdown
size="sm"
className={classNames(className, 'text-gray-800 !px-2 truncate')}
{...buttonProps}

View File

@@ -1,12 +1,14 @@
import classNames from 'classnames';
import { memo } from 'react';
import React, { memo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
interface Props {
@@ -15,6 +17,7 @@ interface Props {
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const activeRequest = useActiveRequest();
const activeWorkspace = useActiveWorkspace();
return (
<HStack
@@ -24,23 +27,30 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
>
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
<SidebarActions />
<WorkspaceActionsDropdown />
<EnvironmentActionsDropdown className="pointer-events-auto" />
<HStack alignItems="center">
<WorkspaceActionsDropdown
leftSlot={
<div className="w-4 h-4 leading-4 rounded text-[0.8em] bg-[#1B88DE] bg-opacity-80 text-white mr-1">
{activeWorkspace?.name[0]?.toUpperCase()}
</div>
}
/>
<Icon icon="chevronRight" className="text-gray-900 text-opacity-disabled" />
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
</HStack>
</HStack>
<div className="pointer-events-none">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
{activeRequest && (
<RequestActionsDropdown requestId={activeRequest?.id}>
<IconButton
size="sm"
title="Request Options"
icon="gear"
className="pointer-events-auto"
/>
</RequestActionsDropdown>
)}
<RequestActionsDropdown requestId={activeRequest?.id ?? null}>
<IconButton
size="sm"
title="Request Options"
icon="gear"
className="pointer-events-auto"
/>
</RequestActionsDropdown>
</div>
</HStack>
);

View File

@@ -51,6 +51,7 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
() =>
classNames(
className,
'max-w-full min-w-0', // Help with truncation
'whitespace-nowrap outline-none',
'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring rounded-md',
@@ -72,7 +73,15 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
) : leftSlot ? (
<div className="mr-1">{leftSlot}</div>
) : null}
<div className={classNames('max-w-[15em] truncate w-full text-left', innerClassName)}>{children}</div>
<div
className={classNames(
'max-w-[15em] truncate w-full',
justify === 'start' ? 'text-left' : 'text-center',
innerClassName,
)}
>
{children}
</div>
{rightSlot && <div className="ml-1">{rightSlot}</div>}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</button>

View File

@@ -1,6 +1,13 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
import type {
CSSProperties,
FocusEvent as ReactFocusEvent,
HTMLAttributes,
MouseEvent,
ReactElement,
ReactNode,
} from 'react';
import React, {
Children,
cloneElement,
@@ -13,10 +20,10 @@ import React, {
useState,
} from 'react';
import { useKey, useKeyPressEvent, useWindowSize } from 'react-use';
import { Overlay } from '../Overlay';
import { Button } from './Button';
import { Separator } from './Separator';
import { VStack } from './Stacks';
import { Overlay } from '../Overlay';
export type DropdownItemSeparator = {
type: 'separator';
@@ -108,7 +115,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
const windowSize = useWindowSize();
const triggerRect = useMemo(() => {
windowSize; // Make TS happy with this dep
if (!windowSize) return null; // No-op to TS happy with this dep
if (!open) return null;
return buttonRef.current?.getBoundingClientRect();
}, [open, windowSize]);
@@ -334,7 +341,13 @@ interface MenuItemProps {
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]);
const handleFocus = useCallback(() => onFocus?.(item), [item, onFocus]);
const handleFocus = useCallback(
(e: ReactFocusEvent<HTMLButtonElement>) => {
e.stopPropagation(); // Don't trigger focus on any parents
return onFocus?.(item);
},
[item, onFocus],
);
const initRef = useCallback(
(el: HTMLButtonElement | null) => {
@@ -355,6 +368,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
tabIndex={-1}
onMouseEnter={(e) => e.currentTarget.focus()}
onMouseLeave={(e) => e.currentTarget.blur()}
disabled={item.disabled}
onFocus={handleFocus}
onClick={handleClick}
justify="start"
@@ -366,6 +380,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
'focus:bg-highlight focus:text-gray-900 rounded',
item.variant === 'danger' && 'text-red-600',
)}
innerClassName="!text-left"
{...props}
>
<div

View File

@@ -6,6 +6,11 @@
* {
@apply cursor-text;
@apply caret-transparent !important;
}
.cm-cursor {
@apply border-gray-800 !important;
}
&.cm-focused {
@@ -17,7 +22,7 @@
}
.cm-line {
@apply text-gray-800 caret-gray-800 pl-1 pr-1.5;
@apply text-gray-800 pl-1 pr-1.5;
}
.cm-placeholder {
@@ -58,7 +63,7 @@
-webkit-text-security: none;
&.placeholder-widget-error {
@apply bg-red-300/40 border-red-300 border-opacity-40;
@apply bg-red-300/40 border-red-300/80 border-opacity-40 hover:border-red-300 hover:bg-red-300/50;
}
}
}
@@ -159,7 +164,7 @@
@apply h-full flex items-center;
/* Break characters on line wrapping mode, useful for URL field.
* We can make this dynamic if we need it to be configurable later
* We can make this dynamic if we need it to be configurable later
*/
&.cm-lineWrapping {
@apply break-all;

View File

@@ -6,13 +6,14 @@ import classNames from 'classnames';
import { EditorView } from 'codemirror';
import type { MutableRefObject, ReactNode } from 'react';
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import type { GenericCompletionConfig } from './genericCompletion';
import { singleLineExt } from './singleLine';
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
// Export some things so all the code-split parts are in this file
export { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities';
@@ -72,7 +73,9 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
ref,
) {
const e = useActiveEnvironment();
const w = useActiveWorkspace();
const environment = autocompleteVariables ? e : null;
const workspace = autocompleteVariables ? w : null;
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view);
@@ -124,9 +127,15 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({ contentType, environment, useTemplating, autocomplete });
const ext = getLanguageExtension({
contentType,
environment,
workspace,
useTemplating,
autocomplete,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating, environment]);
}, [contentType, autocomplete, useTemplating, environment, workspace]);
useEffect(() => {
if (cm.current === null) return;
@@ -152,6 +161,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
useTemplating,
autocomplete,
environment,
workspace,
});
const state = EditorState.create({
@@ -221,10 +231,14 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
onClick={() => {
if (cm.current === null) return;
const { doc } = cm.current.view.state;
const insert = format(doc.toString());
const formatted = format(doc.toString());
// Update editor and blur because the cursor will reset anyway
cm.current.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
cm.current.view.dispatch({
changes: { from: 0, to: doc.length, insert: formatted },
});
cm.current.view.contentDOM.blur();
// Fire change event
onChange?.(formatted);
}}
/>
</HStack>
@@ -271,7 +285,7 @@ function getExtensions({
EditorView.domEventHandlers({
focus: () => onFocus.current?.(),
blur: () => onBlur.current?.(),
keydown: e => onKeyDown.current?.(e),
keydown: (e) => onKeyDown.current?.(e),
}),
// Handle onChange

View File

@@ -32,11 +32,11 @@ import {
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import type { Environment, Workspace } from '../../../lib/models';
import type { EditorProps } from './index';
import { text } from './text/extension';
import { twig } from './twig/extension';
import { url } from './url/extension';
import type { Environment } from '../../../lib/models';
export const myHighlightStyle = HighlightStyle.define([
{
@@ -96,8 +96,9 @@ export function getLanguageExtension({
contentType,
useTemplating = false,
environment,
workspace,
autocomplete,
}: { environment: Environment | null } & Pick<
}: { environment: Environment | null; workspace: Workspace | null } & Pick<
EditorProps,
'contentType' | 'useTemplating' | 'autocomplete'
>) {
@@ -110,20 +111,20 @@ export function getLanguageExtension({
return base;
}
return twig(base, environment, autocomplete);
return twig(base, environment, workspace, autocomplete);
}
export const baseExtensions = [
highlightSpecialChars(),
history(),
dropCursor(),
drawSelection(),
bracketMatching(),
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({
// closeOnBlur: false,
interactionDelay: 200,
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
@@ -147,7 +148,6 @@ export const multiLineExtensions = [
},
}),
EditorState.allowMultipleSelections.of(true),
drawSelection(),
indentOnInput(),
closeBrackets(),
rectangularSelection(),

View File

@@ -7,14 +7,17 @@ import { placeholders } from './placeholder';
import { textLanguageName } from '../text/extension';
import { twigCompletion } from './completion';
import { parser as twigParser } from './twig';
import type { Environment } from '../../../../lib/models';
import type { Environment, Workspace } from '../../../../lib/models';
export function twig(
base: LanguageSupport,
environment: Environment | null,
workspace: Workspace | null,
autocomplete?: GenericCompletionConfig,
) {
const variables = environment?.variables.filter(v => v.enabled) ?? [];
const variables =
[...(workspace?.variables ?? []), ...(environment?.variables ?? [])].filter((v) => v.enabled) ??
[];
const completions = twigCompletion({ options: variables });
const language = mixLanguage(base);

View File

@@ -16,6 +16,7 @@ class PlaceholderWidget extends WidgetType {
elt.className = `placeholder-widget ${
!this.isExistingVariable ? 'placeholder-widget-error' : ''
}`;
elt.title = !this.isExistingVariable ? 'Variable not found in active environment' : '';
elt.textContent = this.name;
return elt;
}

View File

@@ -4,6 +4,7 @@ import {
CheckboxIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClockIcon,
CodeIcon,
ColorWheelIcon,
@@ -12,6 +13,7 @@ import {
DividerHorizontalIcon,
DotsHorizontalIcon,
DotsVerticalIcon,
DownloadIcon,
DragHandleDots2Icon,
EyeClosedIcon,
EyeOpenIcon,
@@ -49,12 +51,14 @@ const icons = {
checkbox: CheckboxIcon,
clock: ClockIcon,
chevronDown: ChevronDownIcon,
chevronRight: ChevronRightIcon,
code: CodeIcon,
colorWheel: ColorWheelIcon,
copy: CopyIcon,
dividerH: DividerHorizontalIcon,
dotsH: DotsHorizontalIcon,
dotsV: DotsVerticalIcon,
download: DownloadIcon,
drag: DragHandleDots2Icon,
eye: EyeOpenIcon,
eyeClosed: EyeClosedIcon,

View File

@@ -8,8 +8,8 @@ interface Props {
}
export function StatusTag({ response, className, showReason }: Props) {
const { status, error } = response;
const label = error ? 'ERR' : status;
const { status } = response;
const label = status < 100 ? 'ERR' : status;
return (
<span
className={classNames(

View File

@@ -53,18 +53,18 @@ export function useAppRoutes() {
}, [nav]);
const setEnvironment = useCallback(
({ id: environmentId }: Environment) => {
(environment: Environment | null) => {
if (workspaceId == null) {
navigate('workspaces');
} else if (requestId == null) {
navigate('workspace', {
workspaceId: workspaceId,
environmentId: environmentId ?? null,
environmentId: environment == null ? undefined : environment.id,
});
} else {
navigate('request', {
workspaceId,
environmentId: environmentId ?? null,
environmentId: environment == null ? undefined : environment.id,
requestId,
});
}

View File

@@ -1,9 +1,10 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Environment } from '../lib/models';
import { environmentsQueryKey, useEnvironments } from './useEnvironments';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { environmentsQueryKey, useEnvironments } from './useEnvironments';
import { usePrompt } from './usePrompt';
import { useWorkspaces } from './useWorkspaces';
@@ -20,7 +21,6 @@ export function useCreateEnvironment() {
const name = await prompt({
name: 'name',
title: 'Create Environment',
description: 'Enter a name for the new environment',
label: 'Name',
defaultValue: 'My Environment',
});
@@ -30,6 +30,7 @@ export function useCreateEnvironment() {
: [];
return invoke('create_environment', { name, variables, workspaceId });
},
onSettled: () => trackEvent('environment', 'create'),
onSuccess: async (environment) => {
if (workspaceId == null) return;
routes.setEnvironment(environment);

View File

@@ -0,0 +1,26 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Folder } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { foldersQueryKey } from './useFolders';
export function useCreateFolder() {
const workspaceId = useActiveWorkspaceId();
const queryClient = useQueryClient();
return useMutation<Folder, unknown, Partial<Pick<Folder, 'name' | 'sortPriority' | 'folderId'>>>({
mutationFn: (patch) => {
if (workspaceId === null) {
throw new Error("Cannot create folder when there's no active workspace");
}
patch.name = patch.name || 'New Folder';
patch.sortPriority = patch.sortPriority || Date.now();
return invoke('create_folder', { workspaceId, ...patch });
},
onSettled: () => trackEvent('folder', 'create'),
onSuccess: async (request) => {
await queryClient.invalidateQueries(foldersQueryKey({ workspaceId: request.workspaceId }));
},
});
}

View File

@@ -1,10 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { requestsQueryKey, useRequests } from './useRequests';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
export function useCreateRequest() {
const workspaceId = useActiveWorkspaceId();
@@ -13,7 +14,11 @@ export function useCreateRequest() {
const requests = useRequests();
const queryClient = useQueryClient();
return useMutation<HttpRequest, unknown, Partial<Pick<HttpRequest, 'name' | 'sortPriority'>>>({
return useMutation<
HttpRequest,
unknown,
Partial<Pick<HttpRequest, 'name' | 'sortPriority' | 'folderId'>>
>({
mutationFn: (patch) => {
if (workspaceId === null) {
throw new Error("Cannot create request when there's no active workspace");
@@ -22,6 +27,7 @@ export function useCreateRequest() {
patch.sortPriority = patch.sortPriority || maxSortPriority(requests) + 1000;
return invoke('create_request', { workspaceId, ...patch });
},
onSettled: () => trackEvent('http_request', 'create'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { Workspace } from '../lib/models';
import { useAppRoutes } from './useAppRoutes';
import { workspacesQueryKey } from './useWorkspaces';
@@ -11,6 +12,7 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }
mutationFn: (patch) => {
return invoke('create_workspace', patch);
},
onSettled: () => trackEvent('workspace', 'create'),
onSuccess: async (workspace) => {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
...(workspaces ?? []),

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import type { HttpRequest } from '../lib/models';
import { getRequest } from '../lib/store';
import { useConfirm } from './useConfirm';
@@ -26,6 +27,7 @@ export function useDeleteAnyRequest() {
if (!confirmed) return null;
return invoke('delete_request', { requestId: id });
},
onSettled: () => trackEvent('http_request', 'delete'),
onSuccess: async (request) => {
// Was it cancelled?
if (request === null) return;

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import type { Environment, Workspace } from '../lib/models';
import { useConfirm } from './useConfirm';
import { environmentsQueryKey } from './useEnvironments';
@@ -23,13 +24,13 @@ export function useDeleteEnvironment(environment: Environment | null) {
if (!confirmed) return null;
return invoke('delete_environment', { environmentId: environment?.id });
},
onSettled: () => trackEvent('environment', 'delete'),
onSuccess: async (environment) => {
if (environment === null) return;
const { id: environmentId, workspaceId } = environment;
queryClient.setQueryData<Workspace[]>(
environmentsQueryKey({ workspaceId }),
(environments) => environments?.filter((e) => e.id !== environmentId),
queryClient.setQueryData<Workspace[]>(environmentsQueryKey({ workspaceId }), (environments) =>
environments?.filter((e) => e.id !== environmentId),
);
},
});

View File

@@ -0,0 +1,42 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import type { Folder } from '../lib/models';
import { getFolder } from '../lib/store';
import { useConfirm } from './useConfirm';
import { foldersQueryKey } from './useFolders';
import { requestsQueryKey } from './useRequests';
export function useDeleteFolder(id: string | null) {
const queryClient = useQueryClient();
const confirm = useConfirm();
return useMutation<Folder | null, string>({
mutationFn: async () => {
const folder = await getFolder(id);
const confirmed = await confirm({
title: 'Delete Folder',
variant: 'delete',
description: (
<>
Permanently delete <InlineCode>{folder?.name}</InlineCode> and everything in it?
</>
),
});
if (!confirmed) return null;
return invoke('delete_folder', { folderId: id });
},
onSettled: () => trackEvent('folder', 'delete'),
onSuccess: async (folder) => {
// Was it cancelled?
if (folder === null) return;
const { workspaceId } = folder;
// Nesting makes it hard to clean things up, so just clear everything that could have been deleted
await queryClient.invalidateQueries(requestsQueryKey({ workspaceId }));
await queryClient.invalidateQueries(foldersQueryKey({ workspaceId }));
},
});
}

View File

@@ -1,40 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { useMutation } from '@tanstack/react-query';
import type { HttpRequest } from '../lib/models';
import { getRequest } from '../lib/store';
import { useConfirm } from './useConfirm';
import { requestsQueryKey } from './useRequests';
import { responsesQueryKey } from './useResponses';
import { useDeleteAnyRequest } from './useDeleteAnyRequest';
export function useDeleteRequest(id: string | null) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const deleteAnyRequest = useDeleteAnyRequest();
return useMutation<HttpRequest | null, string>({
mutationFn: async () => {
const request = await getRequest(id);
const confirmed = await confirm({
title: 'Delete Request',
variant: 'delete',
description: (
<>
Permanently delete <InlineCode>{request?.name}</InlineCode>?
</>
),
});
if (!confirmed) return null;
return invoke('delete_request', { requestId: id });
},
onSuccess: async (request) => {
// Was it cancelled?
if (request === null) return;
const { workspaceId, id: requestId } = request;
queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
(requests ?? []).filter((r) => r.id !== requestId),
);
},
mutationFn: () => deleteAnyRequest.mutateAsync(id ?? 'n/a'),
});
}

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpResponse } from '../lib/models';
import { responsesQueryKey } from './useResponses';
@@ -9,6 +10,7 @@ export function useDeleteResponse(id: string | null) {
mutationFn: async () => {
return await invoke('delete_response', { id: id });
},
onSettled: () => trackEvent('http_response', 'delete'),
onSuccess: ({ requestId, id: responseId }) => {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey({ requestId }), (responses) =>
(responses ?? []).filter((response) => response.id !== responseId),

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import { responsesQueryKey } from './useResponses';
export function useDeleteResponses(requestId?: string) {
@@ -9,6 +10,7 @@ export function useDeleteResponses(requestId?: string) {
if (requestId === undefined) return;
await invoke('delete_all_responses', { requestId });
},
onSettled: () => trackEvent('http_response', 'delete_many'),
onSuccess: async () => {
if (requestId === undefined) return;
queryClient.setQueryData(responsesQueryKey({ requestId }), []);

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import type { Workspace } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
@@ -28,6 +29,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
if (!confirmed) return null;
return invoke('delete_workspace', { workspaceId: workspace?.id });
},
onSettled: () => trackEvent('workspace', 'delete'),
onSuccess: async (workspace) => {
if (workspace === null) return;

View File

@@ -1,10 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { requestsQueryKey } from './useRequests';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
export function useDuplicateRequest({
id,
@@ -22,6 +23,7 @@ export function useDuplicateRequest({
if (id === null) throw new Error("Can't duplicate a null request");
return invoke('duplicate_request', { id });
},
onSettled: () => trackEvent('http_request', 'duplicate'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Folder, HttpRequest } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
export function foldersQueryKey({ workspaceId }: { workspaceId: string }) {
return ['folders', { workspaceId }];
}
export function useFolders() {
const workspaceId = useActiveWorkspaceId();
return (
useQuery({
enabled: workspaceId != null,
queryKey: foldersQueryKey({ workspaceId: workspaceId ?? 'n/a' }),
queryFn: async () => {
if (workspaceId == null) return [];
return (await invoke('list_folders', { workspaceId })) as Folder[];
},
}).data ?? []
);
}

View File

@@ -6,8 +6,8 @@ import { minPromiseMillis } from '../lib/minPromiseMillis';
import type { HttpRequest } from '../lib/models';
import { getResponseBodyText } from '../lib/responseBody';
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
import { useDebouncedValue } from './useDebouncedValue';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useDebouncedValue } from './useDebouncedValue';
const introspectionRequestBody = JSON.stringify({
query: getIntrospectionQuery(),
@@ -66,7 +66,7 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
runIntrospection(); // Run immediately
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [request.id, request.url, request.method, refetchKey]);
}, [request.id, request.url, request.method, refetchKey, activeEnvironmentId]);
const refetch = useCallback(() => {
setRefetchKey((k) => k + 1);

View File

@@ -0,0 +1,13 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { HttpResponse } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
export function useSendAnyRequest() {
const environmentId = useActiveEnvironmentId();
return useMutation<HttpResponse, string, string | null>({
mutationFn: (id) => invoke('send_request', { requestId: id, environmentId }),
onSettled: () => trackEvent('http_request', 'send'),
});
}

View File

@@ -1,11 +1,10 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpResponse } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useSendAnyRequest } from './useSendAnyRequest';
export function useSendRequest(id: string | null) {
const environmentId = useActiveEnvironmentId();
const sendAnyRequest = useSendAnyRequest();
return useMutation<HttpResponse, string>({
mutationFn: () => invoke('send_request', { requestId: id, environmentId }),
}).mutate;
mutationFn: () => sendAnyRequest.mutateAsync(id),
});
}

View File

@@ -0,0 +1,28 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Folder, HttpRequest } from '../lib/models';
import { getFolder, getRequest } from '../lib/store';
import { requestsQueryKey } from './useRequests';
import { foldersQueryKey } from './useFolders';
export function useUpdateAnyFolder() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { id: string; update: (r: Folder) => Folder }>({
mutationFn: async ({ id, update }) => {
const folder = await getFolder(id);
if (folder === null) {
throw new Error("Can't update a null folder");
}
await invoke('update_folder', { folder: update(folder) });
},
onMutate: async ({ id, update }) => {
const folder = await getFolder(id);
if (folder === null) return;
queryClient.setQueryData<Folder[]>(foldersQueryKey(folder), (folders) =>
(folders ?? []).map((f) => (f.id === folder.id ? update(f) : f)),
);
},
});
}

View File

@@ -7,20 +7,28 @@ import { requestsQueryKey } from './useRequests';
export function useUpdateAnyRequest() {
const queryClient = useQueryClient();
return useMutation<void, unknown, { id: string; update: (r: HttpRequest) => HttpRequest }>({
return useMutation<
void,
unknown,
{ id: string; update: Partial<HttpRequest> | ((r: HttpRequest) => HttpRequest) }
>({
mutationFn: async ({ id, update }) => {
const request = await getRequest(id);
if (request === null) {
throw new Error("Can't update a null request");
}
await invoke('update_request', { request: update(request) });
const patchedRequest =
typeof update === 'function' ? update(request) : { ...request, ...update };
await invoke('update_request', { request: patchedRequest });
},
onMutate: async ({ id, update }) => {
const request = await getRequest(id);
if (request === null) return;
const patchedRequest =
typeof update === 'function' ? update(request) : { ...request, ...update };
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(request), (requests) =>
(requests ?? []).map((r) => (r.id === request.id ? update(r) : r)),
(requests ?? []).map((r) => (r.id === patchedRequest.id ? patchedRequest : r)),
);
},
});

View File

@@ -1,29 +1,10 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useMutation } from '@tanstack/react-query';
import type { HttpRequest } from '../lib/models';
import { getRequest } from '../lib/store';
import { requestsQueryKey } from './useRequests';
import { useUpdateAnyRequest } from './useUpdateAnyRequest';
export function useUpdateRequest(id: string | null) {
const queryClient = useQueryClient();
const updateAnyRequest = useUpdateAnyRequest();
return useMutation<void, unknown, Partial<HttpRequest> | ((r: HttpRequest) => HttpRequest)>({
mutationFn: async (v) => {
const request = await getRequest(id);
if (request == null) {
throw new Error("Can't update a null request");
}
const newRequest = typeof v === 'function' ? v(request) : { ...request, ...v };
await invoke('update_request', { request: newRequest });
},
onMutate: async (v) => {
const request = await getRequest(id);
if (request === null) return;
const patchedRequest = typeof v === 'function' ? v(request) : { ...request, ...v };
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(request), (requests) =>
(requests ?? []).map((r) => (r.id === patchedRequest.id ? patchedRequest : r)),
);
},
mutationFn: async (update) => updateAnyRequest.mutateAsync({ id: id ?? 'n/a', update }),
});
}

58
src-web/lib/analytics.ts Normal file
View File

@@ -0,0 +1,58 @@
import { getVersion } from '@tauri-apps/api/app';
import type { Environment, Folder, HttpRequest, HttpResponse, KeyValue, Workspace } from './models';
const appVersion = await getVersion();
export function trackEvent(
resource:
| Workspace['model']
| Environment['model']
| Folder['model']
| HttpRequest['model']
| HttpResponse['model']
| KeyValue['model'],
event: 'create' | 'update' | 'delete' | 'delete_many' | 'send' | 'duplicate',
attributes: Record<string, string | number> = {},
) {
send('/e', [
{ name: 'e', value: `${resource}.${event}` },
{ name: 'a', value: JSON.stringify({ ...attributes, version: appVersion }) },
]);
}
export function trackPage(pathname: string) {
if (pathname === sessionStorage.lastPathName) {
return;
}
sessionStorage.lastPathName = pathname;
send('/p', [
{
name: 'h',
value: 'desktop.yaak.app',
},
{ name: 'p', value: pathname },
]);
}
function send(path: string, params: { name: string; value: string | number }[]) {
if (localStorage.disableAnalytics === 'true') {
console.log('Analytics disabled', path, params);
}
params.push({ name: 'id', value: 'site_zOK0d7jeBy2TLxFCnZ' });
params.push({
name: 'tz',
value: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
params.push({ name: 'xy', value: screensize() });
const qs = params.map((v) => `${v.name}=${encodeURIComponent(v.value)}`).join('&');
const url = `https://t.yaak.app/t${path}?${qs}`;
fetch(url, { mode: 'no-cors' }).catch((err) => console.log('Error:', err));
}
function screensize() {
const w = window.screen.width;
const h = window.screen.height;
return `${Math.round(w / 100) * 100}x${Math.round(h / 100) * 100}`;
}

View File

@@ -1,4 +1,3 @@
export const BODY_TYPE_NONE = null;
export const BODY_TYPE_GRAPHQL = 'graphql';
export const BODY_TYPE_JSON = 'application/json';
@@ -20,6 +19,7 @@ export interface Workspace extends BaseModel {
readonly model: 'workspace';
name: string;
description: string;
variables: EnvironmentVariable[];
}
export interface EnvironmentVariable {
@@ -28,6 +28,14 @@ export interface EnvironmentVariable {
enabled?: boolean;
}
export interface Folder extends BaseModel {
readonly workspaceId: string;
readonly model: 'folder';
folderId: string | null;
sortPriority: number;
name: string;
}
export interface Environment extends BaseModel {
readonly workspaceId: string;
readonly model: 'environment';
@@ -44,6 +52,7 @@ export interface HttpHeader {
export interface HttpRequest extends BaseModel {
readonly workspaceId: string;
readonly model: 'http_request';
folderId: string | null;
sortPriority: number;
name: string;
url: string;
@@ -51,8 +60,6 @@ export interface HttpRequest extends BaseModel {
bodyType: string | null;
authentication: Record<string, string | number | boolean | null | undefined>;
authenticationType: string | null;
auth: Record<string, string | number | null>;
authType: string | null;
method: string;
headers: HttpHeader[];
}
@@ -80,7 +87,7 @@ export interface HttpResponse extends BaseModel {
}
export function isResponseLoading(response: HttpResponse): boolean {
return !(response.body || response.status || response.error);
return response.elapsed === 0;
}
export function modelsEq(a: Model, b: Model) {

View File

@@ -1,8 +1,11 @@
import { invoke } from '@tauri-apps/api';
import type { HttpRequest, HttpResponse } from './models';
export async function sendEphemeralRequest(request: HttpRequest, environmentId: string | null): Promise<HttpResponse> {
export async function sendEphemeralRequest(
request: HttpRequest,
environmentId: string | null,
): Promise<HttpResponse> {
// Remove some things that we don't want to associate
const newRequest = { ...request, id: '', requestId: '', workspaceId: '' };
const newRequest = { ...request };
return invoke('send_ephemeral_request', { request: newRequest, environmentId });
}

View File

@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api';
import type { Environment, HttpRequest, Workspace } from './models';
import type { Environment, Folder, HttpRequest, Workspace } from './models';
export async function getRequest(id: string | null): Promise<HttpRequest | null> {
if (id === null) return null;
@@ -19,6 +19,15 @@ export async function getEnvironment(id: string | null): Promise<Environment | n
return environment;
}
export async function getFolder(id: string | null): Promise<Folder | null> {
if (id === null) return null;
const folder: Folder = (await invoke('get_folder', { id })) ?? null;
if (folder == null) {
return null;
}
return folder;
}
export async function getWorkspace(id: string | null): Promise<Workspace | null> {
if (id === null) return null;
const workspace: Workspace = (await invoke('get_workspace', { id })) ?? null;

View File

@@ -40,9 +40,10 @@ export const appThemeVariants: AppThemeColorVariant[] = [
];
export type AppThemeLayer = 'root' | 'sidebar' | 'titlebar' | 'content' | 'above';
export type AppThemeColors = Record<AppThemeColor, string>;
export interface AppThemeLayerStyle {
colors: Record<AppThemeColor, string>;
colors: AppThemeColors;
blackPoint?: number;
whitePoint?: number;
}

View File

@@ -1,24 +1,43 @@
import type { AppTheme } from './theme';
import type { AppTheme, AppThemeColors } from './theme';
import { generateCSS, toTailwindVariable } from './theme';
export type Appearance = 'dark' | 'light';
enum Theme {
yaak = 'yaak',
catppuccin = 'catppuccin',
}
const themes: Record<Theme, AppThemeColors> = {
yaak: {
gray: 'hsl(245, 23%, 45%)',
red: 'hsl(342,100%, 63%)',
orange: 'hsl(32, 98%, 54%)',
yellow: 'hsl(52, 79%, 58%)',
green: 'hsl(136, 62%, 54%)',
blue: 'hsl(206, 100%, 56%)',
pink: 'hsl(300, 100%, 71%)',
violet: 'hsl(266, 100%, 73%)',
},
catppuccin: {
gray: 'hsl(240, 23%, 47%)',
red: 'hsl(343, 91%, 74%)',
orange: 'hsl(23, 92%, 74%)',
yellow: 'hsl(41, 86%, 72%)',
green: 'hsl(115, 54%, 65%)',
blue: 'hsl(217, 92%, 65%)',
pink: 'hsl(316, 72%, 75%)',
violet: 'hsl(267, 84%, 70%)',
},
};
const darkTheme: AppTheme = {
name: 'Default Dark',
appearance: 'dark',
layers: {
root: {
blackPoint: 0.2,
colors: {
gray: '#6b5b98',
red: '#ff417b',
orange: '#fd9014',
yellow: '#e8d13f',
green: '#3fd265',
blue: '#219dff',
pink: '#ff6dff',
violet: '#b176ff',
},
colors: themes.yaak,
},
},
};

View File

@@ -58,7 +58,7 @@ module.exports = {
'5xl': '3.052rem',
},
colors: {
selection: 'hsl(var(--color-violet-500) / 0.4)',
selection: 'hsl(var(--color-violet-500) / 0.3)',
focus: 'hsl(var(--color-blue-500) / 0.6)',
invalid: 'hsl(var(--color-red-500))',
highlight: 'hsl(var(--color-gray-300) / 0.35)',