Compare commits

...

16 Commits

Author SHA1 Message Date
Gregory Schier
eb5f9a4671 Fix window clicking on Linux 2024-05-14 12:19:19 -07:00
Gregory Schier
ad1a4eadd9 Slight refactor 2024-05-14 08:44:15 -07:00
Gregory Schier
69a151bfe5 Build plugin 2024-05-14 08:28:20 -07:00
Gregory Schier
d8d2f44723 Improve Curl imports 2024-05-14 08:28:01 -07:00
Gregory Schier
d5ea03ce91 Bump version for release 2024-05-14 07:50:01 -07:00
Gregory Schier
816bc543d7 Fix upgrade cancel 2024-05-14 07:08:57 -07:00
Gregory Schier
36d8c56872 Toast after data export 2024-05-14 00:36:15 -07:00
Gregory Schier
a7bb5605ab Fix selection of HTTP Request on create dropdown hotkey 2024-05-14 00:17:33 -07:00
Gregory Schier
31147475f3 Fix curl export with multi-line body 2024-05-14 00:05:54 -07:00
Gregory Schier
5a2d510d07 Autocomplete URLs of other requests 2024-05-13 23:54:52 -07:00
Gregory Schier
4a70c5415b Fixed asset:// loading and tweak curl stuff 2024-05-13 23:20:30 -07:00
Gregory Schier
ad796275b6 Bump version 2024-05-13 16:52:32 -07:00
Gregory Schier
31f5163ee3 Better notifications 2024-05-13 16:52:20 -07:00
Gregory Schier
1a6a75ca13 Improve copy-as-curl 2024-05-13 11:30:10 -07:00
Gregory Schier
edad9e2d68 Refactor UpdateMode 2024-05-13 07:28:45 -07:00
Gregory Schier
d5d0edb0b0 Hide large GRPC messages by default 2024-05-13 07:19:26 -07:00
47 changed files with 7622 additions and 573 deletions

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build Desktop" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="npm run tauri build -- --target universal-apple-darwin" />
<option name="SCRIPT_TEXT" value="npm run tauri build" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" />
@@ -9,12 +9,9 @@
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/zsh" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_IN_TERMINAL" value="false" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs>
<env name="TAURI_KEY_PASSWORD" value="fishhook-upstream-wash-assured" />
<env name="TAURI_PRIVATE_KEY" value="dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5OGxWaytTa3dIa2xXVUltQzRGUXIzd2lYQ2NpV0ZhQURSbWJWZ1NrK0tnY0FBQkFBQUFBQUFBQUFBQUlBQUFBQUV2M1VKdVRyVHpHSzhQdGc2ZVFtOVNsMU5tNEVSN280cFNrbXhncW9tdjNXaFJZUTJqUzQ5Q01zWTJWRVhaY1pGNHNjR1NFR3JmcWFRN09NdWdGMXpZVXhzejR4V3lDV1JpZHlnbW5LNS9vMFFtRlZjbUl4YjZSNzhlMmk3ait5SExYcG5QZUkxOFE9Cg==" />
</envs>
<envs />
<method v="2" />
</configuration>
</component>

View File

@@ -80,6 +80,26 @@ describe('exporter-curl', () => {
);
});
test('Exports multi-line JSON body', () => {
expect(
pluginHookExport({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/json',
body: {
text: `{"foo":"bar",\n"baz":"qux"}`,
},
headers: [{ name: 'Content-Type', value: 'application/json' }],
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data-raw $'{"foo":"bar",\n"baz":"qux"}'`,
].join(` \\\n `),
);
});
test('Exports headers', () => {
expect(
pluginHookExport({

View File

@@ -52,7 +52,7 @@ export function pluginHookImport(rawData: string) {
// Replace non-escaped newlines with semicolons to make parsing easier
// NOTE: This is really slow in debug build but fast in release mode
const normalizedData = rawData.replace(/([^\\])\n/g, '$1; ');
const normalizedData = rawData.replace(/\ncurl/g, '; curl');
let currentCommand: ParseEntry[] = [];
@@ -137,8 +137,6 @@ export function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
// Start at 1 so we can skip the ^curl part
for (let i = 1; i < parseEntries.length; i++) {
let parseEntry = parseEntries[i];
// trim leading spaces between parsed entries
// regex won't match otherwise (e.g. -H 'Content-Type: application/json')
if (typeof parseEntry === 'string') {
parseEntry = parseEntry.trim();
}
@@ -186,7 +184,7 @@ export function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
urlParameters =
search?.split('&').map((p) => {
const v = splitOnce(p, '=');
return { name: v[0] ?? '', value: v[1] ?? '' };
return { name: v[0] ?? '', value: v[1] ?? '', enabled: true };
}) ?? [];
url = baseUrl ?? urlArg;
@@ -214,11 +212,13 @@ export function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
return {
name: (name ?? '').trim().replace(/;$/, ''),
value: '',
enabled: true,
};
}
return {
name: (name ?? '').trim(),
value: value.trim(),
enabled: true,
};
});
@@ -245,6 +245,7 @@ export function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
headers.push({
name: 'Cookie',
value: cookieHeaderValue,
enabled: true,
});
}
@@ -288,12 +289,17 @@ export function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
) {
bodyType = mimeType ?? 'application/x-www-form-urlencoded';
body = {
params: dataParameters.map((parameter) => ({
form: dataParameters.map((parameter) => ({
...parameter,
name: decodeURIComponent(parameter.name || ''),
value: decodeURIComponent(parameter.value || ''),
})),
};
headers.push({
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
});
} else if (dataParameters.length > 0) {
bodyType =
mimeType === 'application/json' || mimeType === 'text/xml' || mimeType === 'text/plain'
@@ -309,13 +315,20 @@ export function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
body = {
form: formDataParams,
};
if (mimeType == null) {
headers.push({
name: 'Content-Type',
value: 'multipart/form-data',
enabled: true,
});
}
}
// Method
let method = getPairValue(pairsByName, '', ['X', 'request']).toUpperCase();
if (method === '' && body) {
method = 'text' in body || 'params' in body ? 'POST' : 'GET';
method = 'text' in body || 'form' in body ? 'POST' : 'GET';
}
const request: ExportResources['httpRequests'][0] = {
@@ -344,6 +357,7 @@ const pairsToDataParameters = (keyedPairs: PairsByName) => {
value: string;
contentType?: string;
filePath?: string;
enabled?: boolean;
}[] = [];
for (const flagName of DATA_FLAGS) {
@@ -363,11 +377,13 @@ const pairsToDataParameters = (keyedPairs: PairsByName) => {
name: name ?? '',
value: '',
filePath: p.slice(1),
enabled: true,
});
} else {
dataParameters.push({
name: name ?? '',
value: flagName === 'data-urlencode' ? encodeURIComponent(value ?? '') : value ?? '',
enabled: true,
});
}
}

View File

@@ -122,6 +122,13 @@ describe('importer-curl', () => {
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data',
enabled: true,
},
],
bodyType: 'multipart/form-data',
body: {
form: [
@@ -145,11 +152,18 @@ describe('importer-curl', () => {
method: 'POST',
url: 'https://yaak.app',
bodyType: 'application/x-www-form-urlencoded',
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
body: {
params: [
{ name: 'a', value: '' },
{ name: 'b', value: '' },
{ name: 'c', value: 'ccc' },
form: [
{ name: 'a', value: '', enabled: true },
{ name: 'b', value: '', enabled: true },
{ name: 'c', value: 'ccc', enabled: true },
],
},
}),
@@ -168,7 +182,7 @@ describe('importer-curl', () => {
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [{ name: 'Content-Type', value: 'text/plain' }],
headers: [{ name: 'Content-Type', value: 'text/plain', enabled: true }],
bodyType: 'text/plain',
body: { text: 'a&b&c=ccc' },
}),
@@ -177,6 +191,27 @@ describe('importer-curl', () => {
});
});
test('Imports multi-line JSON', () => {
expect(
pluginHookImport(
`curl -H Content-Type:application/json -d $'{\n "foo":"bar"\n}' https://yaak.app`,
),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
body: { text: '{\n "foo":"bar"\n}' },
}),
],
},
});
});
test('Imports multiple headers', () => {
expect(
pluginHookImport('curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app'),
@@ -187,10 +222,10 @@ describe('importer-curl', () => {
baseRequest({
url: 'https://yaak.app',
headers: [
{ name: 'Name', value: '' },
{ name: 'Foo', value: 'bar' },
{ name: 'AAA', value: 'bbb' },
{ name: '', value: 'ccc' },
{ name: 'Name', value: '', enabled: true },
{ name: 'Foo', value: 'bar', enabled: true },
{ name: 'AAA', value: 'bbb', enabled: true },
{ name: '', value: 'ccc', enabled: true },
],
}),
],
@@ -241,7 +276,7 @@ describe('importer-curl', () => {
httpRequests: [
baseRequest({
url: 'https://yaak.app',
headers: [{ name: 'Cookie', value: 'foo=bar' }],
headers: [{ name: 'Cookie', value: 'foo=bar', enabled: true }],
}),
],
},
@@ -256,8 +291,8 @@ describe('importer-curl', () => {
baseRequest({
url: 'https://yaak.app',
urlParameters: [
{ name: 'foo', value: 'bar' },
{ name: 'baz', value: 'a%20a' },
{ name: 'foo', value: 'bar', enabled: true },
{ name: 'baz', value: 'a%20a', enabled: true },
],
}),
],

182
src-tauri/Cargo.lock generated
View File

@@ -1051,16 +1051,6 @@ dependencies = [
"version_check 0.9.4",
]
[[package]]
name = "cookie"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd91cf61412820176e137621345ee43b3f4423e589e7ae4e50d601d93e35ef8"
dependencies = [
"time",
"version_check 0.9.4",
]
[[package]]
name = "cookie_store"
version = "0.16.2"
@@ -1830,26 +1820,11 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
dependencies = [
"futures-core",
"futures-sink",
@@ -1857,15 +1832,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
[[package]]
name = "futures-executor"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc"
checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
dependencies = [
"futures-core",
"futures-task",
@@ -1885,9 +1860,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
[[package]]
name = "futures-lite"
@@ -1904,9 +1879,9 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [
"proc-macro2",
"quote",
@@ -1915,23 +1890,22 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
[[package]]
name = "futures-task"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
[[package]]
name = "futures-util"
version = "0.3.29"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -2226,11 +2200,9 @@ dependencies = [
"hyper 0.14.27",
"hyper-rustls 0.24.2",
"log",
"once_cell",
"prost",
"prost-reflect",
"prost-types",
"protoc-bin-vendored",
"serde",
"serde_json",
"tauri",
@@ -2475,6 +2447,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.8.0"
@@ -4321,56 +4299,6 @@ dependencies = [
"prost",
]
[[package]]
name = "protoc-bin-vendored"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ca8623e5633e298ad1f917d8be0a44bcf406bf3cde3b80e63003e49a3f27d"
dependencies = [
"protoc-bin-vendored-linux-aarch_64",
"protoc-bin-vendored-linux-ppcle_64",
"protoc-bin-vendored-linux-x86_32",
"protoc-bin-vendored-linux-x86_64",
"protoc-bin-vendored-macos-x86_64",
"protoc-bin-vendored-win32",
]
[[package]]
name = "protoc-bin-vendored-linux-aarch_64"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fb9fc9cce84c8694b6ea01cc6296617b288b703719b725b8c9c65f7c5874435"
[[package]]
name = "protoc-bin-vendored-linux-ppcle_64"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d2a07dcf7173a04d49974930ccbfb7fd4d74df30ecfc8762cf2f895a094516"
[[package]]
name = "protoc-bin-vendored-linux-x86_32"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d54fef0b04fcacba64d1d80eed74a20356d96847da8497a59b0a0a436c9165b0"
[[package]]
name = "protoc-bin-vendored-linux-x86_64"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8782f2ce7d43a9a5c74ea4936f001e9e8442205c244f7a3d4286bd4c37bc924"
[[package]]
name = "protoc-bin-vendored-macos-x86_64"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5de656c7ee83f08e0ae5b81792ccfdc1d04e7876b1d9a38e6876a9e09e02537"
[[package]]
name = "protoc-bin-vendored-win32"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9653c3ed92974e34c5a6e0a510864dab979760481714c172e0a34e437cb98804"
[[package]]
name = "psl-types"
version = "2.0.11"
@@ -5014,9 +4942,9 @@ dependencies = [
[[package]]
name = "schemars"
version = "0.8.16"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45a28f4c49489add4ce10783f7911893516f15afe45d015608d41faca6bc4d29"
checksum = "fc6e7ed6919cb46507fb01ff1654309219f62b4d603822501b0b80d42f6f21ef"
dependencies = [
"dyn-clone",
"indexmap 1.9.3",
@@ -5028,14 +4956,14 @@ dependencies = [
[[package]]
name = "schemars_derive"
version = "0.8.16"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c767fd6fa65d9ccf9cf026122c1b555f2ef9a4f0cea69da4d7dbc3e258d30967"
checksum = "185f2b7aa7e02d418e453790dde16890256bbd2bcd04b7dc5348811052b53f49"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 1.0.109",
"syn 2.0.58",
]
[[package]]
@@ -5150,13 +5078,13 @@ dependencies = [
[[package]]
name = "serde_derive_internals"
version = "0.26.0"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c"
checksum = "330f01ce65a3a5fe59a60c82f3c9a024b573b8a6e875bd233fe5f934e71d54e3"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
"syn 2.0.58",
]
[[package]]
@@ -5849,9 +5777,9 @@ dependencies = [
[[package]]
name = "tao"
version = "0.27.1"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92bcf8885e147b56d6e26751263b45876284f32ca404703f6d3b8f80d16ff4dd"
checksum = "12a8121bd5721ebbbe0889f8286d5824673beeb04071519b68916fbed04f3093"
dependencies = [
"bitflags 2.5.0",
"cocoa",
@@ -5922,9 +5850,9 @@ checksum = "14c39fd04924ca3a864207c66fc2cd7d22d7c016007f9ce846cbb9326331930a"
[[package]]
name = "tauri"
version = "2.0.0-beta.17"
version = "2.0.0-beta.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fedd5490eddf117253945f0baedafded43474c971cba546a818f527d5c26266"
checksum = "6f8e5bc2e4f5eb7496d1a3e5f4d272f69f1333db5f8efed28d79d7f93334fe95"
dependencies = [
"anyhow",
"bytes",
@@ -5938,6 +5866,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http 1.1.0",
"http-range",
"jni",
"libc",
"log",
@@ -5971,9 +5900,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.0.0-beta.13"
version = "2.0.0-beta.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abcf98a9b4527567c3e5ca9723431d121e001c2145651b3fa044d22b5e025a7e"
checksum = "8aa28eebafcda490fa7097a6e3a4d07f65967614d35dd88b2aaa19dbb49241cd"
dependencies = [
"anyhow",
"cargo_toml",
@@ -5993,9 +5922,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.0.0-beta.13"
version = "2.0.0-beta.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b383f341efb803852b0235a2f330ca90c4c113f422dd6d646b888685b372cace"
checksum = "727d13a28e9ec895f537d90a09acb0aa3593f703a715fe8a77f87269d3245b52"
dependencies = [
"base64 0.22.0",
"brotli",
@@ -6020,11 +5949,11 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.0.0-beta.13"
version = "2.0.0-beta.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71be71718cfe48b149507157bfbad0e2ba0e98ea51658be26c7c677eb188fb0c"
checksum = "258667612ad901d256e04ace71ac54d4b3dd8fb1e5baa24403b50991cade4365"
dependencies = [
"heck 0.4.1",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.58",
@@ -6199,9 +6128,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.0.0-beta.14"
version = "2.0.0-beta.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "148b6e6aff8e63fe5d4ae1d50159d50cfc0b4309abdeca64833c887c6b5631ef"
checksum = "574f3d59cbe6c76b6d849bc35aa3a9e8061ff8f75f557dc33f38c0e43cf55a41"
dependencies = [
"dpi",
"gtk",
@@ -6218,9 +6147,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.0.0-beta.14"
version = "2.0.0-beta.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "398d065c6e0fbf3c4304583759b6e153bc1e0daeb033bede6834ebe4df371fc3"
checksum = "d6d1f223de1d674aaa561c900ac650b3160f11520e9b191a3574f6c493fc77fa"
dependencies = [
"cocoa",
"gtk",
@@ -6242,9 +6171,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.0.0-beta.13"
version = "2.0.0-beta.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4709765385f035338ecc330f3fba753b8ee283c659c235da9768949cdb25469"
checksum = "2b4251529d92b5c611ccaa611f8a31cb41b1aa00db8bcc0a49efe5d966bfa911"
dependencies = [
"brotli",
"cargo_metadata",
@@ -7251,18 +7180,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "window-shadows"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67ff424735b1ac21293b0492b069394b0a189c8a463fb015a16dea7c2e221c08"
dependencies = [
"cocoa",
"objc",
"raw-window-handle 0.5.2",
"windows-sys 0.48.0",
]
[[package]]
name = "window-vibrancy"
version = "0.5.0"
@@ -7731,9 +7648,7 @@ dependencies = [
"boa_runtime",
"chrono",
"cocoa",
"cookie 0.18.0",
"datetime",
"futures",
"grpc",
"http 0.2.10",
"log",
@@ -7759,7 +7674,6 @@ dependencies = [
"tokio",
"tokio-stream",
"uuid",
"window-shadows",
]
[[package]]

View File

@@ -3,10 +3,6 @@ workspace = { members = ["grpc"] }
[package]
name = "yaak-app"
version = "0.0.0"
description = "A network protocol testing utility app"
authors = ["Gregory Schier"]
license = "MIT"
repository = "https://github.com/gschier/yaak-app"
edition = "2021"
# Produce a library for mobile support
@@ -32,18 +28,13 @@ base64 = "0.22.0"
boa_engine = { version = "0.18.0", features = ["annex-b"] }
boa_runtime = { version = "0.18.0" }
chrono = { version = "0.4.31", features = ["serde"] }
futures = "0.3.26"
http = "0.2.8"
http = "0.2.10"
rand = "0.8.5"
reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "brotli", "deflate"] }
cookie = { version = "0.18.0" }
serde = { version = "1.0.195", features = ["derive"] }
serde_json = { version = "1.0.111", features = ["raw_value"] }
reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "brotli", "deflate", "json"] }
serde = { version = "1.0.198", features = ["derive"] }
serde_json = { version = "1.0.116", features = ["raw_value"] }
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }
tauri = { version = "2.0.0-beta.17", features = [
"config-toml",
"devtools",
] }
tauri = { version = "2.0.0-beta.19", features = ["config-toml", "devtools", "protocol-asset"] }
tauri-plugin-clipboard-manager = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2", features = ["colored"] }
@@ -53,10 +44,9 @@ tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace"
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tokio = { version = "1.36.0", features = ["sync"] }
uuid = "1.3.0"
log = "0.4.20"
uuid = "1.7.0"
log = "0.4.21"
datetime = "0.5.2"
window-shadows = "0.2.2"
reqwest_cookie_store = "0.6.0"
grpc = { path = "./grpc" }
tokio-stream = "0.1.15"

View File

@@ -47,6 +47,7 @@
"window:allow-set-title",
"window:allow-start-dragging",
"window:allow-unmaximize",
"clipboard-manager:default"
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text"
]
}

View File

@@ -1 +1 @@
{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["os:allow-os-type","event:allow-emit","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","event:allow-listen","event:allow-unlisten","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"protoc","sidecar":true}]},"window:allow-close","window:allow-is-fullscreen","window:allow-maximize","window:allow-minimize","window:allow-set-decorations","window:allow-set-title","window:allow-start-dragging","window:allow-unmaximize","clipboard-manager:default"]}}
{"main":{"identifier":"main","description":"Main permissions","local":true,"windows":["*"],"permissions":["os:allow-os-type","event:allow-emit","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:allow-open","dialog:allow-save","event:allow-listen","event:allow-unlisten","fs:allow-read-file","fs:allow-read-text-file",{"identifier":"fs:scope","allow":[{"path":"$APPDATA"},{"path":"$APPDATA/**"}]},"shell:allow-open",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"protoc","sidecar":true}]},"window:allow-close","window:allow-is-fullscreen","window:allow-maximize","window:allow-minimize","window:allow-set-decorations","window:allow-set-title","window:allow-start-dragging","window:allow-unmaximize","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}

File diff suppressed because it is too large Load Diff

View File

@@ -14,11 +14,9 @@ serde = { version = "1.0.196", features = ["derive"] }
serde_json = "1.0.113"
prost-reflect = { version = "0.12.0", features = ["serde", "derive"] }
log = "0.4.20"
once_cell = { version = "1.19.0", features = [] }
anyhow = "1.0.79"
hyper = { version = "0.14" }
hyper-rustls = { version = "0.24.0", features = ["http2"] }
protoc-bin-vendored = "3.0.0"
uuid = { version = "1.7.0", features = ["v4"] }
tauri = { version = "2.0.0-beta.16" }
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }

View File

@@ -9,10 +9,10 @@ var j = "(?:" + [
">\\&",
"<\\&",
"[&;()|<>]"
].join("|") + ")", D = new RegExp("^" + j + "$"), q = "|&;()<> \\t", M = '"((\\\\"|[^"])*?)"', Q = "'((\\\\'|[^'])*?)'", V = /^#$/, _ = "'", G = '"', U = "$", R = "", z = 4294967296;
].join("|") + ")", D = new RegExp("^" + j + "$"), q = "|&;()<> \\t", M = '"((\\\\"|[^"])*?)"', Q = "'((\\\\'|[^'])*?)'", V = /^#$/, _ = "'", G = '"', U = "$", $ = "", z = 4294967296;
for (var L = 0; L < 4; L++)
R += (z * Math.random()).toString(16);
var J = new RegExp("^" + R);
$ += (z * Math.random()).toString(16);
var J = new RegExp("^" + $);
function X(n, s) {
for (var e = s.lastIndex, t = [], c; c = s.exec(n); )
t.push(c), s.lastIndex === c.index && (s.lastIndex += 1);
@@ -20,7 +20,7 @@ function X(n, s) {
}
function F(n, s, e) {
var t = typeof n == "function" ? n(e) : n[e];
return typeof t > "u" && e != "" ? t = "" : typeof t > "u" && (t = "$"), typeof t == "object" ? s + R + JSON.stringify(t) + R : s + t;
return typeof t > "u" && e != "" ? t = "" : typeof t > "u" && (t = "$"), typeof t == "object" ? s + $ + JSON.stringify(t) + $ : s + t;
}
function K(n, s, e) {
e || (e = {});
@@ -39,30 +39,30 @@ function K(n, s, e) {
return;
if (D.test(a))
return { op: a };
var x = !1, O = !1, p = "", A = !1, i;
function b() {
var x = !1, C = !1, d = "", O = !1, i;
function T() {
i += 1;
var v, d, T = a.charAt(i);
if (T === "{") {
var v, p, R = a.charAt(i);
if (R === "{") {
if (i += 1, a.charAt(i) === "}")
throw new Error("Bad substitution: " + a.slice(i - 2, i + 1));
if (v = a.indexOf("}", i), v < 0)
throw new Error("Bad substitution: " + a.slice(i));
d = a.slice(i, v), i = v;
} else if (/[*@#?$!_-]/.test(T))
d = T, i += 1;
p = a.slice(i, v), i = v;
} else if (/[*@#?$!_-]/.test(R))
p = R, i += 1;
else {
var g = a.slice(i);
v = g.match(/[^\w\d_]/), v ? (d = g.slice(0, v.index), i += v.index - 1) : (d = g, i = a.length);
v = g.match(/[^\w\d_]/), v ? (p = g.slice(0, v.index), i += v.index - 1) : (p = g, i = a.length);
}
return F(s, "", d);
return F(s, "", p);
}
for (i = 0; i < a.length; i++) {
var u = a.charAt(i);
if (A = A || !x && (u === "*" || u === "?"), O)
p += u, O = !1;
if (O = O || !x && (u === "*" || u === "?"), C)
d += u, C = !1;
else if (x)
u === x ? x = !1 : x == _ ? p += u : u === t ? (i += 1, u = a.charAt(i), u === G || u === t || u === U ? p += u : p += t + u) : u === U ? p += b() : p += u;
u === x ? x = !1 : x == _ ? d += u : u === t ? (i += 1, u = a.charAt(i), u === G || u === t || u === U ? d += u : d += t + u) : u === U ? d += T() : d += u;
else if (u === G || u === _)
x = u;
else {
@@ -70,13 +70,13 @@ function K(n, s, e) {
return { op: a };
if (V.test(u)) {
w = !0;
var E = { comment: n.slice(r.index + i + 1) };
return p.length ? [p, E] : [E];
var b = { comment: n.slice(r.index + i + 1) };
return d.length ? [d, b] : [b];
} else
u === t ? O = !0 : u === U ? p += b() : p += u;
u === t ? C = !0 : u === U ? d += T() : d += u;
}
}
return A ? { op: "glob", pattern: p } : p;
return O ? { op: "glob", pattern: d } : d;
}).reduce(function(r, a) {
return typeof a > "u" ? r : r.concat(a);
}, []);
@@ -86,9 +86,9 @@ var Y = function(s, e, t) {
return typeof e != "function" ? c : c.reduce(function(m, f) {
if (typeof f == "object")
return m.concat(f);
var w = f.split(RegExp("(" + R + ".*?" + R + ")", "g"));
var w = f.split(RegExp("(" + $ + ".*?" + $ + ")", "g"));
return w.length === 1 ? m.concat(w[0]) : m.concat(w.filter(Boolean).map(function(r) {
return J.test(r) ? JSON.parse(r.split(R)[1]) : r;
return J.test(r) ? JSON.parse(r.split($)[1]) : r;
}));
}, []);
}, Z = Y;
@@ -118,7 +118,7 @@ const ae = "curl", se = "cURL", ie = "cURL command line tool", H = ["d", "data",
function oe(n) {
if (!n.match(/^\s*curl /))
return null;
const s = [], e = n.replace(/([^\\])\n/g, "$1; ");
const s = [], e = n.replace(/\ncurl/g, "; curl");
let t = [];
const m = Z(e).flatMap((r) => typeof r == "string" && r.startsWith("-") && !r.startsWith("--") && r.length > 2 ? [r.slice(0, 2), r.slice(2)] : r);
for (const r of m) {
@@ -158,73 +158,84 @@ function te(n, s) {
for (let o = 1; o < n.length; o++) {
let l = n[o];
if (typeof l == "string" && (l = l.trim()), typeof l == "string" && l.match(/^-{1,2}[\w-]+/)) {
const $ = l[0] === "-" && l[1] !== "-";
const E = l[0] === "-" && l[1] !== "-";
let h = l.replace(/^-{1,2}/, "");
if (!ee.includes(h))
continue;
let y;
const S = n[o + 1];
$ && h.length > 1 ? (y = h.slice(1), h = h.slice(0, 1)) : typeof S == "string" && !S.startsWith("-") ? (y = S, o++) : y = !0, e[h] = e[h] || [], e[h].push(y);
E && h.length > 1 ? (y = h.slice(1), h = h.slice(0, 1)) : typeof S == "string" && !S.startsWith("-") ? (y = S, o++) : y = !0, e[h] = e[h] || [], e[h].push(y);
} else
l && t.push(l);
}
let c, m;
const f = C(e, t[0] || "", ["url"]), [w, r] = W(f, "?");
const f = A(e, t[0] || "", ["url"]), [w, r] = W(f, "?");
c = (r == null ? void 0 : r.split("&").map((o) => {
const l = W(o, "=");
return { name: l[0] ?? "", value: l[1] ?? "" };
return { name: l[0] ?? "", value: l[1] ?? "", enabled: !0 };
})) ?? [], m = w ?? f;
const [a, x] = C(e, "", ["u", "user"]).split(/:(.*)$/), O = C(e, !1, ["digest"]), p = a ? O ? "digest" : "basic" : null, A = a ? {
const [a, x] = A(e, "", ["u", "user"]).split(/:(.*)$/), C = A(e, !1, ["digest"]), d = a ? C ? "digest" : "basic" : null, O = a ? {
username: a.trim(),
password: (x ?? "").trim()
} : {}, i = [
...e.header || [],
...e.H || []
].map((o) => {
const [l, $] = o.split(/:(.*)$/);
return $ ? {
const [l, E] = o.split(/:(.*)$/);
return E ? {
name: (l ?? "").trim(),
value: $.trim()
value: E.trim(),
enabled: !0
} : {
name: (l ?? "").trim().replace(/;$/, ""),
value: ""
value: "",
enabled: !0
};
}), b = [
}), T = [
...e.cookie || [],
...e.b || []
].map((o) => {
const l = o.split("=", 1)[0], $ = o.replace(`${l}=`, "");
return `${l}=${$}`;
const l = o.split("=", 1)[0], E = o.replace(`${l}=`, "");
return `${l}=${E}`;
}).join("; "), u = i.find((o) => o.name.toLowerCase() === "cookie");
b && u ? u.value += `; ${b}` : b && i.push({
T && u ? u.value += `; ${T}` : T && i.push({
name: "Cookie",
value: b
value: T,
enabled: !0
});
const E = ne(e), v = i.find((o) => o.name.toLowerCase() === "content-type"), d = v ? v.value.split(";")[0] : null, T = [
const b = ne(e), v = i.find((o) => o.name.toLowerCase() === "content-type"), p = v ? v.value.split(";")[0] : null, R = [
...e.form || [],
...e.F || []
].map((o) => {
const l = o.split("="), $ = l[0] ?? "", h = l[1] ?? "", y = {
name: $,
const l = o.split("="), E = l[0] ?? "", h = l[1] ?? "", y = {
name: E,
enabled: !0
};
return h.indexOf("@") === 0 ? y.file = h.slice(1) : y.value = h, y;
});
let g = {}, I = null;
const B = C(e, !1, ["G", "get"]);
E.length > 0 && B ? c.push(...E) : E.length > 0 && (d == null || d === "application/x-www-form-urlencoded") ? (I = d ?? "application/x-www-form-urlencoded", g = {
params: E.map((o) => ({
const B = A(e, !1, ["G", "get"]);
b.length > 0 && B ? c.push(...b) : b.length > 0 && (p == null || p === "application/x-www-form-urlencoded") ? (I = p ?? "application/x-www-form-urlencoded", g = {
form: b.map((o) => ({
...o,
name: decodeURIComponent(o.name || ""),
value: decodeURIComponent(o.value || "")
}))
}) : E.length > 0 ? (I = d === "application/json" || d === "text/xml" || d === "text/plain" ? d : "other", g = {
text: E.map(({ name: o, value: l }) => o && l ? `${o}=${l}` : o || l).join("&")
}) : T.length && (I = d ?? "multipart/form-data", g = {
form: T
});
let P = C(e, "", ["X", "request"]).toUpperCase();
return P === "" && g && (P = "text" in g || "params" in g ? "POST" : "GET"), {
}, i.push({
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: !0
})) : b.length > 0 ? (I = p === "application/json" || p === "text/xml" || p === "text/plain" ? p : "other", g = {
text: b.map(({ name: o, value: l }) => o && l ? `${o}=${l}` : o || l).join("&")
}) : R.length && (I = p ?? "multipart/form-data", g = {
form: R
}, p == null && i.push({
name: "Content-Type",
value: "multipart/form-data",
enabled: !0
}));
let P = A(e, "", ["X", "request"]).toUpperCase();
return P === "" && g && (P = "text" in g || "form" in g ? "POST" : "GET"), {
id: N("http_request"),
model: "http_request",
workspaceId: s,
@@ -233,8 +244,8 @@ function te(n, s) {
url: m,
method: P,
headers: i,
authentication: A,
authenticationType: p,
authentication: O,
authenticationType: d,
body: g,
bodyType: I,
folderId: null,
@@ -253,15 +264,17 @@ const ne = (n) => {
c.startsWith("@") ? s.push({
name: m ?? "",
value: "",
filePath: c.slice(1)
filePath: c.slice(1),
enabled: !0
}) : s.push({
name: m ?? "",
value: e === "data-urlencode" ? encodeURIComponent(f ?? "") : f ?? ""
value: e === "data-urlencode" ? encodeURIComponent(f ?? "") : f ?? "",
enabled: !0
});
}
}
return s;
}, C = (n, s, e) => {
}, A = (n, s, e) => {
for (const t of e)
if (n[t] && n[t].length)
return n[t][0];

View File

@@ -10,6 +10,7 @@ use std::fs::{create_dir_all, File, read_to_string};
use std::path::PathBuf;
use std::process::exit;
use std::str::FromStr;
use std::time::Duration;
use ::http::Uri;
use ::http::uri::InvalidUri;
@@ -30,7 +31,6 @@ use tauri::TitleBarStyle;
use tauri_plugin_log::{fern, Target, TargetKind};
use tauri_plugin_shell::ShellExt;
use tokio::sync::Mutex;
use tokio::time::sleep;
use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition};
use ::grpc::manager::{DynamicMessage, GrpcHandle};
@@ -48,22 +48,24 @@ use crate::models::{
get_cookie_jar, get_environment, get_folder, get_grpc_connection,
get_grpc_request, get_http_request, get_http_response, get_key_value_raw,
get_or_create_settings, get_workspace, get_workspace_export_resources, GrpcConnection, GrpcEvent,
GrpcEventType, GrpcRequest, HttpRequest, HttpRequestHeader, HttpResponse,
GrpcEventType, GrpcRequest, HttpRequest, HttpResponse,
KeyValue, list_cookie_jars, list_environments, list_folders, list_grpc_connections,
list_grpc_events, list_grpc_requests, list_http_requests, list_responses, list_workspaces,
ModelType, set_key_value_raw, Settings, update_response_if_id, update_settings, upsert_cookie_jar,
upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace,
Workspace, WorkspaceExportResources,
};
use crate::notifications::YaakNotifier;
use crate::plugin::{ImportResult, run_plugin_export_curl, run_plugin_import};
use crate::render::render_request;
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
use crate::updates::{UpdateMode, YaakUpdater};
use crate::window_menu::app_menu;
mod analytics;
mod grpc;
mod http;
mod models;
mod notifications;
mod plugin;
mod render;
mod updates;
@@ -107,6 +109,16 @@ async fn cmd_metadata(app_handle: AppHandle) -> Result<AppMetaData, ()> {
});
}
#[tauri::command]
async fn cmd_dismiss_notification(
app: AppHandle,
notification_id: &str,
yaak_notifier: State<'_, Mutex<YaakNotifier>>,
) -> Result<(), String> {
info!("SEEN? {notification_id}");
yaak_notifier.lock().await.seen(&app, notification_id).await
}
#[tauri::command]
async fn cmd_grpc_reflect(
request_id: &str,
@@ -1172,30 +1184,12 @@ async fn cmd_duplicate_grpc_request(id: &str, w: WebviewWindow) -> Result<GrpcRe
#[tauri::command]
async fn cmd_create_http_request(
workspace_id: &str,
name: &str,
sort_priority: f64,
folder_id: Option<&str>,
method: Option<&str>,
headers: Option<Vec<HttpRequestHeader>>,
body_type: Option<&str>,
request: HttpRequest,
w: WebviewWindow,
) -> Result<HttpRequest, String> {
upsert_http_request(
&w,
HttpRequest {
workspace_id: workspace_id.to_string(),
name: name.to_string(),
folder_id: folder_id.map(|s| s.to_string()),
body_type: body_type.map(|s| s.to_string()),
method: method.map(|s| s.to_string()).unwrap_or("GET".to_string()),
headers: Json(headers.unwrap_or_default()),
sort_priority,
..Default::default()
},
)
.await
.map_err(|e| e.to_string())
upsert_http_request(&w, request)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
@@ -1588,6 +1582,10 @@ pub fn run() {
let yaak_updater = YaakUpdater::new();
app.manage(Mutex::new(yaak_updater));
// Add notifier
let yaak_notifier = YaakNotifier::new();
app.manage(Mutex::new(yaak_notifier));
// Add GRPC manager
let grpc_handle = GrpcHandle::new(&app.app_handle());
app.manage(Mutex::new(grpc_handle));
@@ -1656,6 +1654,7 @@ pub fn run() {
cmd_metadata,
cmd_new_window,
cmd_request_to_curl,
cmd_dismiss_notification,
cmd_send_ephemeral_request,
cmd_send_http_request,
cmd_set_key_value,
@@ -1674,21 +1673,11 @@ pub fn run() {
.run(|app_handle, event| {
match event {
RunEvent::Ready => {
let w = create_window(app_handle, None);
// if let Err(e) = w.restore_state(StateFlags::all()) {
// error!("Failed to restore window state {}", e);
// }
create_window(app_handle, None);
let h = app_handle.clone();
tauri::async_runtime::spawn(async move {
let info = analytics::track_launch_event(&h).await;
debug!("Launched Yaak {:?}", info);
// Wait for window render and give a chance for the user to notice
if info.launched_after_update && info.num_launches > 1 {
sleep(std::time::Duration::from_secs(5)).await;
let _ = w.emit("show_changelog", true);
}
});
}
RunEvent::WindowEvent {
@@ -1703,6 +1692,16 @@ pub fn run() {
let update_mode = get_update_mode(&h).await;
_ = val.lock().await.check(&h, update_mode).await;
});
let h = app_handle.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_millis(4000)).await;
let val: State<'_, Mutex<YaakNotifier>> = h.state();
let mut n = val.lock().await;
if let Err(e) = n.check(&h).await {
warn!("Failed to check for notifications {}", e)
}
});
}
_ => {}
};
@@ -1722,6 +1721,9 @@ fn is_dev() -> bool {
fn create_window(handle: &AppHandle, url: Option<&str>) -> WebviewWindow {
let menu = app_menu(handle).unwrap();
// This causes the window to not be clickable (in AppImage), so disable on Linux
#[cfg(not(target_os = "linux"))]
handle.set_menu(menu).expect("Failed to set app menu");
let window_num = handle.webview_windows().len();
@@ -1820,7 +1822,7 @@ fn create_window(handle: &AppHandle, url: Option<&str>) -> WebviewWindow {
async fn get_update_mode(h: &AppHandle) -> UpdateMode {
let settings = get_or_create_settings(h).await;
update_mode_from_str(settings.update_channel.as_str())
UpdateMode::new(settings.update_channel.as_str())
}
fn safe_uri(endpoint: &str) -> Result<Uri, InvalidUri> {

View File

@@ -0,0 +1,94 @@
use std::time::SystemTime;
use chrono::{Duration, NaiveDateTime, Utc};
use http::Method;
use log::debug;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::models::{get_key_value_raw, set_key_value_raw};
// Check for updates every hour
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;
const KV_NAMESPACE: &str = "notifications";
const KV_KEY: &str = "seen";
// Create updater struct
pub struct YaakNotifier {
last_check: SystemTime,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct YaakNotification {
timestamp: NaiveDateTime,
id: String,
message: String,
action: Option<YaakNotificationAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct YaakNotificationAction {
label: String,
url: String,
}
impl YaakNotifier {
pub fn new() -> Self {
Self {
last_check: SystemTime::UNIX_EPOCH,
}
}
pub async fn seen(&mut self, app: &AppHandle, id: &str) -> Result<(), String> {
let mut seen = get_kv(app).await?;
seen.push(id.to_string());
debug!("Marked notification as seen {}", id);
let seen_json = serde_json::to_string(&seen).map_err(|e| e.to_string())?;
set_key_value_raw(app, KV_NAMESPACE, KV_KEY, seen_json.as_str()).await;
Ok(())
}
pub async fn check(&mut self, app: &AppHandle) -> Result<(), String> {
let ignore_check = self.last_check.elapsed().unwrap().as_secs() < MAX_UPDATE_CHECK_SECONDS;
if ignore_check {
return Ok(());
}
self.last_check = SystemTime::now();
let info = app.package_info().clone();
let req = reqwest::Client::default()
.request(Method::GET, "https://notify.yaak.app/notifications")
.query(&[("version", info.version)]);
let resp = req.send().await.map_err(|e| e.to_string())?;
let notification = resp
.json::<YaakNotification>()
.await
.map_err(|e| e.to_string())?;
let age = notification
.timestamp
.signed_duration_since(Utc::now().naive_utc());
let seen = get_kv(app).await?;
if seen.contains(&notification.id) || (age > Duration::days(1)) {
debug!("Already seen notification {}", notification.id);
return Ok(());
}
debug!("Got notification {:?}", notification);
let _ = app.emit("notification", notification.clone());
Ok(())
}
}
async fn get_kv(app: &AppHandle) -> Result<Vec<String>, String> {
match get_key_value_raw(app, "notifications", "seen").await {
None => Ok(Vec::new()),
Some(v) => serde_json::from_str(&v.value).map_err(|e| e.to_string()),
}
}

View File

@@ -1,3 +1,4 @@
use std::fmt::{Display, Formatter};
use std::time::SystemTime;
use log::info;
@@ -20,6 +21,25 @@ pub enum UpdateMode {
Beta,
}
impl Display for UpdateMode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let s = match self {
UpdateMode::Stable => "stable",
UpdateMode::Beta => "beta",
};
write!(f, "{}", s)
}
}
impl UpdateMode {
pub fn new(mode: &str) -> UpdateMode {
match mode {
"beta" => UpdateMode::Beta,
_ => UpdateMode::Stable,
}
}
}
impl YaakUpdater {
pub fn new() -> Self {
Self {
@@ -33,23 +53,21 @@ impl YaakUpdater {
) -> Result<bool, tauri_plugin_updater::Error> {
self.last_update_check = SystemTime::now();
let update_mode = get_update_mode_str(mode);
let enabled = !is_dev();
info!(
"Checking for updates mode={} enabled={}",
update_mode, enabled
);
let enabled = is_dev();
info!("Checking for updates mode={} enabled={}", mode, enabled);
if !enabled {
return Ok(false);
}
match app_handle
let update_check_result = app_handle
.updater_builder()
.header("X-Update-Mode", update_mode)?
.header("X-Update-Mode", mode.to_string())?
.build()?
.check()
.await
.await;
match update_check_result
{
Ok(Some(update)) => {
let h = app_handle.clone();
@@ -59,6 +77,8 @@ impl YaakUpdater {
"{} is available. Would you like to download and install it now?",
update.version
))
.ok_button_label("Download")
.cancel_button_label("Later")
.title("Update Available")
.show(|confirmed| {
if !confirmed {
@@ -70,6 +90,8 @@ impl YaakUpdater {
if h.dialog()
.message("Would you like to restart the app?")
.title("Update Installed")
.ok_button_label("Restart")
.cancel_button_label("Later")
.blocking_show()
{
h.restart();
@@ -102,17 +124,3 @@ impl YaakUpdater {
self.force_check(app_handle, mode).await
}
}
pub fn update_mode_from_str(mode: &str) -> UpdateMode {
match mode {
"beta" => UpdateMode::Beta,
_ => UpdateMode::Stable,
}
}
fn get_update_mode_str(mode: UpdateMode) -> &'static str {
match mode {
UpdateMode::Stable => "stable",
UpdateMode::Beta => "beta",
}
}

View File

@@ -6,15 +6,18 @@
"frontendDist": "../dist"
},
"productName": "Yaak",
"version": "2024.4.0-beta.2",
"version": "2024.4.0",
"identifier": "app.yaak.desktop",
"app": {
"withGlobalTauri": false,
"security": {
"assetProtocol": {
"scope": [
"$APPDATA/responses/*"
]
"enable": true,
"scope": {
"allow": [
"$APPDATA/responses/*"
]
}
}
}
},
@@ -24,48 +27,6 @@
"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEMxRDJFREQ1MjExQjdGN0IKUldSN2Z4c2gxZTNTd1FHNCtmYnFXMHVVQzhuNkJOM1cwOFBodmdLall3ckhKenpKUytHSTR1MlkK"
},
"allowlist": {
"all": false,
"os": {
"allow-os-type": true
},
"fs": {
"readFile": true,
"scope": [
"$RESOURCE/*",
"$APPDATA/responses/*"
]
},
"shell": {
"all": false,
"open": true,
"sidecar": true,
"scope": [
{
"name": "protoc",
"sidecar": true,
"args": true
}
]
},
"window": {
"close": true,
"maximize": true,
"minimize": true,
"setDecorations": true,
"setTitle": true,
"startDragging": true,
"unmaximize": true
},
"dialog": {
"all": false,
"open": true,
"save": true
},
"path": {
"all": true
}
}
},
"bundle": {

View File

@@ -30,15 +30,14 @@ export function BinaryFileEditor({
const handleClick = async () => {
await ignoreContentType.set(false);
const path = await open({
const selected = await open({
title: 'Select File',
multiple: false,
});
if (path) {
onChange({ filePath: path });
} else {
onChange({ filePath: undefined });
if (selected == null) {
return;
}
onChange({ filePath: selected.path });
};
const filePath = typeof body.filePath === 'string' ? body.filePath : undefined;

View File

@@ -2,12 +2,15 @@ import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import type { DropdownProps } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
interface Props {
interface Props extends Omit<DropdownProps, 'items'> {
hideFolder?: boolean;
children: DropdownProps['children'];
}
export function CreateDropdown({ hideFolder, children }: Props) {
export function CreateDropdown({ hideFolder, children, ...props }: Props) {
const items = useCreateDropdownItems({ hideFolder, hideIcons: true });
return <Dropdown items={items}>{children}</Dropdown>;
return (
<Dropdown items={items} {...props}>
{children}
</Dropdown>
);
}

View File

@@ -1,6 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { save } from '@tauri-apps/plugin-dialog';
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import slugify from 'slugify';
import type { Workspace } from '../lib/models';
import { count } from '../lib/pluralize';
@@ -10,16 +10,26 @@ import { HStack, VStack } from './core/Stacks';
interface Props {
onHide: () => void;
onSuccess: (path: string) => void;
activeWorkspace: Workspace;
workspaces: Workspace[];
}
export function ExportDataDialog({ onHide, activeWorkspace, workspaces: allWorkspaces }: Props) {
export function ExportDataDialog({
onHide,
onSuccess,
activeWorkspace,
workspaces: allWorkspaces,
}: Props) {
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
[activeWorkspace.id]: true,
});
const workspaces = [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)];
// Put active workspace first
const workspaces = useMemo(
() => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)],
[activeWorkspace, allWorkspaces],
);
const handleToggleAll = () => {
setSelectedWorkspaces(
@@ -27,7 +37,7 @@ export function ExportDataDialog({ onHide, activeWorkspace, workspaces: allWorks
);
};
const handleExport = async () => {
const handleExport = useCallback(async () => {
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : 'workspaces';
@@ -41,7 +51,8 @@ export function ExportDataDialog({ onHide, activeWorkspace, workspaces: allWorks
await invoke('cmd_export_data', { workspaceIds: ids, exportPath });
onHide();
};
onSuccess(exportPath);
}, [onHide, onSuccess, selectedWorkspaces, workspaces]);
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
@@ -97,7 +108,7 @@ export function ExportDataDialog({ onHide, activeWorkspace, workspaces: allWorks
className="focus"
color="primary"
disabled={noneSelected}
onClick={handleExport}
onClick={() => handleExport()}
>
Export {count('Workspace', numSelected, { omitSingle: true, noneWord: 'Nothing' })}
</Button>

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCurrent } from '@tauri-apps/api/webviewWindow';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
@@ -24,12 +24,12 @@ import { workspacesQueryKey } from '../hooks/useWorkspaces';
import type { Model } from '../lib/models';
import { modelsEq } from '../lib/models';
import { setPathname } from '../lib/persistPathname';
import { useNotificationToast } from '../hooks/useNotificationToast';
const DEFAULT_FONT_SIZE = 16;
export function GlobalHooks() {
// Include here so they always update, even
// if no component references them
// Include here so they always update, even if no component references them
useRecentWorkspaces();
useRecentEnvironments();
useRecentRequests();
@@ -38,6 +38,7 @@ export function GlobalHooks() {
useSyncWindowTitle();
useGlobalCommands();
useCommandPalette();
useNotificationToast();
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);

View File

@@ -10,9 +10,11 @@ import { JsonAttributeTree } from './core/JsonAttributeTree';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack } from './core/Stacks';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { RecentConnectionsDropdown } from './RecentConnectionsDropdown';
import { Button } from './core/Button';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
interface Props {
style?: CSSProperties;
@@ -32,6 +34,8 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
const connections = useGrpcConnections(activeRequest.id ?? null);
const activeConnection = connections[0] ?? null;
const events = useGrpcEvents(activeConnection?.id ?? null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const activeEvent = useMemo(
() => events.find((m) => m.id === activeEventId) ?? null,
@@ -102,7 +106,30 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
<div className="mb-2 select-text cursor-text font-semibold">
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
</div>
<JsonAttributeTree attrValue={JSON.parse(activeEvent?.content ?? '{}')} />
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
<VStack space={2} className="text-sm italic text-gray-500">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="gray"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<JsonAttributeTree attrValue={JSON.parse(activeEvent?.content ?? '{}')} />
)}
</>
) : (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
@@ -111,7 +138,7 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
{activeEvent.content}
</div>
{activeEvent.error && (
<div className="text-xs font-mono py-1 text-orange-700">
<div className="select-text cursor-text text-xs font-mono py-1 text-orange-700">
{activeEvent.error}
</div>
)}
@@ -196,12 +223,8 @@ function EventRow({
}
/>
<div className={classNames('w-full truncate text-2xs')}>
{content}
{error && (
<>
<span className="text-orange-600"> ({error})</span>
</>
)}
{content.slice(0, 1000)}
{error && <span className="text-orange-600"> ({error})</span>}
</div>
<div className={classNames('opacity-50 text-2xs')}>
{format(createdAt + 'Z', 'HH:mm:ss.SSS')}

View File

@@ -42,15 +42,15 @@ export function GrpcProtoSelection({ requestId }: Props) {
color="primary"
size="sm"
onClick={async () => {
const files = await open({
const selected = await open({
title: 'Select Proto Files',
multiple: true,
filters: [{ name: 'Proto Files', extensions: ['proto'] }],
});
if (files == null) {
if (selected == null) {
return;
}
const newFiles = files.map((f) => f.path).filter((p) => !protoFiles.includes(p));
const newFiles = selected.map((f) => f.path).filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
}}

View File

@@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { Button } from './core/Button';
import { useClipboardText } from '../hooks/useClipboardText';
import { useImportCurl } from '../hooks/useImportCurl';
import { Icon } from './core/Icon';
import { motion } from 'framer-motion';
export function ImportCurlButton() {
const [clipboardText] = useClipboardText();
const importCurl = useImportCurl({ clearClipboard: true });
const [isLoading, setIsLoading] = useState(false);
if (!clipboardText?.trim().startsWith('curl ')) {
return null;
}
return (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="paste" size="sm" />}
isLoading={isLoading}
onClick={() => {
setIsLoading(true);
importCurl
.mutateAsync({
requestId: null, // Create request
command: clipboardText,
})
.finally(() => setIsLoading(false));
}}
>
Import Curl
</Button>
</motion.div>
);
}

View File

@@ -38,9 +38,9 @@ import { GraphQLEditor } from './GraphQLEditor';
import { HeadersEditor } from './HeadersEditor';
import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
import { useCurlToRequest } from '../hooks/useCurlToRequest';
import { useToast } from './ToastContext';
import { Icon } from './core/Icon';
import { useImportCurl } from '../hooks/useImportCurl';
import { useRequests } from '../hooks/useRequests';
import type { GenericCompletionOption } from './core/Editor/genericCompletion';
interface Props {
style: CSSProperties;
@@ -57,6 +57,7 @@ export const RequestPane = memo(function RequestPane({
className,
activeRequest,
}: Props) {
const requests = useRequests();
const activeRequestId = activeRequest.id;
const updateRequest = useUpdateHttpRequest(activeRequestId);
const [activeTab, setActiveTab] = useActiveTab();
@@ -230,11 +231,9 @@ export const RequestPane = memo(function RequestPane({
[updateRequest],
);
const importCurl = useCurlToRequest();
const toast = useToast();
const isLoading = useIsResponseLoading(activeRequestId ?? null);
const { updateKey } = useRequestUpdateKey(activeRequestId ?? null);
const importCurl = useImportCurl({ clearClipboard: true });
return (
<div
@@ -244,6 +243,7 @@ export const RequestPane = memo(function RequestPane({
{activeRequest && (
<>
<UrlBar
key={forceUpdateKey}
url={activeRequest.url}
method={activeRequest.method}
placeholder="https://example.com"
@@ -252,14 +252,24 @@ export const RequestPane = memo(function RequestPane({
return;
}
importCurl.mutate({ requestId: activeRequestId, command });
toast.show({
render: () => [
<>
<Icon icon="info" />
<span>Curl command imported</span>
</>,
],
});
}}
autocomplete={{
minMatch: 3,
options:
requests.length > 0
? [
...requests.map(
(r) =>
({
type: 'constant',
label: r.url,
} as GenericCompletionOption),
),
]
: [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
],
}}
onSend={handleSend}
onCancel={handleCancel}
@@ -326,17 +336,6 @@ export const RequestPane = memo(function RequestPane({
contentType="text/xml"
onChange={handleBodyTextChange}
/>
) : activeRequest.bodyType === BODY_TYPE_OTHER ? (
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteVariables
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
onChange={handleBodyTextChange}
/>
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
@@ -365,6 +364,17 @@ export const RequestPane = memo(function RequestPane({
onChange={handleBinaryFileChange}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === 'string' ? (
<Editor
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteVariables
placeholder="..."
className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
onChange={handleBodyTextChange}
/>
) : (
<EmptyStateText>Empty Body</EmptyStateText>
)}

View File

@@ -1,5 +1,5 @@
import { open } from '@tauri-apps/plugin-shell';
import { useRef, useState } from 'react';
import { useRef } from 'react';
import { useAppInfo } from '../hooks/useAppInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData';
@@ -20,11 +20,6 @@ export function SettingsDropdown() {
const dropdownRef = useRef<DropdownRef>(null);
const dialog = useDialog();
const checkForUpdates = useCheckForUpdates();
const [showChangelog, setShowChangelog] = useState<boolean>(false);
useListenToTauriEvent('show_changelog', () => {
setShowChangelog(true);
});
const showSettings = () => {
dialog.show({
@@ -40,7 +35,6 @@ export function SettingsDropdown() {
return (
<Dropdown
ref={dropdownRef}
onClose={() => setShowChangelog(false)}
items={[
{
key: 'settings',
@@ -92,20 +86,13 @@ export function SettingsDropdown() {
{
key: 'changelog',
label: 'Changelog',
variant: showChangelog ? 'notify' : 'default',
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="externalLink" />,
onSelect: () => open(`https://yaak.app/changelog/${appInfo.data?.version}`),
},
]}
>
<IconButton
size="sm"
title="Main Menu"
icon="settings"
className="pointer-events-auto"
showBadge={showChangelog}
/>
<IconButton size="sm" title="Main Menu" icon="settings" className="pointer-events-auto" />
</Dropdown>
);
}

View File

@@ -608,7 +608,7 @@ const SidebarItem = forwardRef(function SidebarItem(
const deleteRequest = useDeleteRequest(activeRequest ?? null);
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
const [copyAsCurl] = useCopyAsCurl(itemId);
const [, copyAsCurl] = useCopyAsCurl(itemId);
const sendRequest = useSendRequest(itemId);
const sendManyRequests = useSendManyRequests();
const latestHttpResponse = useLatestHttpResponse(itemId);

View File

@@ -34,13 +34,8 @@ export function SidebarActions() {
hotkeyAction="sidebar.toggle"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/>
<CreateDropdown>
<IconButton
size="sm"
icon="plusCircle"
title="Add Resource"
hotkeyAction="http_request.create"
/>
<CreateDropdown hotKeyAction="http_request.create">
<IconButton size="sm" icon="plusCircle" title="Add Resource" />
</CreateDropdown>
</HStack>
);

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import React, { createContext, useContext, useMemo, useRef, useState } from 'react';
import type { ToastProps } from './core/Toast';
import { Toast } from './core/Toast';
@@ -6,13 +7,15 @@ import { Portal } from './Portal';
import { AnimatePresence } from 'framer-motion';
type ToastEntry = {
render: ({ hide }: { hide: () => void }) => React.ReactNode;
timeout?: number;
id?: string;
message: ReactNode;
timeout?: number | null;
onClose?: ToastProps['onClose'];
} & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>;
type PrivateToastEntry = ToastEntry & {
id: string;
timeout: number;
timeout: number | null;
};
interface State {
@@ -33,16 +36,26 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const timeoutRef = useRef<NodeJS.Timeout>();
const actions = useMemo<Actions>(
() => ({
show({ timeout = 4000, ...props }: ToastEntry) {
const id = generateId();
timeoutRef.current = setTimeout(() => {
this.hide(id);
}, timeout);
setToasts((a) => [...a.filter((d) => d.id !== id), { id, timeout, ...props }]);
show({ id, timeout = 4000, ...props }: ToastEntry) {
id = id ?? generateId();
if (timeout != null) {
timeoutRef.current = setTimeout(() => this.hide(id), timeout);
}
setToasts((a) => {
if (a.some((v) => v.id === id)) {
// It's already visible with this id
return a;
}
return [...a, { id, timeout, ...props }];
});
return id;
},
hide: (id: string) => {
setToasts((a) => a.filter((d) => d.id !== id));
setToasts((all) => {
const t = all.find((t) => t.id === id);
t?.onClose?.();
return all.filter((t) => t.id !== id);
});
},
}),
[],
@@ -66,12 +79,18 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
);
};
function ToastInstance({ id, render, timeout, ...props }: PrivateToastEntry) {
function ToastInstance({ id, message, timeout, ...props }: PrivateToastEntry) {
const { actions } = useContext(ToastContext);
const children = render({ hide: () => actions.hide(id) });
return (
<Toast open timeout={timeout} onClose={() => actions.hide(id)} {...props}>
{children}
<Toast
open
timeout={timeout}
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => actions.hide(id)}
>
{message}
</Toast>
);
}

View File

@@ -1,10 +1,11 @@
import type { EditorView } from 'codemirror';
import type { FormEvent } from 'react';
import type { FormEvent, ReactNode } from 'react';
import { memo, useRef, useState } from 'react';
import { useHotKey } from '../hooks/useHotKey';
import type { HttpRequest } from '../lib/models';
import type { IconProps } from './core/Icon';
import { IconButton } from './core/IconButton';
import type { InputProps } from './core/Input';
import { Input } from './core/Input';
import { RequestMethodDropdown } from './RequestMethodDropdown';
@@ -20,6 +21,8 @@ type Props = Pick<HttpRequest, 'url'> & {
onMethodChange?: (method: string) => void;
isLoading: boolean;
forceUpdateKey: string;
rightSlot?: ReactNode;
autocomplete?: InputProps['autocomplete'];
};
export const UrlBar = memo(function UrlBar({
@@ -34,6 +37,8 @@ export const UrlBar = memo(function UrlBar({
onMethodChange,
onPaste,
submitIcon = 'sendHorizontal',
autocomplete,
rightSlot,
isLoading,
}: Props) {
const inputRef = useRef<EditorView>(null);
@@ -65,6 +70,7 @@ export const UrlBar = memo(function UrlBar({
className="pl-0 pr-1.5 py-0.5"
name="url"
label="Enter URL"
autocomplete={autocomplete}
forceUpdateKey={forceUpdateKey}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
@@ -84,17 +90,20 @@ export const UrlBar = memo(function UrlBar({
)
}
rightSlot={
submitIcon !== null && (
<IconButton
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 my-0.5 mr-0.5"
icon={isLoading ? 'x' : submitIcon}
hotkeyAction="http_request.send"
/>
)
<>
{rightSlot}
{submitIcon !== null && (
<IconButton
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 my-0.5 mr-0.5"
icon={isLoading ? 'x' : submitIcon}
hotkeyAction="http_request.send"
/>
)}
</>
}
/>
</form>

View File

@@ -6,7 +6,7 @@ import type {
MouseEvent as ReactMouseEvent,
ReactNode,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
@@ -33,8 +33,6 @@ import { ResizeHandle } from './ResizeHandle';
import { Sidebar } from './Sidebar';
import { SidebarActions } from './SidebarActions';
import { WorkspaceHeader } from './WorkspaceHeader';
import { useClipboardText } from '../hooks/useClipboardText';
import { useToast } from './ToastContext';
const side = { gridArea: 'side' };
const head = { gridArea: 'head' };
@@ -56,24 +54,6 @@ export default function Workspace() {
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
const clipboardText = useClipboardText();
const toast = useToast();
useEffect(() => {
const isCurlInClipboard = clipboardText?.startsWith('curl ');
if (!isCurlInClipboard) {
return;
}
toast.show({
render: () => (
<div>
<p>Curl command detected?</p>
<Button color="primary">Import</Button>
</div>
),
});
}, [clipboardText, toast]);
const unsub = () => {
if (moveState.current !== null) {

View File

@@ -11,6 +11,7 @@ import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { SettingsDropdown } from './SettingsDropdown';
import { SidebarActions } from './SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
import { ImportCurlButton } from './ImportCurlButton';
interface Props {
className?: string;
@@ -19,6 +20,7 @@ interface Props {
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const osInfo = useOsInfo();
const [maximized, setMaximized] = useState<boolean>(false);
return (
<HStack
space={2}
@@ -38,7 +40,8 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<div className="pointer-events-none">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex items-center h-full justify-end pointer-events-none">
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none">
<ImportCurlButton />
<SettingsDropdown />
{(osInfo?.osType === 'linux' || osInfo?.osType === 'windows') && (
<HStack className="ml-4" alignItems="center">

View File

@@ -10,7 +10,7 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color'> & {
color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger';
variant?: 'border' | 'solid';
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
size?: 'xs' | 'sm' | 'md';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
forDropdown?: boolean;

View File

@@ -30,6 +30,7 @@ import { HotKey } from './HotKey';
import { Separator } from './Separator';
import { HStack, VStack } from './Stacks';
import { Icon } from './Icon';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
export type DropdownItemSeparator = {
type: 'separator';
@@ -58,6 +59,7 @@ export interface DropdownProps {
items: DropdownItem[];
onOpen?: () => void;
onClose?: () => void;
hotKeyAction?: HotkeyAction;
}
export interface DropdownRef {
@@ -71,7 +73,7 @@ export interface DropdownRef {
}
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items, onOpen, onClose }: DropdownProps,
{ children, items, onOpen, onClose, hotKeyAction }: DropdownProps,
ref,
) {
const [isOpen, _setIsOpen] = useState<boolean>(false);
@@ -88,18 +90,33 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
[onClose, onOpen],
);
const handleClose = useCallback(() => {
setIsOpen(false);
buttonRef.current?.focus();
// Reset so it triggers a render if opening sets to 0, for example
setDefaultSelectedIndex(undefined);
}, [setIsOpen]);
useImperativeHandle(ref, () => ({
...menuRef.current,
isOpen: isOpen,
toggle() {
if (!isOpen) this.open();
else setIsOpen(false);
else this.close();
},
open() {
setIsOpen(true);
},
close() {
handleClose();
},
}));
useHotKey(hotKeyAction ?? null, () => {
setDefaultSelectedIndex(0);
setIsOpen(true);
});
const child = useMemo(() => {
const existingChild = Children.only(children);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -119,11 +136,6 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
return cloneElement(existingChild, props);
}, [children, setIsOpen]);
const handleClose = useCallback(() => {
setIsOpen(false);
buttonRef.current?.focus();
}, [setIsOpen]);
useEffect(() => {
buttonRef.current?.setAttribute('aria-expanded', isOpen.toString());
}, [isOpen]);
@@ -206,7 +218,10 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
}: MenuProps,
ref,
) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(defaultSelectedIndex ?? null);
const [selectedIndex, setSelectedIndex] = useStateWithDeps<number | null>(
defaultSelectedIndex ?? null,
[defaultSelectedIndex],
);
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
const [filter, setFilter] = useState<string>('');
const [containerWidth, setContainerWidth] = useState<number | null>(null);
@@ -223,7 +238,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
onClose();
setSelectedIndex(null);
setFilter('');
}, [onClose]);
}, [onClose, setSelectedIndex]);
// Close menu on space bar
const handleMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
@@ -265,7 +280,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
}
return nextIndex;
});
}, [items]);
}, [items, setSelectedIndex]);
const handleNext = useCallback(() => {
setSelectedIndex((currIndex) => {
@@ -282,7 +297,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
}
return nextIndex;
});
}, [items]);
}, [items, setSelectedIndex]);
useKey(
'ArrowUp',
@@ -312,11 +327,11 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
handleClose();
}
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
if (i.type !== 'separator' && typeof i.onSelect === 'function') {
i.onSelect();
}
},
[handleClose],
[handleClose, setSelectedIndex],
);
useImperativeHandle(
@@ -377,7 +392,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
const index = filteredItems.findIndex((item) => item === i) ?? null;
setSelectedIndex(index);
},
[filteredItems],
[filteredItems, setSelectedIndex],
);
if (items.length === 0) return null;
@@ -506,7 +521,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
return (
<Button
ref={initRef}
size="xs"
size="sm"
tabIndex={-1}
onMouseEnter={(e) => e.currentTarget.focus()}
onMouseLeave={(e) => e.currentTarget.blur()}
@@ -518,6 +533,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
className={classNames(
className,
'h-xs', // More compact
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
'focus:bg-highlight focus:text-gray-800 rounded',
item.variant === 'danger' && 'text-red-600',

View File

@@ -17,9 +17,12 @@ export interface GenericCompletionConfig {
options: GenericCompletionOption[];
}
/**
* Complete options, always matching until the start of the line
*/
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) {
return function completions(context: CompletionContext) {
const toMatch = context.matchBefore(/\w*/);
const toMatch = context.matchBefore(/.*/);
// Only match if we're at the start of the line
if (toMatch === null || toMatch.from > 0) return null;

View File

@@ -21,20 +21,12 @@ export function twig(
const completions = twigCompletion({ options: variables });
const language = mixLanguage(base);
const completion = language.data.of({ autocomplete: completions });
const completionBase = base.language.data.of({ autocomplete: completions });
const additionalCompletion = autocomplete
? [language.data.of({ autocomplete: genericCompletion(autocomplete) })]
? [base.language.data.of({ autocomplete: genericCompletion(autocomplete) })]
: [];
return [
language,
completion,
completionBase,
base.support,
placeholders(variables),
...additionalCompletion,
];
return [language, completionBase, base.support, placeholders(variables), ...additionalCompletion];
}
function mixLanguage(base: LanguageSupport): LRLanguage {

View File

@@ -1,5 +1,4 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { completions } from './completion';
import { parser } from './url';
const urlLanguage = LRLanguage.define({
@@ -7,8 +6,6 @@ const urlLanguage = LRLanguage.define({
languageData: {},
});
const completion = urlLanguage.data.of({ autocomplete: completions });
export function url() {
return new LanguageSupport(urlLanguage, [completion]);
return new LanguageSupport(urlLanguage, []);
}

View File

@@ -19,6 +19,7 @@ const icons = {
cake: lucide.CakeIcon,
chat: lucide.MessageSquare,
check: lucide.CheckIcon,
checkCircle: lucide.CheckCircleIcon,
chevronDown: lucide.ChevronDownIcon,
chevronRight: lucide.ChevronRightIcon,
code: lucide.CodeIcon,
@@ -41,6 +42,7 @@ const icons = {
magicWand: lucide.Wand2Icon,
minus: lucide.MinusIcon,
moreVertical: lucide.MoreVerticalIcon,
paste: lucide.ClipboardPasteIcon,
pencil: lucide.PencilIcon,
plug: lucide.Plug,
plus: lucide.PlusIcon,

View File

@@ -2,7 +2,7 @@ import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
import { useStateSyncDefault } from '../../hooks/useStateSyncDefault';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import type { EditorProps } from './Editor';
import { Editor } from './Editor';
import { IconButton } from './IconButton';
@@ -72,7 +72,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
}: InputProps,
ref,
) {
const [obscured, setObscured] = useStateSyncDefault(type === 'password');
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);

View File

@@ -392,11 +392,15 @@ function PairEditorRow({
className="font-mono text-xs"
onClick={async (e) => {
e.preventDefault();
const file = await open({
const selected = await open({
title: 'Select file',
multiple: false,
});
handleChangeValueFile((Array.isArray(file) ? file[0] : file) ?? '');
if (selected == null) {
return;
}
handleChangeValueFile(selected.path);
}}
>
{getFileName(pairContainer.pair.value) || 'Select File'}
@@ -491,7 +495,8 @@ const newPairContainer = (initialPair?: Pair): PairContainer => {
return { id, pair };
};
const getFileName = (path: string): string => {
const getFileName = (path: string | null | undefined): string => {
if (typeof path !== 'string') return '';
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] ?? '';
};

View File

@@ -1,23 +1,39 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import React, { useMemo } from 'react';
import React from 'react';
import { useKey } from 'react-use';
import { Heading } from './Heading';
import { IconButton } from './IconButton';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
export interface ToastProps {
children: ReactNode;
open: boolean;
onClose: () => void;
title?: ReactNode;
className?: string;
timeout: number;
timeout: number | null;
action?: ReactNode;
variant?: 'copied' | 'success' | 'info' | 'warning' | 'error';
}
export function Toast({ children, className, open, onClose, title, timeout }: ToastProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const ICONS: Record<NonNullable<ToastProps['variant']>, IconProps['icon']> = {
copied: 'copyCheck',
warning: 'alert',
error: 'alert',
info: 'info',
success: 'checkCircle',
};
export function Toast({
children,
className,
open,
onClose,
timeout,
action,
variant,
}: ToastProps) {
useKey(
'Escape',
() => {
@@ -46,14 +62,22 @@ export function Toast({ children, className, open, onClose, title, timeout }: To
'text-gray-700',
)}
>
<div className="px-3 py-2">
{title && (
<Heading size={3} id={titleId}>
{title}
</Heading>
<div className="px-3 py-2 flex items-center gap-2">
{variant != null && (
<Icon
icon={ICONS[variant]}
className={classNames(
variant === 'success' && 'text-green-500',
variant === 'warning' && 'text-orange-500',
variant === 'error' && 'text-red-500',
variant === 'copied' && 'text-violet-500',
)}
/>
)}
<div className="flex items-center gap-2">{children}</div>
<div className="flex flex-col gap-1 w-full">
<div>{children}</div>
{action}
</div>
</div>
<IconButton
@@ -63,14 +87,17 @@ export function Toast({ children, className, open, onClose, title, timeout }: To
icon="x"
onClick={onClose}
/>
<div className="w-full absolute bottom-0 left-0 right-0">
<motion.div
className="bg-highlight h-0.5"
initial={{ width: '100%' }}
animate={{ width: '0%', opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: 'linear' }}
/>
</div>
{timeout != null && (
<div className="w-full absolute bottom-0 left-0 right-0">
<motion.div
className="bg-highlight h-0.5"
initial={{ width: '100%' }}
animate={{ width: '0%', opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: 'linear' }}
/>
</div>
)}
</motion.div>
);
}

View File

@@ -1,9 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { readText } from '@tauri-apps/plugin-clipboard-manager';
import { readText, writeText } from '@tauri-apps/plugin-clipboard-manager';
import { useCallback, useEffect } from 'react';
import { useWindowFocus } from './useWindowFocus';
import { createGlobalState } from 'react-use';
const useClipboardTextState = createGlobalState<string>('');
export function useClipboardText() {
return useQuery({
queryKey: [],
queryFn: () => readText(),
}).data;
const [value, setValue] = useClipboardTextState();
const focused = useWindowFocus();
useEffect(() => {
readText().then(setValue);
}, [focused, setValue]);
const setText = useCallback(
(text: string) => {
writeText(text).catch(console.error);
setValue(text);
},
[setValue],
);
return [value, setText] as const;
}

View File

@@ -1,8 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import React, { useState } from 'react';
import { useState } from 'react';
import { useToast } from '../components/ToastContext';
import { Icon } from '../components/core/Icon';
export function useCopyAsCurl(requestId: string) {
const [checked, setChecked] = useState<boolean>(false);
@@ -15,12 +14,8 @@ export function useCopyAsCurl(requestId: string) {
setChecked(true);
setTimeout(() => setChecked(false), 800);
toast.show({
render: () => [
<>
<Icon icon="copyCheck" />
<span>Command copied to clipboard</span>
</>,
],
variant: 'copied',
message: 'Curl copied to clipboard',
});
return cmd;
},

View File

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

View File

@@ -1,24 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { useMutation } from '@tanstack/react-query';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useRequestUpdateKey } from './useRequestUpdateKey';
import { useUpdateAnyHttpRequest } from './useUpdateAnyHttpRequest';
export function useCurlToRequest() {
const workspaceId = useActiveWorkspaceId();
const updateRequest = useUpdateAnyHttpRequest();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
return useMutation({
mutationFn: async ({ requestId, command }: { requestId: string; command: string }) => {
const request: Record<string, unknown> = await invoke('cmd_curl_to_request', {
command,
workspaceId,
});
delete request.id;
await updateRequest.mutateAsync({ id: requestId, update: request });
wasUpdatedExternally(requestId);
console.log('FOO', request);
},
});
}

View File

@@ -4,12 +4,14 @@ import { ExportDataDialog } from '../components/ExportDataDialog';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAlert } from './useAlert';
import { useWorkspaces } from './useWorkspaces';
import { useToast } from '../components/ToastContext';
export function useExportData() {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const alert = useAlert();
const dialog = useDialog();
const toast = useToast();
return useMutation({
onError: (err: string) => {
@@ -28,6 +30,12 @@ export function useExportData() {
onHide={hide}
workspaces={workspaces}
activeWorkspace={activeWorkspace}
onSuccess={() => {
toast.show({
variant: 'success',
message: 'Data export successful',
});
}}
/>
),
});

View File

@@ -0,0 +1,46 @@
import { invoke } from '@tauri-apps/api/core';
import { useMutation } from '@tanstack/react-query';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useRequestUpdateKey } from './useRequestUpdateKey';
import { useUpdateAnyHttpRequest } from './useUpdateAnyHttpRequest';
import { useToast } from '../components/ToastContext';
import { useCreateHttpRequest } from './useCreateHttpRequest';
import { useClipboardText } from './useClipboardText';
export function useImportCurl({ clearClipboard }: { clearClipboard?: boolean } = {}) {
const workspaceId = useActiveWorkspaceId();
const updateRequest = useUpdateAnyHttpRequest();
const createRequest = useCreateHttpRequest();
const { wasUpdatedExternally } = useRequestUpdateKey(null);
const toast = useToast();
const [, setClipboardText] = useClipboardText();
return useMutation({
mutationFn: async ({ requestId, command }: { requestId: string | null; command: string }) => {
const request: Record<string, unknown> = await invoke('cmd_curl_to_request', {
command,
workspaceId,
});
delete request.id;
let verb;
if (requestId == null) {
verb = 'Created';
await createRequest.mutateAsync(request);
} else {
verb = 'Updated';
await updateRequest.mutateAsync({ id: requestId, update: request });
setTimeout(() => wasUpdatedExternally(requestId), 100);
}
toast.show({
variant: 'success',
message: `${verb} request from Curl`,
});
if (clearClipboard) {
setClipboardText('');
}
},
});
}

View File

@@ -0,0 +1,46 @@
import { useToast } from '../components/ToastContext';
import { useListenToTauriEvent } from './useListenToTauriEvent';
import { Button } from '../components/core/Button';
import { open } from '@tauri-apps/plugin-shell';
import { invoke } from '@tauri-apps/api/core';
export function useNotificationToast() {
const toast = useToast();
const markRead = (id: string) => {
invoke('cmd_dismiss_notification', { notificationId: id }).catch(console.error);
};
useListenToTauriEvent<{
id: string;
timestamp: string;
message: string;
action?: null | {
url: string;
label: string;
};
}>('notification', ({ payload }) => {
const actionUrl = payload.action?.url;
const actionLabel = payload.action?.label;
toast.show({
id: payload.id,
timeout: null,
message: payload.message,
onClose: () => markRead(payload.id),
action:
actionLabel && actionUrl ? (
<Button
size="xs"
color="gray"
className="mr-auto min-w-[5rem]"
onClick={() => {
toast.hide(payload.id);
return open(actionUrl);
}}
>
{actionLabel}
</Button>
) : null,
});
});
}

View File

@@ -1,12 +1,14 @@
import type { DependencyList } from 'react';
import { useEffect, useState } from 'react';
/**
* Like useState, except it will update the value when the default value changes
*/
export function useStateSyncDefault<T>(defaultValue: T) {
export function useStateWithDeps<T>(defaultValue: T, deps: DependencyList) {
const [value, setValue] = useState(defaultValue);
useEffect(() => {
setValue(defaultValue);
}, [defaultValue]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return [value, setValue] as const;
}