Compare commits

..

33 Commits

Author SHA1 Message Date
Gregory Schier
221e768b33 Fix recent workspaces 2024-03-10 17:42:25 -07:00
Gregory Schier
c2dc7e0f4a Fix adding header if not exist 2024-03-10 17:10:16 -07:00
Gregory Schier
9e065c34ee Remove completion debug blur thing 2024-03-10 16:46:18 -07:00
Gregory Schier
2f91d541c5 Adjust detected content-type header 2024-03-10 16:26:06 -07:00
Gregory Schier
948fd487ab Clickable links in response viewer 2024-03-10 13:41:44 -07:00
Gregory Schier
ed6a5386a2 Better error handling for file not found 2024-03-10 11:02:32 -07:00
Gregory Schier
8a24c48fd3 Cancel file selection sets to undefined 2024-03-10 10:57:49 -07:00
Gregory Schier
d726a6f5bf Binary file uploads and missing workspace empty state 2024-03-10 10:56:38 -07:00
Gregory Schier
8d2a2a8532 Fix GraphQL Header backend 2024-02-28 13:38:22 -08:00
Gregory Schier
b838a6ffc1 Fix GraphQL content type on creation, and placeholder 2024-02-28 13:04:17 -08:00
Gregory Schier
2174a91b64 Include default protoc includes 2024-02-28 09:45:11 -08:00
Gregory Schier
083f83ccab Bump version 2024-02-28 08:51:34 -08:00
Gregory Schier
4f749be2e2 Fix dropdown arrow keys 2024-02-28 08:51:08 -08:00
Gregory Schier
cefdc3ecf3 Track GRPC 2024-02-28 07:32:05 -08:00
Gregory Schier
02960d2d64 Analytics ID 2024-02-28 07:27:19 -08:00
Gregory Schier
9e5226aa83 Analytics ID 2024-02-28 07:26:02 -08:00
Gregory Schier
63d7a44586 Remove Escape from hotkeys 2024-02-27 18:58:41 -08:00
Gregory Schier
c851dfe206 Fix sidebar focus 2024-02-27 10:33:20 -08:00
Gregory Schier
6adc15a249 Fix gap in dropdown menu items 2024-02-27 10:27:04 -08:00
Gregory Schier
9ac7aac296 Methods in recent dropdown 2024-02-27 10:20:35 -08:00
Gregory Schier
325d63e1b7 Many hotkey improvements 2024-02-27 10:10:38 -08:00
Gregory Schier
e639a77165 Info logs in build 2024-02-26 17:27:08 -08:00
Gregory Schier
c075efc752 Introspection tweak 2024-02-26 17:24:44 -08:00
Gregory Schier
c4f42f71c3 Tweak editor find/replace 2024-02-26 17:17:37 -08:00
Gregory Schier
535adfe200 Fix find/replace CM styling 2024-02-26 17:07:09 -08:00
Gregory Schier
85fa159f0d Fix lint errors 2024-02-26 07:43:08 -08:00
Gregory Schier
fd2fe46c95 Autocomplete icons and transfer proto files on duplicate 2024-02-26 07:39:53 -08:00
Gregory Schier
6e52f35626 Prompt folder name on create 2024-02-26 07:14:27 -08:00
Gregory Schier
a0d1e7023d Better creation from folder menu 2024-02-26 07:09:59 -08:00
Gregory Schier
97a2f00d59 Auto-fill link to changelog in release script 2024-02-25 18:42:04 -08:00
Gregory Schier
50ad4efad7 Protoc sidecar 2024-02-25 17:43:29 -08:00
Gregory Schier
79a3d9c8df Fix deletion in sidebar 2024-02-25 12:56:57 -08:00
Gregory Schier
b8e20d885f Fix create dropdown hotkey 2024-02-24 22:02:04 -08:00
79 changed files with 3774 additions and 424 deletions

View File

@@ -7,6 +7,7 @@ permissions: write-all
jobs: jobs:
build-artifacts: build-artifacts:
name: Build
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -37,7 +38,7 @@ jobs:
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }} key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 20
cache: 'npm' cache: 'npm'
- name: install dependencies (ubuntu only) - name: install dependencies (ubuntu only)
if: matrix.os == 'ubuntu-20.04' if: matrix.os == 'ubuntu-20.04'
@@ -65,7 +66,7 @@ jobs:
with: with:
tagName: 'v__VERSION__' tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__' releaseName: 'Release __VERSION__'
releaseBody: '<!-- Release Notes -->' releaseBody: 'https://yaak.app/changelog/__VERSION__'
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
args: '--target ${{ matrix.target }}' args: '--target ${{ matrix.target }}'

15
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"format-graphql": "^1.4.0", "format-graphql": "^1.4.0",
"framer-motion": "^9.0.4", "framer-motion": "^9.0.4",
"lucide-react": "^0.309.0", "lucide-react": "^0.309.0",
"mime": "^4.0.1",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"parse-color": "^1.0.0", "parse-color": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
@@ -7208,6 +7209,20 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.1.tgz",
"integrity": "sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==",
"funding": [
"https://github.com/sponsors/broofa"
],
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mimic-fn": { "node_modules/mimic-fn": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",

View File

@@ -51,6 +51,7 @@
"format-graphql": "^1.4.0", "format-graphql": "^1.4.0",
"framer-motion": "^9.0.4", "framer-motion": "^9.0.4",
"lucide-react": "^0.309.0", "lucide-react": "^0.309.0",
"mime": "^4.0.1",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"parse-color": "^1.0.0", "parse-color": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",

33
src-tauri/Cargo.lock generated
View File

@@ -1699,6 +1699,7 @@ dependencies = [
"protoc-bin-vendored", "protoc-bin-vendored",
"serde", "serde",
"serde_json", "serde_json",
"tauri",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tonic", "tonic",
@@ -1763,9 +1764,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.3.21" version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833" checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9"
dependencies = [ dependencies = [
"bytes", "bytes",
"fnv", "fnv",
@@ -1773,7 +1774,7 @@ dependencies = [
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"http", "http",
"indexmap 1.9.3", "indexmap 2.1.0",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -2974,6 +2975,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "os_pipe"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57119c3b893986491ec9aa85056780d3a0f3cf4da7cc09dd3650dbd6c6738fb9"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "overload" name = "overload"
version = "0.1.1" version = "0.1.1"
@@ -4164,6 +4175,16 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shared_child"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0d94659ad3c2137fef23ae75b03d5241d633f8acded53d672decfa0e6e0caef"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "signature" name = "signature"
version = "2.1.0" version = "2.1.0"
@@ -4197,9 +4218,9 @@ dependencies = [
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.11.2" version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -4776,6 +4797,7 @@ dependencies = [
"once_cell", "once_cell",
"open", "open",
"os_info", "os_info",
"os_pipe",
"percent-encoding", "percent-encoding",
"rand 0.8.5", "rand 0.8.5",
"raw-window-handle", "raw-window-handle",
@@ -4787,6 +4809,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_repr", "serde_repr",
"serialize-to-javascript", "serialize-to-javascript",
"shared_child",
"state", "state",
"sys-locale", "sys-locale",
"tar", "tar",

View File

@@ -44,6 +44,7 @@ tauri = { version = "1.5.4", features = [
"os-all", "os-all",
"protocol-asset", "protocol-asset",
"shell-open", "shell-open",
"shell-sidecar",
"updater", "updater",
"window-close", "window-close",
"window-maximize", "window-maximize",

View File

@@ -20,3 +20,4 @@ hyper = { version = "0.14" }
hyper-rustls = { version = "0.24.0", features = ["http2"] } hyper-rustls = { version = "0.24.0", features = ["http2"] }
protoc-bin-vendored = "3.0.0" protoc-bin-vendored = "3.0.0"
uuid = { version = "1.7.0", features = ["v4"] } uuid = { version = "1.7.0", features = ["v4"] }
tauri = { version = "1.5.4", features = ["process-command-api"]}

View File

@@ -8,6 +8,7 @@ use hyper_rustls::HttpsConnector;
pub use prost_reflect::DynamicMessage; pub use prost_reflect::DynamicMessage;
use prost_reflect::{DescriptorPool, MethodDescriptor, ServiceDescriptor}; use prost_reflect::{DescriptorPool, MethodDescriptor, ServiceDescriptor};
use serde_json::Deserializer; use serde_json::Deserializer;
use tauri::AppHandle;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::body::BoxBody; use tonic::body::BoxBody;
use tonic::metadata::{MetadataKey, MetadataValue}; use tonic::metadata::{MetadataKey, MetadataValue};
@@ -166,13 +167,14 @@ impl GrpcConnection {
} }
pub struct GrpcHandle { pub struct GrpcHandle {
app_handle: AppHandle,
pools: HashMap<String, DescriptorPool>, pools: HashMap<String, DescriptorPool>,
} }
impl Default for GrpcHandle { impl GrpcHandle {
fn default() -> Self { pub fn new(app_handle: &AppHandle) -> Self {
let pools = HashMap::new(); let pools = HashMap::new();
Self { pools } Self { pools, app_handle: app_handle.clone() }
} }
} }
@@ -183,7 +185,7 @@ impl GrpcHandle {
uri: &Uri, uri: &Uri,
paths: Vec<PathBuf>, paths: Vec<PathBuf>,
) -> Result<Vec<ServiceDefinition>, String> { ) -> Result<Vec<ServiceDefinition>, String> {
let pool = fill_pool_from_files(paths).await?; let pool = fill_pool_from_files(&self.app_handle, paths).await?;
self.pools.insert(self.get_pool_key(id, uri), pool.clone()); self.pools.insert(self.get_pool_key(id, uri), pool.clone());
Ok(self.services_from_pool(&pool)) Ok(self.services_from_pool(&pool))
} }
@@ -237,7 +239,7 @@ impl GrpcHandle {
None => match proto_files.len() { None => match proto_files.len() {
0 => fill_pool(&uri).await?, 0 => fill_pool(&uri).await?,
_ => { _ => {
let pool = fill_pool_from_files(proto_files).await?; let pool = fill_pool_from_files(&self.app_handle, proto_files).await?;
self.pools.insert(id.to_string(), pool.clone()); self.pools.insert(id.to_string(), pool.clone());
pool pool
} }

View File

@@ -1,17 +1,18 @@
use std::env::temp_dir; use std::env::temp_dir;
use std::ops::Deref; use std::ops::Deref;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command;
use std::str::FromStr; use std::str::FromStr;
use anyhow::anyhow; use anyhow::anyhow;
use hyper::client::HttpConnector; use hyper::client::HttpConnector;
use hyper::Client; use hyper::Client;
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use log::{debug, warn}; use log::{debug, info, warn};
use prost::Message; use prost::Message;
use prost_reflect::{DescriptorPool, MethodDescriptor}; use prost_reflect::{DescriptorPool, MethodDescriptor};
use prost_types::{FileDescriptorProto, FileDescriptorSet}; use prost_types::{FileDescriptorProto, FileDescriptorSet};
use tauri::api::process::{Command, CommandEvent};
use tauri::AppHandle;
use tokio::fs; use tokio::fs;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tonic::body::BoxBody; use tonic::body::BoxBody;
@@ -23,40 +24,77 @@ use tonic_reflection::pb::server_reflection_request::MessageRequest;
use tonic_reflection::pb::server_reflection_response::MessageResponse; use tonic_reflection::pb::server_reflection_response::MessageResponse;
use tonic_reflection::pb::ServerReflectionRequest; use tonic_reflection::pb::ServerReflectionRequest;
pub async fn fill_pool_from_files(paths: Vec<PathBuf>) -> Result<DescriptorPool, String> { pub async fn fill_pool_from_files(
app_handle: &AppHandle,
paths: Vec<PathBuf>,
) -> Result<DescriptorPool, String> {
let mut pool = DescriptorPool::new(); let mut pool = DescriptorPool::new();
let random_file_name = format!("{}.desc", uuid::Uuid::new_v4()); let random_file_name = format!("{}.desc", uuid::Uuid::new_v4());
let desc_path = temp_dir().join(random_file_name); let desc_path = temp_dir().join(random_file_name);
let bin = protoc_bin_vendored::protoc_bin_path().unwrap(); let global_import_dir = app_handle
.path_resolver()
.resolve_resource("protoc-vendored/include")
.expect("failed to resolve protoc include directory");
let mut cmd = Command::new(bin.clone()); let mut args = vec![
cmd.arg("--include_imports") "--include_imports".to_string(),
.arg("--include_source_info") "--include_source_info".to_string(),
.arg("-o") "-I".to_string(),
.arg(&desc_path); global_import_dir.to_string_lossy().to_string(),
"-o".to_string(),
desc_path.to_string_lossy().to_string(),
];
for p in paths { for p in paths {
if p.as_path().exists() { if p.as_path().exists() {
cmd.arg(p.as_path().to_string_lossy().as_ref()); args.push(p.to_string_lossy().to_string());
} else { } else {
continue; continue;
} }
let parent = p.as_path().parent(); let parent = p.as_path().parent();
if let Some(parent_path) = parent { if let Some(parent_path) = parent {
cmd.arg("-I").arg(parent_path); args.push("-I".to_string());
cmd.arg("-I").arg(parent_path.parent().unwrap()); args.push(parent_path.to_string_lossy().to_string());
args.push("-I".to_string());
args.push(parent_path.parent().unwrap().to_string_lossy().to_string());
} else { } else {
debug!("ignoring {:?} since it does not exist.", parent) debug!("ignoring {:?} since it does not exist.", parent)
} }
} }
let output = cmd.output().map_err(|e| e.to_string())?; let (mut rx, _child) = Command::new_sidecar("protoc")
if !output.status.success() { .expect("protoc not found")
return Err(format!( .args(args)
"protoc failed: {}", .spawn()
String::from_utf8_lossy(&output.stderr) .expect("protoc failed to start");
));
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
info!("protoc stdout: {}", line);
}
CommandEvent::Stderr(line) => {
info!("protoc stderr: {}", line);
}
CommandEvent::Error(e) => {
return Err(e.to_string());
}
CommandEvent::Terminated(c) => {
match c.code {
Some(0) => {
// success
}
Some(code) => {
return Err(format!("protoc failed with exit code: {}", code,));
}
None => {
return Err("protoc failed with no exit code".to_string());
}
}
}
_ => {}
};
} }
let bytes = fs::read(desc_path.as_path()) let bytes = fs::read(desc_path.as_path())

View File

@@ -0,0 +1,162 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
syntax = "proto3";
package google.protobuf;
option go_package = "google.golang.org/protobuf/types/known/anypb";
option java_package = "com.google.protobuf";
option java_outer_classname = "AnyProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
// `Any` contains an arbitrary serialized protocol buffer message along with a
// URL that describes the type of the serialized message.
//
// Protobuf library provides support to pack/unpack Any values in the form
// of utility functions or additional generated methods of the Any type.
//
// Example 1: Pack and unpack a message in C++.
//
// Foo foo = ...;
// Any any;
// any.PackFrom(foo);
// ...
// if (any.UnpackTo(&foo)) {
// ...
// }
//
// Example 2: Pack and unpack a message in Java.
//
// Foo foo = ...;
// Any any = Any.pack(foo);
// ...
// if (any.is(Foo.class)) {
// foo = any.unpack(Foo.class);
// }
// // or ...
// if (any.isSameTypeAs(Foo.getDefaultInstance())) {
// foo = any.unpack(Foo.getDefaultInstance());
// }
//
// Example 3: Pack and unpack a message in Python.
//
// foo = Foo(...)
// any = Any()
// any.Pack(foo)
// ...
// if any.Is(Foo.DESCRIPTOR):
// any.Unpack(foo)
// ...
//
// Example 4: Pack and unpack a message in Go
//
// foo := &pb.Foo{...}
// any, err := anypb.New(foo)
// if err != nil {
// ...
// }
// ...
// foo := &pb.Foo{}
// if err := any.UnmarshalTo(foo); err != nil {
// ...
// }
//
// The pack methods provided by protobuf library will by default use
// 'type.googleapis.com/full.type.name' as the type URL and the unpack
// methods only use the fully qualified type name after the last '/'
// in the type URL, for example "foo.bar.com/x/y.z" will yield type
// name "y.z".
//
// JSON
// ====
// The JSON representation of an `Any` value uses the regular
// representation of the deserialized, embedded message, with an
// additional field `@type` which contains the type URL. Example:
//
// package google.profile;
// message Person {
// string first_name = 1;
// string last_name = 2;
// }
//
// {
// "@type": "type.googleapis.com/google.profile.Person",
// "firstName": <string>,
// "lastName": <string>
// }
//
// If the embedded message type is well-known and has a custom JSON
// representation, that representation will be embedded adding a field
// `value` which holds the custom JSON in addition to the `@type`
// field. Example (for message [google.protobuf.Duration][]):
//
// {
// "@type": "type.googleapis.com/google.protobuf.Duration",
// "value": "1.212s"
// }
//
message Any {
// A URL/resource name that uniquely identifies the type of the serialized
// protocol buffer message. This string must contain at least
// one "/" character. The last segment of the URL's path must represent
// the fully qualified name of the type (as in
// `path/google.protobuf.Duration`). The name should be in a canonical form
// (e.g., leading "." is not accepted).
//
// In practice, teams usually precompile into the binary all types that they
// expect it to use in the context of Any. However, for URLs which use the
// scheme `http`, `https`, or no scheme, one can optionally set up a type
// server that maps type URLs to message definitions as follows:
//
// * If no scheme is provided, `https` is assumed.
// * An HTTP GET on the URL must yield a [google.protobuf.Type][]
// value in binary format, or produce an error.
// * Applications are allowed to cache lookup results based on the
// URL, or have them precompiled into a binary to avoid any
// lookup. Therefore, binary compatibility needs to be preserved
// on changes to types. (Use versioned type names to manage
// breaking changes.)
//
// Note: this functionality is not currently available in the official
// protobuf release, and it is not used for type URLs beginning with
// type.googleapis.com. As of May 2023, there are no widely used type server
// implementations and no plans to implement one.
//
// Schemes other than `http`, `https` (or the empty scheme) might be
// used with implementation specific semantics.
//
string type_url = 1;
// Must be a valid serialized protocol buffer of the above specified type.
bytes value = 2;
}

View File

@@ -0,0 +1,207 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
syntax = "proto3";
package google.protobuf;
import "google/protobuf/source_context.proto";
import "google/protobuf/type.proto";
option java_package = "com.google.protobuf";
option java_outer_classname = "ApiProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
option go_package = "google.golang.org/protobuf/types/known/apipb";
// Api is a light-weight descriptor for an API Interface.
//
// Interfaces are also described as "protocol buffer services" in some contexts,
// such as by the "service" keyword in a .proto file, but they are different
// from API Services, which represent a concrete implementation of an interface
// as opposed to simply a description of methods and bindings. They are also
// sometimes simply referred to as "APIs" in other contexts, such as the name of
// this message itself. See https://cloud.google.com/apis/design/glossary for
// detailed terminology.
message Api {
// The fully qualified name of this interface, including package name
// followed by the interface's simple name.
string name = 1;
// The methods of this interface, in unspecified order.
repeated Method methods = 2;
// Any metadata attached to the interface.
repeated Option options = 3;
// A version string for this interface. If specified, must have the form
// `major-version.minor-version`, as in `1.10`. If the minor version is
// omitted, it defaults to zero. If the entire version field is empty, the
// major version is derived from the package name, as outlined below. If the
// field is not empty, the version in the package name will be verified to be
// consistent with what is provided here.
//
// The versioning schema uses [semantic
// versioning](http://semver.org) where the major version number
// indicates a breaking change and the minor version an additive,
// non-breaking change. Both version numbers are signals to users
// what to expect from different versions, and should be carefully
// chosen based on the product plan.
//
// The major version is also reflected in the package name of the
// interface, which must end in `v<major-version>`, as in
// `google.feature.v1`. For major versions 0 and 1, the suffix can
// be omitted. Zero major versions must only be used for
// experimental, non-GA interfaces.
//
string version = 4;
// Source context for the protocol buffer service represented by this
// message.
SourceContext source_context = 5;
// Included interfaces. See [Mixin][].
repeated Mixin mixins = 6;
// The source syntax of the service.
Syntax syntax = 7;
}
// Method represents a method of an API interface.
message Method {
// The simple name of this method.
string name = 1;
// A URL of the input message type.
string request_type_url = 2;
// If true, the request is streamed.
bool request_streaming = 3;
// The URL of the output message type.
string response_type_url = 4;
// If true, the response is streamed.
bool response_streaming = 5;
// Any metadata attached to the method.
repeated Option options = 6;
// The source syntax of this method.
Syntax syntax = 7;
}
// Declares an API Interface to be included in this interface. The including
// interface must redeclare all the methods from the included interface, but
// documentation and options are inherited as follows:
//
// - If after comment and whitespace stripping, the documentation
// string of the redeclared method is empty, it will be inherited
// from the original method.
//
// - Each annotation belonging to the service config (http,
// visibility) which is not set in the redeclared method will be
// inherited.
//
// - If an http annotation is inherited, the path pattern will be
// modified as follows. Any version prefix will be replaced by the
// version of the including interface plus the [root][] path if
// specified.
//
// Example of a simple mixin:
//
// package google.acl.v1;
// service AccessControl {
// // Get the underlying ACL object.
// rpc GetAcl(GetAclRequest) returns (Acl) {
// option (google.api.http).get = "/v1/{resource=**}:getAcl";
// }
// }
//
// package google.storage.v2;
// service Storage {
// rpc GetAcl(GetAclRequest) returns (Acl);
//
// // Get a data record.
// rpc GetData(GetDataRequest) returns (Data) {
// option (google.api.http).get = "/v2/{resource=**}";
// }
// }
//
// Example of a mixin configuration:
//
// apis:
// - name: google.storage.v2.Storage
// mixins:
// - name: google.acl.v1.AccessControl
//
// The mixin construct implies that all methods in `AccessControl` are
// also declared with same name and request/response types in
// `Storage`. A documentation generator or annotation processor will
// see the effective `Storage.GetAcl` method after inherting
// documentation and annotations as follows:
//
// service Storage {
// // Get the underlying ACL object.
// rpc GetAcl(GetAclRequest) returns (Acl) {
// option (google.api.http).get = "/v2/{resource=**}:getAcl";
// }
// ...
// }
//
// Note how the version in the path pattern changed from `v1` to `v2`.
//
// If the `root` field in the mixin is specified, it should be a
// relative path under which inherited HTTP paths are placed. Example:
//
// apis:
// - name: google.storage.v2.Storage
// mixins:
// - name: google.acl.v1.AccessControl
// root: acls
//
// This implies the following inherited HTTP annotation:
//
// service Storage {
// // Get the underlying ACL object.
// rpc GetAcl(GetAclRequest) returns (Acl) {
// option (google.api.http).get = "/v2/acls/{resource=**}:getAcl";
// }
// ...
// }
message Mixin {
// The fully qualified name of the interface which is included.
string name = 1;
// If non-empty specifies a path under which inherited HTTP paths
// are rooted.
string root = 2;
}

View File

@@ -0,0 +1,168 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd
// Author: kenton@google.com (Kenton Varda)
//
// protoc (aka the Protocol Compiler) can be extended via plugins. A plugin is
// just a program that reads a CodeGeneratorRequest from stdin and writes a
// CodeGeneratorResponse to stdout.
//
// Plugins written using C++ can use google/protobuf/compiler/plugin.h instead
// of dealing with the raw protocol defined here.
//
// A plugin executable needs only to be placed somewhere in the path. The
// plugin should be named "protoc-gen-$NAME", and will then be used when the
// flag "--${NAME}_out" is passed to protoc.
syntax = "proto2";
package google.protobuf.compiler;
option java_package = "com.google.protobuf.compiler";
option java_outer_classname = "PluginProtos";
option csharp_namespace = "Google.Protobuf.Compiler";
option go_package = "google.golang.org/protobuf/types/pluginpb";
import "google/protobuf/descriptor.proto";
// The version number of protocol compiler.
message Version {
optional int32 major = 1;
optional int32 minor = 2;
optional int32 patch = 3;
// A suffix for alpha, beta or rc release, e.g., "alpha-1", "rc2". It should
// be empty for mainline stable releases.
optional string suffix = 4;
}
// An encoded CodeGeneratorRequest is written to the plugin's stdin.
message CodeGeneratorRequest {
// The .proto files that were explicitly listed on the command-line. The
// code generator should generate code only for these files. Each file's
// descriptor will be included in proto_file, below.
repeated string file_to_generate = 1;
// The generator parameter passed on the command-line.
optional string parameter = 2;
// FileDescriptorProtos for all files in files_to_generate and everything
// they import. The files will appear in topological order, so each file
// appears before any file that imports it.
//
// Note: the files listed in files_to_generate will include runtime-retention
// options only, but all other files will include source-retention options.
// The source_file_descriptors field below is available in case you need
// source-retention options for files_to_generate.
//
// protoc guarantees that all proto_files will be written after
// the fields above, even though this is not technically guaranteed by the
// protobuf wire format. This theoretically could allow a plugin to stream
// in the FileDescriptorProtos and handle them one by one rather than read
// the entire set into memory at once. However, as of this writing, this
// is not similarly optimized on protoc's end -- it will store all fields in
// memory at once before sending them to the plugin.
//
// Type names of fields and extensions in the FileDescriptorProto are always
// fully qualified.
repeated FileDescriptorProto proto_file = 15;
// File descriptors with all options, including source-retention options.
// These descriptors are only provided for the files listed in
// files_to_generate.
repeated FileDescriptorProto source_file_descriptors = 17;
// The version number of protocol compiler.
optional Version compiler_version = 3;
}
// The plugin writes an encoded CodeGeneratorResponse to stdout.
message CodeGeneratorResponse {
// Error message. If non-empty, code generation failed. The plugin process
// should exit with status code zero even if it reports an error in this way.
//
// This should be used to indicate errors in .proto files which prevent the
// code generator from generating correct code. Errors which indicate a
// problem in protoc itself -- such as the input CodeGeneratorRequest being
// unparseable -- should be reported by writing a message to stderr and
// exiting with a non-zero status code.
optional string error = 1;
// A bitmask of supported features that the code generator supports.
// This is a bitwise "or" of values from the Feature enum.
optional uint64 supported_features = 2;
// Sync with code_generator.h.
enum Feature {
FEATURE_NONE = 0;
FEATURE_PROTO3_OPTIONAL = 1;
FEATURE_SUPPORTS_EDITIONS = 2;
}
// Represents a single generated file.
message File {
// The file name, relative to the output directory. The name must not
// contain "." or ".." components and must be relative, not be absolute (so,
// the file cannot lie outside the output directory). "/" must be used as
// the path separator, not "\".
//
// If the name is omitted, the content will be appended to the previous
// file. This allows the generator to break large files into small chunks,
// and allows the generated text to be streamed back to protoc so that large
// files need not reside completely in memory at one time. Note that as of
// this writing protoc does not optimize for this -- it will read the entire
// CodeGeneratorResponse before writing files to disk.
optional string name = 1;
// If non-empty, indicates that the named file should already exist, and the
// content here is to be inserted into that file at a defined insertion
// point. This feature allows a code generator to extend the output
// produced by another code generator. The original generator may provide
// insertion points by placing special annotations in the file that look
// like:
// @@protoc_insertion_point(NAME)
// The annotation can have arbitrary text before and after it on the line,
// which allows it to be placed in a comment. NAME should be replaced with
// an identifier naming the point -- this is what other generators will use
// as the insertion_point. Code inserted at this point will be placed
// immediately above the line containing the insertion point (thus multiple
// insertions to the same point will come out in the order they were added).
// The double-@ is intended to make it unlikely that the generated code
// could contain things that look like insertion points by accident.
//
// For example, the C++ code generator places the following line in the
// .pb.h files that it generates:
// // @@protoc_insertion_point(namespace_scope)
// This line appears within the scope of the file's package namespace, but
// outside of any particular class. Another plugin can then specify the
// insertion_point "namespace_scope" to generate additional classes or
// other declarations that should be placed in this scope.
//
// Note that if the line containing the insertion point begins with
// whitespace, the same whitespace will be added to every line of the
// inserted text. This is useful for languages like Python, where
// indentation matters. In these languages, the insertion point comment
// should be indented the same amount as any inserted code will need to be
// in order to work correctly in that context.
//
// The code generator that generates the initial file and the one which
// inserts into it must both run as part of a single invocation of protoc.
// Code generators are executed in the order in which they appear on the
// command line.
//
// If |insertion_point| is present, |name| must also be present.
optional string insertion_point = 2;
// The file contents.
optional string content = 15;
// Information describing the file content being inserted. If an insertion
// point is used, this information will be appropriately offset and inserted
// into the code generation metadata for the generated files.
optional GeneratedCodeInfo generated_code_info = 16;
}
repeated File file = 15;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
syntax = "proto3";
package google.protobuf;
option cc_enable_arenas = true;
option go_package = "google.golang.org/protobuf/types/known/durationpb";
option java_package = "com.google.protobuf";
option java_outer_classname = "DurationProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
// A Duration represents a signed, fixed-length span of time represented
// as a count of seconds and fractions of seconds at nanosecond
// resolution. It is independent of any calendar and concepts like "day"
// or "month". It is related to Timestamp in that the difference between
// two Timestamp values is a Duration and it can be added or subtracted
// from a Timestamp. Range is approximately +-10,000 years.
//
// # Examples
//
// Example 1: Compute Duration from two Timestamps in pseudo code.
//
// Timestamp start = ...;
// Timestamp end = ...;
// Duration duration = ...;
//
// duration.seconds = end.seconds - start.seconds;
// duration.nanos = end.nanos - start.nanos;
//
// if (duration.seconds < 0 && duration.nanos > 0) {
// duration.seconds += 1;
// duration.nanos -= 1000000000;
// } else if (duration.seconds > 0 && duration.nanos < 0) {
// duration.seconds -= 1;
// duration.nanos += 1000000000;
// }
//
// Example 2: Compute Timestamp from Timestamp + Duration in pseudo code.
//
// Timestamp start = ...;
// Duration duration = ...;
// Timestamp end = ...;
//
// end.seconds = start.seconds + duration.seconds;
// end.nanos = start.nanos + duration.nanos;
//
// if (end.nanos < 0) {
// end.seconds -= 1;
// end.nanos += 1000000000;
// } else if (end.nanos >= 1000000000) {
// end.seconds += 1;
// end.nanos -= 1000000000;
// }
//
// Example 3: Compute Duration from datetime.timedelta in Python.
//
// td = datetime.timedelta(days=3, minutes=10)
// duration = Duration()
// duration.FromTimedelta(td)
//
// # JSON Mapping
//
// In JSON format, the Duration type is encoded as a string rather than an
// object, where the string ends in the suffix "s" (indicating seconds) and
// is preceded by the number of seconds, with nanoseconds expressed as
// fractional seconds. For example, 3 seconds with 0 nanoseconds should be
// encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should
// be expressed in JSON format as "3.000000001s", and 3 seconds and 1
// microsecond should be expressed in JSON format as "3.000001s".
//
message Duration {
// Signed seconds of the span of time. Must be from -315,576,000,000
// to +315,576,000,000 inclusive. Note: these bounds are computed from:
// 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years
int64 seconds = 1;
// Signed fractions of a second at nanosecond resolution of the span
// of time. Durations less than one second are represented with a 0
// `seconds` field and a positive or negative `nanos` field. For durations
// of one second or more, a non-zero value for the `nanos` field must be
// of the same sign as the `seconds` field. Must be from -999,999,999
// to +999,999,999 inclusive.
int32 nanos = 2;
}

View File

@@ -0,0 +1,51 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
syntax = "proto3";
package google.protobuf;
option go_package = "google.golang.org/protobuf/types/known/emptypb";
option java_package = "com.google.protobuf";
option java_outer_classname = "EmptyProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
option cc_enable_arenas = true;
// A generic empty message that you can re-use to avoid defining duplicated
// empty messages in your APIs. A typical example is to use it as the request
// or the response type of an API method. For instance:
//
// service Foo {
// rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty);
// }
//
message Empty {}

View File

@@ -0,0 +1,245 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
syntax = "proto3";
package google.protobuf;
option java_package = "com.google.protobuf";
option java_outer_classname = "FieldMaskProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
option go_package = "google.golang.org/protobuf/types/known/fieldmaskpb";
option cc_enable_arenas = true;
// `FieldMask` represents a set of symbolic field paths, for example:
//
// paths: "f.a"
// paths: "f.b.d"
//
// Here `f` represents a field in some root message, `a` and `b`
// fields in the message found in `f`, and `d` a field found in the
// message in `f.b`.
//
// Field masks are used to specify a subset of fields that should be
// returned by a get operation or modified by an update operation.
// Field masks also have a custom JSON encoding (see below).
//
// # Field Masks in Projections
//
// When used in the context of a projection, a response message or
// sub-message is filtered by the API to only contain those fields as
// specified in the mask. For example, if the mask in the previous
// example is applied to a response message as follows:
//
// f {
// a : 22
// b {
// d : 1
// x : 2
// }
// y : 13
// }
// z: 8
//
// The result will not contain specific values for fields x,y and z
// (their value will be set to the default, and omitted in proto text
// output):
//
//
// f {
// a : 22
// b {
// d : 1
// }
// }
//
// A repeated field is not allowed except at the last position of a
// paths string.
//
// If a FieldMask object is not present in a get operation, the
// operation applies to all fields (as if a FieldMask of all fields
// had been specified).
//
// Note that a field mask does not necessarily apply to the
// top-level response message. In case of a REST get operation, the
// field mask applies directly to the response, but in case of a REST
// list operation, the mask instead applies to each individual message
// in the returned resource list. In case of a REST custom method,
// other definitions may be used. Where the mask applies will be
// clearly documented together with its declaration in the API. In
// any case, the effect on the returned resource/resources is required
// behavior for APIs.
//
// # Field Masks in Update Operations
//
// A field mask in update operations specifies which fields of the
// targeted resource are going to be updated. The API is required
// to only change the values of the fields as specified in the mask
// and leave the others untouched. If a resource is passed in to
// describe the updated values, the API ignores the values of all
// fields not covered by the mask.
//
// If a repeated field is specified for an update operation, new values will
// be appended to the existing repeated field in the target resource. Note that
// a repeated field is only allowed in the last position of a `paths` string.
//
// If a sub-message is specified in the last position of the field mask for an
// update operation, then new value will be merged into the existing sub-message
// in the target resource.
//
// For example, given the target message:
//
// f {
// b {
// d: 1
// x: 2
// }
// c: [1]
// }
//
// And an update message:
//
// f {
// b {
// d: 10
// }
// c: [2]
// }
//
// then if the field mask is:
//
// paths: ["f.b", "f.c"]
//
// then the result will be:
//
// f {
// b {
// d: 10
// x: 2
// }
// c: [1, 2]
// }
//
// An implementation may provide options to override this default behavior for
// repeated and message fields.
//
// In order to reset a field's value to the default, the field must
// be in the mask and set to the default value in the provided resource.
// Hence, in order to reset all fields of a resource, provide a default
// instance of the resource and set all fields in the mask, or do
// not provide a mask as described below.
//
// If a field mask is not present on update, the operation applies to
// all fields (as if a field mask of all fields has been specified).
// Note that in the presence of schema evolution, this may mean that
// fields the client does not know and has therefore not filled into
// the request will be reset to their default. If this is unwanted
// behavior, a specific service may require a client to always specify
// a field mask, producing an error if not.
//
// As with get operations, the location of the resource which
// describes the updated values in the request message depends on the
// operation kind. In any case, the effect of the field mask is
// required to be honored by the API.
//
// ## Considerations for HTTP REST
//
// The HTTP kind of an update operation which uses a field mask must
// be set to PATCH instead of PUT in order to satisfy HTTP semantics
// (PUT must only be used for full updates).
//
// # JSON Encoding of Field Masks
//
// In JSON, a field mask is encoded as a single string where paths are
// separated by a comma. Fields name in each path are converted
// to/from lower-camel naming conventions.
//
// As an example, consider the following message declarations:
//
// message Profile {
// User user = 1;
// Photo photo = 2;
// }
// message User {
// string display_name = 1;
// string address = 2;
// }
//
// In proto a field mask for `Profile` may look as such:
//
// mask {
// paths: "user.display_name"
// paths: "photo"
// }
//
// In JSON, the same mask is represented as below:
//
// {
// mask: "user.displayName,photo"
// }
//
// # Field Masks and Oneof Fields
//
// Field masks treat fields in oneofs just as regular fields. Consider the
// following message:
//
// message SampleMessage {
// oneof test_oneof {
// string name = 4;
// SubMessage sub_message = 9;
// }
// }
//
// The field mask can be:
//
// mask {
// paths: "name"
// }
//
// Or:
//
// mask {
// paths: "sub_message"
// }
//
// Note that oneof type names ("test_oneof" in this case) cannot be used in
// paths.
//
// ## Field Mask Verification
//
// The implementation of any API method which has a FieldMask type field in the
// request should verify the included field paths, and return an
// `INVALID_ARGUMENT` error if any path is unmappable.
message FieldMask {
// The set of field mask paths.
repeated string paths = 1;
}

View File

@@ -0,0 +1,48 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
syntax = "proto3";
package google.protobuf;
option java_package = "com.google.protobuf";
option java_outer_classname = "SourceContextProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
option go_package = "google.golang.org/protobuf/types/known/sourcecontextpb";
// `SourceContext` represents information about the source of a
// protobuf element, like the file in which it is defined.
message SourceContext {
// The path-qualified name of the .proto file that contained the associated
// protobuf element. For example: `"google/protobuf/source_context.proto"`.
string file_name = 1;
}

View File

@@ -0,0 +1,95 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
syntax = "proto3";
package google.protobuf;
option cc_enable_arenas = true;
option go_package = "google.golang.org/protobuf/types/known/structpb";
option java_package = "com.google.protobuf";
option java_outer_classname = "StructProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
// `Struct` represents a structured data value, consisting of fields
// which map to dynamically typed values. In some languages, `Struct`
// might be supported by a native representation. For example, in
// scripting languages like JS a struct is represented as an
// object. The details of that representation are described together
// with the proto support for the language.
//
// The JSON representation for `Struct` is JSON object.
message Struct {
// Unordered map of dynamically typed values.
map<string, Value> fields = 1;
}
// `Value` represents a dynamically typed value which can be either
// null, a number, a string, a boolean, a recursive struct value, or a
// list of values. A producer of value is expected to set one of these
// variants. Absence of any variant indicates an error.
//
// The JSON representation for `Value` is JSON value.
message Value {
// The kind of value.
oneof kind {
// Represents a null value.
NullValue null_value = 1;
// Represents a double value.
double number_value = 2;
// Represents a string value.
string string_value = 3;
// Represents a boolean value.
bool bool_value = 4;
// Represents a structured value.
Struct struct_value = 5;
// Represents a repeated `Value`.
ListValue list_value = 6;
}
}
// `NullValue` is a singleton enumeration to represent the null value for the
// `Value` type union.
//
// The JSON representation for `NullValue` is JSON `null`.
enum NullValue {
// Null value.
NULL_VALUE = 0;
}
// `ListValue` is a wrapper around a repeated field of values.
//
// The JSON representation for `ListValue` is JSON array.
message ListValue {
// Repeated field of dynamically typed values.
repeated Value values = 1;
}

View File

@@ -0,0 +1,144 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
syntax = "proto3";
package google.protobuf;
option cc_enable_arenas = true;
option go_package = "google.golang.org/protobuf/types/known/timestamppb";
option java_package = "com.google.protobuf";
option java_outer_classname = "TimestampProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
// A Timestamp represents a point in time independent of any time zone or local
// calendar, encoded as a count of seconds and fractions of seconds at
// nanosecond resolution. The count is relative to an epoch at UTC midnight on
// January 1, 1970, in the proleptic Gregorian calendar which extends the
// Gregorian calendar backwards to year one.
//
// All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap
// second table is needed for interpretation, using a [24-hour linear
// smear](https://developers.google.com/time/smear).
//
// The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By
// restricting to that range, we ensure that we can convert to and from [RFC
// 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings.
//
// # Examples
//
// Example 1: Compute Timestamp from POSIX `time()`.
//
// Timestamp timestamp;
// timestamp.set_seconds(time(NULL));
// timestamp.set_nanos(0);
//
// Example 2: Compute Timestamp from POSIX `gettimeofday()`.
//
// struct timeval tv;
// gettimeofday(&tv, NULL);
//
// Timestamp timestamp;
// timestamp.set_seconds(tv.tv_sec);
// timestamp.set_nanos(tv.tv_usec * 1000);
//
// Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`.
//
// FILETIME ft;
// GetSystemTimeAsFileTime(&ft);
// UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
//
// // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z
// // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z.
// Timestamp timestamp;
// timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL));
// timestamp.set_nanos((INT32) ((ticks % 10000000) * 100));
//
// Example 4: Compute Timestamp from Java `System.currentTimeMillis()`.
//
// long millis = System.currentTimeMillis();
//
// Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000)
// .setNanos((int) ((millis % 1000) * 1000000)).build();
//
// Example 5: Compute Timestamp from Java `Instant.now()`.
//
// Instant now = Instant.now();
//
// Timestamp timestamp =
// Timestamp.newBuilder().setSeconds(now.getEpochSecond())
// .setNanos(now.getNano()).build();
//
// Example 6: Compute Timestamp from current time in Python.
//
// timestamp = Timestamp()
// timestamp.GetCurrentTime()
//
// # JSON Mapping
//
// In JSON format, the Timestamp type is encoded as a string in the
// [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the
// format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z"
// where {year} is always expressed using four digits while {month}, {day},
// {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional
// seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution),
// are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone
// is required. A proto3 JSON serializer should always use UTC (as indicated by
// "Z") when printing the Timestamp type and a proto3 JSON parser should be
// able to accept both UTC and other timezones (as indicated by an offset).
//
// For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past
// 01:30 UTC on January 15, 2017.
//
// In JavaScript, one can convert a Date object to this format using the
// standard
// [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
// method. In Python, a standard `datetime.datetime` object can be converted
// to this format using
// [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with
// the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use
// the Joda Time's [`ISODateTimeFormat.dateTime()`](
// http://joda-time.sourceforge.net/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime()
// ) to obtain a formatter capable of generating timestamps in this format.
//
message Timestamp {
// Represents seconds of UTC time since Unix epoch
// 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to
// 9999-12-31T23:59:59Z inclusive.
int64 seconds = 1;
// Non-negative fractions of a second at nanosecond resolution. Negative
// second values with fractions must still have non-negative nanos values
// that count forward in time. Must be from 0 to 999,999,999
// inclusive.
int32 nanos = 2;
}

View File

@@ -0,0 +1,193 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
syntax = "proto3";
package google.protobuf;
import "google/protobuf/any.proto";
import "google/protobuf/source_context.proto";
option cc_enable_arenas = true;
option java_package = "com.google.protobuf";
option java_outer_classname = "TypeProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
option go_package = "google.golang.org/protobuf/types/known/typepb";
// A protocol buffer message type.
message Type {
// The fully qualified message name.
string name = 1;
// The list of fields.
repeated Field fields = 2;
// The list of types appearing in `oneof` definitions in this type.
repeated string oneofs = 3;
// The protocol buffer options.
repeated Option options = 4;
// The source context.
SourceContext source_context = 5;
// The source syntax.
Syntax syntax = 6;
// The source edition string, only valid when syntax is SYNTAX_EDITIONS.
string edition = 7;
}
// A single field of a message type.
message Field {
// Basic field types.
enum Kind {
// Field type unknown.
TYPE_UNKNOWN = 0;
// Field type double.
TYPE_DOUBLE = 1;
// Field type float.
TYPE_FLOAT = 2;
// Field type int64.
TYPE_INT64 = 3;
// Field type uint64.
TYPE_UINT64 = 4;
// Field type int32.
TYPE_INT32 = 5;
// Field type fixed64.
TYPE_FIXED64 = 6;
// Field type fixed32.
TYPE_FIXED32 = 7;
// Field type bool.
TYPE_BOOL = 8;
// Field type string.
TYPE_STRING = 9;
// Field type group. Proto2 syntax only, and deprecated.
TYPE_GROUP = 10;
// Field type message.
TYPE_MESSAGE = 11;
// Field type bytes.
TYPE_BYTES = 12;
// Field type uint32.
TYPE_UINT32 = 13;
// Field type enum.
TYPE_ENUM = 14;
// Field type sfixed32.
TYPE_SFIXED32 = 15;
// Field type sfixed64.
TYPE_SFIXED64 = 16;
// Field type sint32.
TYPE_SINT32 = 17;
// Field type sint64.
TYPE_SINT64 = 18;
}
// Whether a field is optional, required, or repeated.
enum Cardinality {
// For fields with unknown cardinality.
CARDINALITY_UNKNOWN = 0;
// For optional fields.
CARDINALITY_OPTIONAL = 1;
// For required fields. Proto2 syntax only.
CARDINALITY_REQUIRED = 2;
// For repeated fields.
CARDINALITY_REPEATED = 3;
}
// The field type.
Kind kind = 1;
// The field cardinality.
Cardinality cardinality = 2;
// The field number.
int32 number = 3;
// The field name.
string name = 4;
// The field type URL, without the scheme, for message or enumeration
// types. Example: `"type.googleapis.com/google.protobuf.Timestamp"`.
string type_url = 6;
// The index of the field type in `Type.oneofs`, for message or enumeration
// types. The first type has index 1; zero means the type is not in the list.
int32 oneof_index = 7;
// Whether to use alternative packed wire representation.
bool packed = 8;
// The protocol buffer options.
repeated Option options = 9;
// The field JSON name.
string json_name = 10;
// The string value of the default value of this field. Proto2 syntax only.
string default_value = 11;
}
// Enum type definition.
message Enum {
// Enum type name.
string name = 1;
// Enum value definitions.
repeated EnumValue enumvalue = 2;
// Protocol buffer options.
repeated Option options = 3;
// The source context.
SourceContext source_context = 4;
// The source syntax.
Syntax syntax = 5;
// The source edition string, only valid when syntax is SYNTAX_EDITIONS.
string edition = 6;
}
// Enum value definition.
message EnumValue {
// Enum value name.
string name = 1;
// Enum value number.
int32 number = 2;
// Protocol buffer options.
repeated Option options = 3;
}
// A protocol buffer option, which can be attached to a message, field,
// enumeration, etc.
message Option {
// The option's name. For protobuf built-in options (options defined in
// descriptor.proto), this is the short name. For example, `"map_entry"`.
// For custom options, it should be the fully-qualified name. For example,
// `"google.api.http"`.
string name = 1;
// The option's value packed in an Any message. If the value is a primitive,
// the corresponding wrapper type defined in google/protobuf/wrappers.proto
// should be used. If the value is an enum, it should be stored as an int32
// value using the google.protobuf.Int32Value type.
Any value = 2;
}
// The syntax in which a protocol buffer element is defined.
enum Syntax {
// Syntax `proto2`.
SYNTAX_PROTO2 = 0;
// Syntax `proto3`.
SYNTAX_PROTO3 = 1;
// Syntax `editions`.
SYNTAX_EDITIONS = 2;
}

View File

@@ -0,0 +1,123 @@
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
//
// Wrappers for primitive (non-message) types. These types are useful
// for embedding primitives in the `google.protobuf.Any` type and for places
// where we need to distinguish between the absence of a primitive
// typed field and its default value.
//
// These wrappers have no meaningful use within repeated fields as they lack
// the ability to detect presence on individual elements.
// These wrappers have no meaningful use within a map or a oneof since
// individual entries of a map or fields of a oneof can already detect presence.
syntax = "proto3";
package google.protobuf;
option cc_enable_arenas = true;
option go_package = "google.golang.org/protobuf/types/known/wrapperspb";
option java_package = "com.google.protobuf";
option java_outer_classname = "WrappersProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";
option csharp_namespace = "Google.Protobuf.WellKnownTypes";
// Wrapper message for `double`.
//
// The JSON representation for `DoubleValue` is JSON number.
message DoubleValue {
// The double value.
double value = 1;
}
// Wrapper message for `float`.
//
// The JSON representation for `FloatValue` is JSON number.
message FloatValue {
// The float value.
float value = 1;
}
// Wrapper message for `int64`.
//
// The JSON representation for `Int64Value` is JSON string.
message Int64Value {
// The int64 value.
int64 value = 1;
}
// Wrapper message for `uint64`.
//
// The JSON representation for `UInt64Value` is JSON string.
message UInt64Value {
// The uint64 value.
uint64 value = 1;
}
// Wrapper message for `int32`.
//
// The JSON representation for `Int32Value` is JSON number.
message Int32Value {
// The int32 value.
int32 value = 1;
}
// Wrapper message for `uint32`.
//
// The JSON representation for `UInt32Value` is JSON number.
message UInt32Value {
// The uint32 value.
uint32 value = 1;
}
// Wrapper message for `bool`.
//
// The JSON representation for `BoolValue` is JSON `true` and `false`.
message BoolValue {
// The bool value.
bool value = 1;
}
// Wrapper message for `string`.
//
// The JSON representation for `StringValue` is JSON string.
message StringValue {
// The string value.
string value = 1;
}
// Wrapper message for `bytes`.
//
// The JSON representation for `BytesValue` is JSON string.
message BytesValue {
// The bytes value.
bytes value = 1;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +1,13 @@
use std::fmt::Display; use std::fmt::Display;
use log::{debug, warn}; use log::{debug, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use sqlx::types::JsonValue; use sqlx::types::JsonValue;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use crate::{is_dev, models}; use crate::is_dev;
use crate::models::{generate_id, get_key_value_int, get_key_value_string, set_key_value_int, set_key_value_string};
// serializable // serializable
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@@ -34,7 +36,11 @@ impl AnalyticsResource {
impl Display for AnalyticsResource { impl Display for AnalyticsResource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap().replace("\"", "")) write!(
f,
"{}",
serde_json::to_string(self).unwrap().replace("\"", "")
)
} }
} }
@@ -42,6 +48,7 @@ impl Display for AnalyticsResource {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AnalyticsAction { pub enum AnalyticsAction {
Cancel, Cancel,
Commit,
Create, Create,
Delete, Delete,
DeleteMany, DeleteMany,
@@ -67,7 +74,11 @@ impl AnalyticsAction {
impl Display for AnalyticsAction { impl Display for AnalyticsAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap().replace("\"", "")) write!(
f,
"{}",
serde_json::to_string(self).unwrap().replace("\"", "")
)
} }
} }
@@ -85,10 +96,9 @@ pub async fn track_launch_event(app_handle: &AppHandle) -> LaunchEventInfo {
let mut info = LaunchEventInfo::default(); let mut info = LaunchEventInfo::default();
info.num_launches = info.num_launches = get_key_value_int(app_handle, namespace, "num_launches", 0).await + 1;
models::get_key_value_int(app_handle, namespace, "num_launches", 0).await + 1;
info.previous_version = info.previous_version =
models::get_key_value_string(app_handle, namespace, last_tracked_version_key, "").await; get_key_value_string(app_handle, namespace, last_tracked_version_key, "").await;
info.current_version = app_handle.package_info().version.to_string(); info.current_version = app_handle.package_info().version.to_string();
if info.previous_version.is_empty() { if info.previous_version.is_empty() {
@@ -123,14 +133,14 @@ pub async fn track_launch_event(app_handle: &AppHandle) -> LaunchEventInfo {
// Update key values // Update key values
models::set_key_value_string( set_key_value_string(
app_handle, app_handle,
namespace, namespace,
last_tracked_version_key, last_tracked_version_key,
info.current_version.as_str(), info.current_version.as_str(),
) )
.await; .await;
models::set_key_value_int(app_handle, namespace, "num_launches", info.num_launches).await; set_key_value_int(app_handle, namespace, "num_launches", info.num_launches).await;
info info
} }
@@ -141,6 +151,7 @@ pub async fn track_event(
action: AnalyticsAction, action: AnalyticsAction,
attributes: Option<JsonValue>, attributes: Option<JsonValue>,
) { ) {
let id = get_id(app_handle).await;
let event = format!("{}.{}", resource, action); let event = format!("{}.{}", resource, action);
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string(); let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();
let info = app_handle.package_info(); let info = app_handle.package_info();
@@ -154,6 +165,7 @@ pub async fn track_event(
false => "https://t.yaak.app", false => "https://t.yaak.app",
}; };
let params = vec![ let params = vec![
("u", id),
("e", event.clone()), ("e", event.clone()),
("a", attributes_json.clone()), ("a", attributes_json.clone()),
("id", site.to_string()), ("id", site.to_string()),
@@ -170,7 +182,7 @@ pub async fn track_event(
// Disable analytics actual sending in dev // Disable analytics actual sending in dev
if is_dev() { if is_dev() {
debug!("track: {} {}", event, attributes_json); debug!("track: {} {} {:?}", event, attributes_json, params);
return; return;
} }
@@ -216,3 +228,14 @@ fn get_window_size(app_handle: &AppHandle) -> String {
(height / 100.0).round() * 100.0 (height / 100.0).round() * 100.0
) )
} }
async fn get_id(app_handle: &AppHandle) -> String {
let id = get_key_value_string(app_handle, "analytics", "id", "").await;
if id.is_empty() {
let new_id = generate_id(None);
set_key_value_string(app_handle, "analytics", "id", new_id.as_str()).await;
new_id
} else {
id
}
}

View File

@@ -7,15 +7,15 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use base64::Engine; use base64::Engine;
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use http::header::{ACCEPT, USER_AGENT}; use http::header::{ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use log::{error, info, warn}; use log::{error, info, warn};
use reqwest::{multipart, Url};
use reqwest::redirect::Policy; use reqwest::redirect::Policy;
use reqwest::{multipart, Url};
use sqlx::types::{Json, JsonValue}; use sqlx::types::{Json, JsonValue};
use tauri::{Manager, Window}; use tauri::{Manager, Window};
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tokio::sync::watch::{Receiver}; use tokio::sync::watch::Receiver;
use crate::{models, render, response_err}; use crate::{models, render, response_err};
@@ -244,6 +244,22 @@ pub async fn send_http_request(
} }
} }
request_builder = request_builder.form(&form_params); request_builder = request_builder.form(&form_params);
} else if body_type == "binary" && request_body.contains_key("filePath") {
let file_path = request_body
.get("filePath")
.ok_or("filePath not set")?
.as_str()
.unwrap_or_default();
match fs::read(file_path).map_err(|e| e.to_string()) {
Ok(f) => {
request_builder = request_builder.body(f);
}
Err(e) => {
return response_err(response, e, window).await;
}
}
} else if body_type == "multipart/form-data" && request_body.contains_key("form") { } else if body_type == "multipart/form-data" && request_body.contains_key("form") {
let mut multipart_form = multipart::Form::new(); let mut multipart_form = multipart::Form::new();
if let Some(form_definition) = request_body.get("form") { if let Some(form_definition) = request_body.get("form") {
@@ -307,11 +323,11 @@ pub async fn send_http_request(
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let (resp_tx, resp_rx) = oneshot::channel(); let (resp_tx, resp_rx) = oneshot::channel();
tokio::spawn(async move { tokio::spawn(async move {
let _ = resp_tx.send(client.execute(sendable_req).await); let _ = resp_tx.send(client.execute(sendable_req).await);
}); });
let raw_response = tokio::select! { let raw_response = tokio::select! {
Ok(r) = resp_rx => {r} Ok(r) = resp_rx => {r}
_ = cancel_rx.changed() => { _ = cancel_rx.changed() => {

View File

@@ -10,53 +10,39 @@ extern crate objc;
use std::collections::HashMap; use std::collections::HashMap;
use std::env::current_dir; use std::env::current_dir;
use std::fs::{create_dir_all, read_to_string, File}; use std::fs::{create_dir_all, File, read_to_string};
use std::path::PathBuf; use std::path::PathBuf;
use std::process::exit; use std::process::exit;
use std::str::FromStr; use std::str::FromStr;
use ::http::uri::InvalidUri;
use ::http::Uri; use ::http::Uri;
use ::http::uri::InvalidUri;
use base64::Engine; use base64::Engine;
use fern::colors::ColoredLevelConfig; use fern::colors::ColoredLevelConfig;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rand::random; use rand::random;
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::{Pool, Sqlite, SqlitePool};
use sqlx::migrate::Migrator; use sqlx::migrate::Migrator;
use sqlx::types::Json; use sqlx::types::Json;
use sqlx::{Pool, Sqlite, SqlitePool};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl}; use tauri::{AppHandle, RunEvent, State, Window, WindowUrl};
use tauri::{Manager, WindowEvent}; use tauri::{Manager, WindowEvent};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri_plugin_log::{fern, LogTarget}; use tauri_plugin_log::{fern, LogTarget};
use tauri_plugin_window_state::{StateFlags, WindowExt}; use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::sleep; use tokio::time::sleep;
use window_shadows::set_shadow; use window_shadows::set_shadow;
use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition};
use ::grpc::manager::{DynamicMessage, GrpcHandle}; use ::grpc::manager::{DynamicMessage, GrpcHandle};
use ::grpc::{deserialize_message, serialize_message, Code, ServiceDefinition};
use window_ext::TrafficLightWindowExt; use window_ext::TrafficLightWindowExt;
use crate::analytics::{AnalyticsAction, AnalyticsResource}; use crate::analytics::{AnalyticsAction, AnalyticsResource};
use crate::grpc::metadata_to_map; use crate::grpc::metadata_to_map;
use crate::http::send_http_request; use crate::http::send_http_request;
use crate::models::{ use crate::models::{cancel_pending_grpc_connections, cancel_pending_responses, CookieJar, create_http_response, delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request, delete_http_response, delete_workspace, duplicate_grpc_request, duplicate_http_request, Environment, EnvironmentVariable, Folder, get_cookie_jar, get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request, get_http_response, get_key_value_raw, get_or_create_settings, get_workspace, get_workspace_export_resources, GrpcConnection, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpRequestHeader, HttpResponse, KeyValue, list_cookie_jars, list_environments, list_folders, list_grpc_connections, list_grpc_events, list_grpc_requests, list_requests, list_responses, list_workspaces, set_key_value_raw, Settings, update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, Workspace, WorkspaceExportResources};
cancel_pending_grpc_connections, cancel_pending_responses, create_http_response,
delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment,
delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request,
delete_http_response, delete_workspace, duplicate_grpc_request, duplicate_http_request,
get_cookie_jar, get_environment, get_folder, get_grpc_connection, get_grpc_request,
get_http_request, get_http_response, get_key_value_raw, get_or_create_settings, get_workspace,
get_workspace_export_resources, list_cookie_jars, list_environments, list_folders,
list_grpc_connections, list_grpc_events, list_grpc_requests, list_requests, list_responses,
list_workspaces, set_key_value_raw, update_response_if_id, update_settings, upsert_cookie_jar,
upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event,
upsert_grpc_request, upsert_http_request, upsert_workspace, CookieJar, Environment,
EnvironmentVariable, Folder, GrpcConnection, GrpcEvent, GrpcEventType, GrpcRequest,
HttpRequest, HttpResponse, KeyValue, Settings, Workspace, WorkspaceExportResources,
};
use crate::plugin::ImportResult; use crate::plugin::ImportResult;
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater}; use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
@@ -828,11 +814,14 @@ async fn cmd_send_http_request(
.expect("Failed to get request"); .expect("Failed to get request");
let environment = match environment_id { let environment = match environment_id {
Some(id) => Some( Some(id) =>
get_environment(&window, id) match get_environment(&window, id).await {
.await Ok(env) => Some(env),
.expect("Failed to get environment"), Err(e) => {
), warn!("Failed to find environment by id {id} {}", e);
None
}
},
None => None, None => None,
}; };
@@ -1051,6 +1040,7 @@ async fn cmd_create_http_request(
sort_priority: f64, sort_priority: f64,
folder_id: Option<&str>, folder_id: Option<&str>,
method: Option<&str>, method: Option<&str>,
headers: Option<Vec<HttpRequestHeader>>,
body_type: Option<&str>, body_type: Option<&str>,
w: Window, w: Window,
) -> Result<HttpRequest, String> { ) -> Result<HttpRequest, String> {
@@ -1062,6 +1052,7 @@ async fn cmd_create_http_request(
folder_id: folder_id.map(|s| s.to_string()), folder_id: folder_id.map(|s| s.to_string()),
body_type: body_type.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()), method: method.map(|s| s.to_string()).unwrap_or("GET".to_string()),
headers: Json(headers.unwrap_or_default()),
sort_priority, sort_priority,
..Default::default() ..Default::default()
}, },
@@ -1386,7 +1377,11 @@ fn main() {
.level_for("tower", log::LevelFilter::Info) .level_for("tower", log::LevelFilter::Info)
.level_for("tracing", log::LevelFilter::Info) .level_for("tracing", log::LevelFilter::Info)
.with_colors(ColoredLevelConfig::default()) .with_colors(ColoredLevelConfig::default())
.level(log::LevelFilter::Trace) .level(if is_dev() {
log::LevelFilter::Trace
} else {
log::LevelFilter::Info
})
.build(), .build(),
) )
.setup(|app| { .setup(|app| {
@@ -1419,7 +1414,7 @@ fn main() {
app.manage(Mutex::new(yaak_updater)); app.manage(Mutex::new(yaak_updater));
// Add GRPC manager // Add GRPC manager
let grpc_handle = GrpcHandle::default(); let grpc_handle = GrpcHandle::new(&app.app_handle());
app.manage(Mutex::new(grpc_handle)); app.manage(Mutex::new(grpc_handle));
// Add DB handle // Add DB handle
@@ -1530,7 +1525,7 @@ fn main() {
let h = app_handle.clone(); let h = app_handle.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let info = analytics::track_launch_event(&h).await; let info = analytics::track_launch_event(&h).await;
info!("Launched Yaak {:?}", info); debug!("Launched Yaak {:?}", info);
// Wait for window render and give a chance for the user to notice // Wait for window render and give a chance for the user to notice
if info.launched_after_update && info.num_launches > 1 { if info.launched_after_update && info.num_launches > 1 {

View File

@@ -8,7 +8,7 @@
}, },
"package": { "package": {
"productName": "Yaak", "productName": "Yaak",
"version": "2024.3.0-beta.1" "version": "2024.3.4"
}, },
"tauri": { "tauri": {
"windows": [], "windows": [],
@@ -28,11 +28,17 @@
"scope": [ "scope": [
"$RESOURCE/*", "$RESOURCE/*",
"$APPDATA/responses/*" "$APPDATA/responses/*"
] ]
}, },
"shell": { "shell": {
"all": false, "all": false,
"open": true "open": true,
"sidecar": true,
"scope": [
{ "name": "protoc", "sidecar": true,
"args": true
}
]
}, },
"window": { "window": {
"close": true, "close": true,
@@ -56,7 +62,9 @@
"active": true, "active": true,
"category": "DeveloperTool", "category": "DeveloperTool",
"copyright": "", "copyright": "",
"externalBin": [], "externalBin": [
"protoc-vendored/protoc"
],
"icon": [ "icon": [
"icons/release/32x32.png", "icons/release/32x32.png",
"icons/release/128x128.png", "icons/release/128x128.png",
@@ -68,7 +76,8 @@
"longDescription": "The best cross-platform visual API client", "longDescription": "The best cross-platform visual API client",
"resources": [ "resources": [
"migrations/*", "migrations/*",
"plugins/*" "plugins/*",
"protoc-vendored/include/*"
], ],
"shortDescription": "The best API client", "shortDescription": "The best API client",
"targets": [ "targets": [

View File

@@ -0,0 +1,82 @@
import { open } from '@tauri-apps/api/dialog';
import mime from 'mime';
import { useKeyValue } from '../hooks/useKeyValue';
import type { HttpRequest } from '../lib/models';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { HStack, VStack } from './core/Stacks';
type Props = {
requestId: string;
contentType: string | null;
body: HttpRequest['body'];
onChange: (body: HttpRequest['body']) => void;
onChangeContentType: (contentType: string | null) => void;
};
export function BinaryFileEditor({
contentType,
body,
onChange,
onChangeContentType,
requestId,
}: Props) {
const ignoreContentType = useKeyValue<boolean>({
namespace: 'global',
key: ['ignore_content_type', requestId],
fallback: false,
});
const handleClick = async () => {
await ignoreContentType.set(false);
const path = await open({
title: 'Select File',
multiple: false,
});
if (path) {
onChange({ filePath: path });
} else {
onChange({ filePath: undefined });
}
};
const filePath = typeof body.filePath === 'string' ? body.filePath : undefined;
const mimeType = mime.getType(filePath ?? '') ?? 'application/octet-stream';
return (
<VStack space={2}>
<HStack space={2} alignItems="center">
<Button variant="border" color="gray" size="sm" onClick={handleClick}>
Choose File
</Button>
<div className="text-xs font-mono truncate rtl pr-3 text-gray-800">
{/* Special character to insert ltr text in rtl element without making things wonky */}
&#x200E;
{filePath ?? 'Select File'}
</div>
</HStack>
{filePath != null && mimeType !== contentType && !ignoreContentType.value && (
<Banner className="mt-3 !py-5">
<div className="text-sm mb-4 text-center">
<div>Set Content-Type header</div>
<InlineCode>{mimeType}</InlineCode> for current request?
</div>
<HStack space={1.5} justifyContent="center">
<Button
variant="solid"
color="gray"
size="xs"
onClick={() => onChangeContentType(mimeType)}
>
Set Header
</Button>
<Button size="xs" variant="border" onClick={() => ignoreContentType.set(true)}>
Ignore
</Button>
</HStack>
</Banner>
)}
</VStack>
);
}

View File

@@ -1,53 +1,17 @@
import { useCreateFolder } from '../hooks/useCreateFolder'; import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest'; import type { DropdownProps } from './core/Dropdown';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
import { BODY_TYPE_GRAPHQL } from '../lib/models';
import type { DropdownItem, DropdownProps } from './core/Dropdown';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
interface Props { interface Props {
hideFolder?: boolean; hideFolder?: boolean;
children: DropdownProps['children']; children: DropdownProps['children'];
openOnHotKeyAction?: DropdownProps['openOnHotKeyAction'];
} }
export function CreateDropdown({ hideFolder, children }: Props) { export function CreateDropdown({ hideFolder, children, openOnHotKeyAction }: Props) {
const createHttpRequest = useCreateHttpRequest(); const items = useCreateDropdownItems({ hideFolder, hideIcons: true });
const createGrpcRequest = useCreateGrpcRequest();
const createFolder = useCreateFolder();
return ( return (
<Dropdown <Dropdown openOnHotKeyAction={openOnHotKeyAction} items={items}>
openOnHotKeyAction="http_request.create"
items={[
{
key: 'create-http-request',
label: 'HTTP Request',
onSelect: () => createHttpRequest.mutate({}),
},
{
key: 'create-graphql-request',
label: 'GraphQL Query',
onSelect: () => createHttpRequest.mutate({ bodyType: BODY_TYPE_GRAPHQL, method: 'POST' }),
},
{
key: 'create-grpc-request',
label: 'gRPC Call',
onSelect: () => createGrpcRequest.mutate({}),
},
...((hideFolder
? []
: [
{
type: 'separator',
},
{
key: 'create-folder',
label: 'Folder',
onSelect: () => createFolder.mutate({}),
},
]) as DropdownItem[]),
]}
>
{children} {children}
</Dropdown> </Dropdown>
); );

View File

@@ -1,5 +1,4 @@
import React, { createContext, useContext, useMemo, useState } from 'react'; import React, { createContext, useContext, useMemo, useState } from 'react';
import { useHotKey } from '../hooks/useHotKey';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import type { DialogProps } from './core/Dialog'; import type { DialogProps } from './core/Dialog';
import { Dialog } from './core/Dialog'; import { Dialog } from './core/Dialog';
@@ -21,7 +20,7 @@ interface Actions {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const DialogContext = createContext<State>({} as any); const DialogContext = createContext<State>({} as State);
export const DialogProvider = ({ children }: { children: React.ReactNode }) => { export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
const [dialogs, setDialogs] = useState<State['dialogs']>([]); const [dialogs, setDialogs] = useState<State['dialogs']>([]);
@@ -60,7 +59,6 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
function DialogInstance({ id, render, ...props }: DialogEntry) { function DialogInstance({ id, render, ...props }: DialogEntry) {
const { actions } = useContext(DialogContext); const { actions } = useContext(DialogContext);
const children = render({ hide: () => actions.hide(id) }); const children = render({ hide: () => actions.hide(id) });
useHotKey('popup.close', () => actions.hide(id));
return ( return (
<Dialog open onClose={() => actions.hide(id)} {...props}> <Dialog open onClose={() => actions.hide(id)} {...props}>
{children} {children}

View File

@@ -6,7 +6,7 @@ import { PairEditor } from './core/PairEditor';
type Props = { type Props = {
forceUpdateKey: string; forceUpdateKey: string;
body: HttpRequest['body']; body: HttpRequest['body'];
onChange: (headers: HttpRequest['body']) => void; onChange: (body: HttpRequest['body']) => void;
}; };
export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) { export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {

View File

@@ -18,7 +18,6 @@ import { settingsQueryKey } from '../hooks/useSettings';
import { useSyncAppearance } from '../hooks/useSyncAppearance'; import { useSyncAppearance } from '../hooks/useSyncAppearance';
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle'; import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
import { workspacesQueryKey } from '../hooks/useWorkspaces'; import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Model } from '../lib/models'; import type { Model } from '../lib/models';
import { modelsEq } from '../lib/models'; import { modelsEq } from '../lib/models';
import { setPathname } from '../lib/persistPathname'; import { setPathname } from '../lib/persistPathname';
@@ -142,7 +141,7 @@ function removeById<T extends { id: string }>(model: T) {
const shouldIgnoreModel = (payload: Model) => { const shouldIgnoreModel = (payload: Model) => {
if (payload.model === 'key_value') { if (payload.model === 'key_value') {
return payload.namespace === NAMESPACE_NO_SYNC; return payload.namespace === 'no_sync';
} }
return false; return false;
}; };

View File

@@ -119,6 +119,11 @@ export function GrpcConnectionSetupPane({
onGo(); onGo();
}, [activeRequest, onGo]); }, [activeRequest, onGo]);
const handleSend = useCallback(async () => {
if (activeRequest == null) return;
onSend({ message: activeRequest.message });
}, [activeRequest, onGo]);
const tabs: TabItem[] = useMemo( const tabs: TabItem[] = useMemo(
() => [ () => [
{ value: 'message', label: 'Message' }, { value: 'message', label: 'Message' },
@@ -212,52 +217,52 @@ export function GrpcConnectionSetupPane({
{select.options.find((o) => o.value === select.value)?.label ?? 'No Schema'} {select.options.find((o) => o.value === select.value)?.label ?? 'No Schema'}
</Button> </Button>
</RadioDropdown> </RadioDropdown>
{!isStreaming && ( {methodType === 'client_streaming' || methodType === 'streaming' ? (
<>
{isStreaming && (
<>
<IconButton
className="border border-highlight"
size="sm"
title="Cancel"
onClick={onCancel}
icon="x"
/>
<IconButton
className="border border-highlight"
size="sm"
title="Commit"
onClick={onCommit}
icon="check"
/>
</>
)}
<IconButton
className="border border-highlight"
size="sm"
title={isStreaming ? 'Connect' : 'Send'}
hotkeyAction="grpc_request.send"
onClick={isStreaming ? handleSend : handleConnect}
icon={isStreaming ? 'sendHorizontal' : 'arrowUpDown'}
/>
</>
) : (
<IconButton <IconButton
className="border border-highlight" className="border border-highlight"
size="sm" size="sm"
title={methodType === 'unary' ? 'Send' : 'Connect'} title={methodType === 'unary' ? 'Send' : 'Connect'}
hotkeyAction="grpc_request.send" hotkeyAction="grpc_request.send"
onClick={handleConnect} onClick={isStreaming ? onCancel : handleConnect}
disabled={methodType === 'no-schema' || methodType === 'no-method'} disabled={methodType === 'no-schema' || methodType === 'no-method'}
icon={ icon={
isStreaming isStreaming
? 'refresh' ? 'x'
: methodType.includes('streaming') : methodType.includes('streaming')
? 'arrowUpDown' ? 'arrowUpDown'
: 'sendHorizontal' : 'sendHorizontal'
} }
/> />
)} )}
{isStreaming && (
<IconButton
className="border border-highlight"
size="sm"
title="Cancel"
onClick={onCancel}
icon="x"
disabled={!isStreaming}
/>
)}
{(methodType === 'client_streaming' || methodType === 'streaming') && isStreaming && (
<>
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
onClick={onCommit}
icon="check"
/>
<IconButton
className="border border-highlight"
size="sm"
title="to-do"
hotkeyAction="grpc_request.send"
onClick={() => onSend({ message: activeRequest.message ?? '' })}
icon="sendHorizontal"
/>
</>
)}
</HStack> </HStack>
</div> </div>
<Tabs <Tabs

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useKey, useKeyPressEvent } from 'react-use'; import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId'; import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId'; import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes'; import { useAppRoutes } from '../hooks/useAppRoutes';
@@ -14,12 +14,13 @@ import type { ButtonProps } from './core/Button';
import { Button } from './core/Button'; import { Button } from './core/Button';
import type { DropdownItem, DropdownRef } from './core/Dropdown'; import type { DropdownItem, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
import { HttpMethodTag } from './core/HttpMethodTag';
export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'className'>) { export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'className'>) {
const dropdownRef = useRef<DropdownRef>(null); const dropdownRef = useRef<DropdownRef>(null);
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId(); const activeWorkspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId(); const activeEnvironment = useActiveEnvironment();
const httpRequests = useHttpRequests(); const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests(); const grpcRequests = useGrpcRequests();
const routes = useAppRoutes(); const routes = useAppRoutes();
@@ -63,10 +64,11 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
key: request.id, key: request.id,
label: fallbackRequestName(request), label: fallbackRequestName(request),
// leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length} />, // leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length} />,
leftSlot: <HttpMethodTag request={request} />,
onSelect: () => { onSelect: () => {
routes.navigate('request', { routes.navigate('request', {
requestId: request.id, requestId: request.id,
environmentId: activeEnvironmentId ?? undefined, environmentId: activeEnvironment?.id,
workspaceId: activeWorkspaceId, workspaceId: activeWorkspaceId,
}); });
}, },
@@ -85,7 +87,7 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
} }
return recentRequestItems.slice(0, 20); return recentRequestItems.slice(0, 20);
}, [activeWorkspaceId, activeEnvironmentId, recentRequestIds, requests, routes]); }, [activeWorkspaceId, activeEnvironment?.id, recentRequestIds, requests, routes]);
return ( return (
<Dropdown ref={dropdownRef} items={items}> <Dropdown ref={dropdownRef} items={items}>

View File

@@ -6,24 +6,27 @@ import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading'; import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { useSendRequest } from '../hooks/useSendRequest'; import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest'; import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { tryFormatJson } from '../lib/formatters'; import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest, HttpUrlParameter } from '../lib/models'; import type { HttpHeader, HttpRequest, HttpUrlParameter } from '../lib/models';
import { import {
BODY_TYPE_OTHER,
AUTH_TYPE_BASIC, AUTH_TYPE_BASIC,
AUTH_TYPE_BEARER, AUTH_TYPE_BEARER,
AUTH_TYPE_NONE, AUTH_TYPE_NONE,
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_MULTIPART,
BODY_TYPE_FORM_URLENCODED, BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL, BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON, BODY_TYPE_JSON,
BODY_TYPE_NONE, BODY_TYPE_NONE,
BODY_TYPE_OTHER,
BODY_TYPE_XML, BODY_TYPE_XML,
} from '../lib/models'; } from '../lib/models';
import { BasicAuth } from './BasicAuth'; import { BasicAuth } from './BasicAuth';
import { BearerAuth } from './BearerAuth'; import { BearerAuth } from './BearerAuth';
import { BinaryFileEditor } from './BinaryFileEditor';
import { CountBadge } from './core/CountBadge'; import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor'; import { Editor } from './core/Editor';
import type { TabItem } from './core/Tabs/Tabs'; import type { TabItem } from './core/Tabs/Tabs';
@@ -56,6 +59,7 @@ export const RequestPane = memo(function RequestPane({
const [activeTab, setActiveTab] = useActiveTab(); const [activeTab, setActiveTab] = useActiveTab();
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0); const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null); const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const contentType = useContentTypeFromHeaders(activeRequest.headers);
const tabs: TabItem[] = useMemo( const tabs: TabItem[] = useMemo(
() => [ () => [
@@ -68,19 +72,19 @@ export const RequestPane = memo(function RequestPane({
{ label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED }, { label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED },
{ label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART }, { label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART },
{ type: 'separator', label: 'Text Content' }, { type: 'separator', label: 'Text Content' },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ label: 'JSON', value: BODY_TYPE_JSON }, { label: 'JSON', value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML }, { label: 'XML', value: BODY_TYPE_XML },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ label: 'Other', value: BODY_TYPE_OTHER }, { label: 'Other', value: BODY_TYPE_OTHER },
{ type: 'separator', label: 'Other' }, { type: 'separator', label: 'Other' },
{ label: 'Binary File', value: BODY_TYPE_BINARY },
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE }, { label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
], ],
onChange: async (bodyType) => { onChange: async (bodyType) => {
const patch: Partial<HttpRequest> = { bodyType }; const patch: Partial<HttpRequest> = { bodyType };
let newContentType: string | null | undefined;
if (bodyType === BODY_TYPE_NONE) { if (bodyType === BODY_TYPE_NONE) {
patch.headers = activeRequest.headers.filter( newContentType = null;
(h) => h.name.toLowerCase() !== 'content-type',
);
} else if ( } else if (
bodyType === BODY_TYPE_FORM_URLENCODED || bodyType === BODY_TYPE_FORM_URLENCODED ||
bodyType === BODY_TYPE_FORM_MULTIPART || bodyType === BODY_TYPE_FORM_MULTIPART ||
@@ -89,32 +93,17 @@ export const RequestPane = memo(function RequestPane({
bodyType === BODY_TYPE_XML bodyType === BODY_TYPE_XML
) { ) {
patch.method = 'POST'; patch.method = 'POST';
patch.headers = [ newContentType = bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType;
...(activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ??
[]),
{
name: 'Content-Type',
value: bodyType,
enabled: true,
},
];
} else if (bodyType == BODY_TYPE_GRAPHQL) { } else if (bodyType == BODY_TYPE_GRAPHQL) {
patch.method = 'POST'; patch.method = 'POST';
patch.headers = [ newContentType = 'application/json';
...(activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ??
[]),
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
},
];
} }
// Force update header editor so any changed headers are reflected await updateRequest.mutateAsync(patch);
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
updateRequest.mutate(patch); if (newContentType !== undefined) {
await handleContentTypeChange(newContentType);
}
}, },
}, },
}, },
@@ -171,6 +160,31 @@ export const RequestPane = memo(function RequestPane({
(body: HttpRequest['body']) => updateRequest.mutate({ body }), (body: HttpRequest['body']) => updateRequest.mutate({ body }),
[updateRequest], [updateRequest],
); );
const handleContentTypeChange = useCallback(
async (contentType: string | null) => {
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type');
if (contentType != null) {
headers.push({
name: 'Content-Type',
value: contentType,
enabled: true,
});
}
await updateRequest.mutateAsync({ headers });
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
},
[activeRequest.headers, updateRequest],
);
const handleBinaryFileChange = useCallback(
(body: HttpRequest['body']) => {
updateRequest.mutate({ body });
},
[updateRequest],
);
const handleBodyTextChange = useCallback( const handleBodyTextChange = useCallback(
(text: string) => updateRequest.mutate({ body: { text } }), (text: string) => updateRequest.mutate({ body: { text } }),
[updateRequest], [updateRequest],
@@ -314,6 +328,14 @@ export const RequestPane = memo(function RequestPane({
body={activeRequest.body} body={activeRequest.body}
onChange={handleBodyChange} onChange={handleBodyChange}
/> />
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={handleBinaryFileChange}
onChangeContentType={handleContentTypeChange}
/>
) : ( ) : (
<EmptyStateText>No Body</EmptyStateText> <EmptyStateText>No Body</EmptyStateText>
)} )}

View File

@@ -3,7 +3,7 @@ import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { createGlobalState } from 'react-use'; import { createGlobalState } from 'react-use';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseContentType } from '../hooks/useResponseContentType'; import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { useResponseViewMode } from '../hooks/useResponseViewMode'; import { useResponseViewMode } from '../hooks/useResponseViewMode';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import { isResponseLoading } from '../lib/models'; import { isResponseLoading } from '../lib/models';
@@ -37,7 +37,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
const { activeResponse, setPinnedResponse, responses } = usePinnedHttpResponse(activeRequest); const { activeResponse, setPinnedResponse, responses } = usePinnedHttpResponse(activeRequest);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab(); const [activeTab, setActiveTab] = useActiveTab();
const contentType = useResponseContentType(activeResponse); const contentType = useContentTypeFromHeaders(activeResponse?.headers ?? null);
const tabs = useMemo<TabItem[]>( const tabs = useMemo<TabItem[]>(
() => [ () => [

View File

@@ -9,10 +9,7 @@ import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes'; import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateFolder } from '../hooks/useCreateFolder'; import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
import { useDeleteAnyGrpcRequest } from '../hooks/useDeleteAnyGrpcRequest';
import { useDeleteAnyHttpRequest } from '../hooks/useDeleteAnyHttpRequest';
import { useDeleteFolder } from '../hooks/useDeleteFolder'; import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest'; import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest'; import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
@@ -34,7 +31,6 @@ import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest'; import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest'; import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { fallbackRequestName } from '../lib/fallbackRequestName'; import { fallbackRequestName } from '../lib/fallbackRequestName';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '../lib/models'; import type { Folder, GrpcRequest, HttpRequest, Workspace } from '../lib/models';
import { isResponseLoading } from '../lib/models'; import { isResponseLoading } from '../lib/models';
import type { DropdownItem } from './core/Dropdown'; import type { DropdownItem } from './core/Dropdown';
@@ -61,15 +57,13 @@ interface TreeNode {
} }
export function Sidebar({ className }: Props) { export function Sidebar({ className }: Props) {
const { hidden } = useSidebarHidden(); const { hidden, show, hide } = useSidebarHidden();
const sidebarRef = useRef<HTMLLIElement>(null); const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
const activeEnvironmentId = useActiveEnvironmentId(); const activeEnvironmentId = useActiveEnvironmentId();
const httpRequests = useHttpRequests(); const httpRequests = useHttpRequests();
const grpcRequests = useGrpcRequests(); const grpcRequests = useGrpcRequests();
const folders = useFolders(); const folders = useFolders();
const deleteAnyHttpRequest = useDeleteAnyHttpRequest();
const deleteAnyGrpcRequest = useDeleteAnyGrpcRequest();
const activeWorkspace = useActiveWorkspace(); const activeWorkspace = useActiveWorkspace();
const duplicateHttpRequest = useDuplicateHttpRequest({ const duplicateHttpRequest = useDuplicateHttpRequest({
id: activeRequest?.id ?? null, id: activeRequest?.id ?? null,
@@ -91,8 +85,8 @@ export function Sidebar({ className }: Props) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const collapsed = useKeyValue<Record<string, boolean>>({ const collapsed = useKeyValue<Record<string, boolean>>({
key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'], key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'],
defaultValue: {}, fallback: {},
namespace: NAMESPACE_NO_SYNC, namespace: 'no_sync',
}); });
useHotKey('http_request.duplicate', async () => { useHotKey('http_request.duplicate', async () => {
@@ -108,9 +102,10 @@ export function Sidebar({ className }: Props) {
[collapsed.value], [collapsed.value],
); );
const { tree, treeParentMap, selectableRequests } = useMemo<{ const { tree, treeParentMap, selectableRequests, selectedRequest } = useMemo<{
tree: TreeNode | null; tree: TreeNode | null;
treeParentMap: Record<string, TreeNode>; treeParentMap: Record<string, TreeNode>;
selectedRequest: HttpRequest | GrpcRequest | null;
selectableRequests: { selectableRequests: {
id: string; id: string;
index: number; index: number;
@@ -123,14 +118,23 @@ export function Sidebar({ className }: Props) {
index: number; index: number;
tree: TreeNode; tree: TreeNode;
}[] = []; }[] = [];
if (activeWorkspace == null) { if (activeWorkspace == null) {
return { tree: null, treeParentMap, selectableRequests }; return { tree: null, treeParentMap, selectableRequests, selectedRequest: null };
} }
let selectedRequest: HttpRequest | GrpcRequest | null = null;
let selectableRequestIndex = 0; let selectableRequestIndex = 0;
// Put requests and folders into a tree structure // Put requests and folders into a tree structure
const next = (node: TreeNode): TreeNode => { const next = (node: TreeNode): TreeNode => {
if (
node.item.id === selectedId &&
(node.item.model === 'http_request' || node.item.model === 'grpc_request')
) {
selectedRequest = node.item;
}
const childItems = [...httpRequests, ...grpcRequests, ...folders].filter((f) => const childItems = [...httpRequests, ...grpcRequests, ...folders].filter((f) =>
node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id, node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id,
); );
@@ -149,8 +153,10 @@ export function Sidebar({ className }: Props) {
const tree = next({ item: activeWorkspace, children: [], depth: 0 }); const tree = next({ item: activeWorkspace, children: [], depth: 0 });
return { tree, treeParentMap, selectableRequests }; return { tree, treeParentMap, selectableRequests, selectedRequest };
}, [activeWorkspace, httpRequests, grpcRequests, folders]); }, [activeWorkspace, selectedId, httpRequests, grpcRequests, folders]);
const deleteSelectedRequest = useDeleteRequest(selectedRequest);
const focusActiveRequest = useCallback( const focusActiveRequest = useCallback(
( (
@@ -167,13 +173,14 @@ export function Sidebar({ className }: Props) {
const children = tree?.children ?? []; const children = tree?.children ?? [];
const id = const id =
forced?.id ?? children.find((m) => m.item.id === activeRequest?.id)?.item.id ?? null; forced?.id ?? children.find((m) => m.item.id === activeRequest?.id)?.item.id ?? null;
setHasFocus(true);
setSelectedId(id);
setSelectedTree(tree);
if (id == null) { if (id == null) {
return; return;
} }
setSelectedId(id);
setSelectedTree(tree);
setHasFocus(true);
if (!noFocusSidebar) { if (!noFocusSidebar) {
sidebarRef.current?.focus(); sidebarRef.current?.focus();
} }
@@ -221,23 +228,33 @@ export function Sidebar({ className }: Props) {
const handleBlur = useCallback(() => setHasFocus(false), []); const handleBlur = useCallback(() => setHasFocus(false), []);
const handleDeleteKey = useCallback( const handleDeleteKey = useCallback(
(e: KeyboardEvent) => { async (e: KeyboardEvent) => {
if (!hasFocus) return; if (!hasFocus) return;
e.preventDefault(); e.preventDefault();
const selected = selectableRequests.find((r) => r.id === selectedId); const selected = selectableRequests.find((r) => r.id === selectedId);
if (selected == null) return; if (selected == null) return;
deleteAnyHttpRequest.mutate(selected.id); await deleteSelectedRequest.mutateAsync();
deleteAnyGrpcRequest.mutate(selected.id);
}, },
[deleteAnyHttpRequest, deleteAnyGrpcRequest, hasFocus, selectableRequests, selectedId], [hasFocus, selectableRequests, deleteSelectedRequest, selectedId],
); );
useKeyPressEvent('Backspace', handleDeleteKey); useKeyPressEvent('Backspace', handleDeleteKey);
useKeyPressEvent('Delete', handleDeleteKey); useKeyPressEvent('Delete', handleDeleteKey);
useHotKey('sidebar.focus', () => { useHotKey('sidebar.focus', async () => {
if (hidden || hasFocus) return; console.log('sidebar.focus', { hidden, hasFocus });
// Hide the sidebar if it's already focused
if (!hidden && hasFocus) {
await hide();
return;
}
// Show the sidebar if it's hidden
if (hidden) {
await show();
}
// Select 0 index on focus if none selected // Select 0 index on focus if none selected
focusActiveRequest( focusActiveRequest(
selectedTree != null && selectedId != null selectedTree != null && selectedId != null
@@ -496,13 +513,9 @@ function SidebarItems({
} }
itemModel={child.item.model} itemModel={child.item.model}
itemPrefix={ itemPrefix={
child.item.model === 'http_request' && child.item.bodyType === 'graphql' ? ( (child.item.model === 'http_request' || child.item.model === 'grpc_request') && (
<HttpMethodTag className="opacity-50">GQL</HttpMethodTag> <HttpMethodTag request={child.item} />
) : child.item.model === 'http_request' ? ( )
<HttpMethodTag className="opacity-50">{child.item.method}</HttpMethodTag>
) : child.item.model === 'grpc_request' ? (
<HttpMethodTag className="opacity-50">GRPC</HttpMethodTag>
) : null
} }
onMove={handleMove} onMove={handleMove}
onEnd={handleEnd} onEnd={handleEnd}
@@ -574,10 +587,8 @@ const SidebarItem = forwardRef(function SidebarItem(
ref: ForwardedRef<HTMLLIElement>, ref: ForwardedRef<HTMLLIElement>,
) { ) {
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
const createRequest = useCreateHttpRequest();
const createFolder = useCreateFolder();
const deleteFolder = useDeleteFolder(itemId); const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(itemId); const deleteRequest = useDeleteRequest(activeRequest ?? null);
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true }); const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true }); const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
const sendRequest = useSendRequest(itemId); const sendRequest = useSendRequest(itemId);
@@ -590,6 +601,7 @@ const SidebarItem = forwardRef(function SidebarItem(
const prompt = usePrompt(); const prompt = usePrompt();
const [editing, setEditing] = useState<boolean>(false); const [editing, setEditing] = useState<boolean>(false);
const isActive = activeRequest?.id === itemId; const isActive = activeRequest?.id === itemId;
const createDropdownItems = useCreateDropdownItems({ folderId: itemId });
const handleSubmitNameEdit = useCallback( const handleSubmitNameEdit = useCallback(
(el: HTMLInputElement) => { (el: HTMLInputElement) => {
@@ -693,18 +705,7 @@ const SidebarItem = forwardRef(function SidebarItem(
onSelect: () => deleteFolder.mutate(), onSelect: () => deleteFolder.mutate(),
}, },
{ type: 'separator' }, { type: 'separator' },
{ ...createDropdownItems,
key: 'createRequest',
label: 'New Request',
leftSlot: <Icon icon="plus" />,
onSelect: () => createRequest.mutate({ folderId: itemId }),
},
{
key: 'createFolder',
label: 'New Folder',
leftSlot: <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId: itemId, sortPriority: -1 }),
},
] ]
: [ : [
...((itemModel === 'http_request' ...((itemModel === 'http_request'

View File

@@ -25,7 +25,7 @@ export const SidebarActions = memo(function SidebarActions() {
hotkeyAction="sidebar.toggle" hotkeyAction="sidebar.toggle"
icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'} icon={hidden ? 'leftPanelHidden' : 'leftPanelVisible'}
/> />
<CreateDropdown> <CreateDropdown openOnHotKeyAction="http_request.create">
<IconButton size="sm" icon="plusCircle" title="Add Resource" /> <IconButton size="sm" icon="plusCircle" title="Add Resource" />
</CreateDropdown> </CreateDropdown>
</HStack> </HStack>

View File

@@ -9,13 +9,19 @@ import type {
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use'; import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useImportData } from '../hooks/useImportData'; import { useImportData } from '../hooks/useImportData';
import { useIsFullscreen } from '../hooks/useIsFullscreen'; import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { useOsInfo } from '../hooks/useOsInfo'; import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth'; import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { HotKeyList } from './core/HotKeyList'; import { HotKeyList } from './core/HotKeyList';
import { InlineCode } from './core/InlineCode';
import { FeedbackLink } from './core/Link';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { CreateDropdown } from './CreateDropdown'; import { CreateDropdown } from './CreateDropdown';
import { GrpcConnectionLayout } from './GrpcConnectionLayout'; import { GrpcConnectionLayout } from './GrpcConnectionLayout';
@@ -34,6 +40,9 @@ const drag = { gridArea: 'drag' };
const WINDOW_FLOATING_SIDEBAR_WIDTH = 600; const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
export default function Workspace() { export default function Workspace() {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = useActiveWorkspaceId();
const { setWidth, width, resetWidth } = useSidebarWidth(); const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, show, hidden } = useSidebarHidden(); const { hide, show, hidden } = useSidebarHidden();
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
@@ -119,6 +128,11 @@ export default function Workspace() {
); );
} }
// We're loading still
if (workspaces.length === 0) {
return null;
}
return ( return (
<div <div
style={styles} style={styles}
@@ -163,7 +177,15 @@ export default function Workspace() {
<HeaderSize data-tauri-drag-region style={head}> <HeaderSize data-tauri-drag-region style={head}>
<WorkspaceHeader className="pointer-events-none" /> <WorkspaceHeader className="pointer-events-none" />
</HeaderSize> </HeaderSize>
{activeRequest == null ? ( {activeWorkspace == null ? (
<div className="m-auto">
<Banner color="warning" className="max-w-[30rem]">
The active workspace{' '}
<InlineCode className="text-orange-800">{activeWorkspaceId}</InlineCode> was not found.
Select a workspace from the header menu or report this bug to <FeedbackLink />
</Banner>
</div>
) : activeRequest == null ? (
<HotKeyList <HotKeyList
hotkeys={['http_request.create', 'sidebar.toggle', 'settings.show']} hotkeys={['http_request.create', 'sidebar.toggle', 'settings.show']}
bottomSlot={ bottomSlot={

View File

@@ -148,18 +148,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
key: 'create-workspace', key: 'create-workspace',
label: 'New Workspace', label: 'New Workspace',
leftSlot: <Icon icon="plus" />, leftSlot: <Icon icon="plus" />,
onSelect: async () => { onSelect: () => createWorkspace.mutate({}),
const name = await prompt({
id: 'new-workspace',
name: 'name',
label: 'Name',
defaultValue: 'My Workspace',
title: 'New Workspace',
confirmLabel: 'Create',
placeholder: 'My Workspace',
});
createWorkspace.mutate({ name });
},
}, },
]; ];
}, [ }, [
@@ -178,10 +167,14 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
<Dropdown items={items}> <Dropdown items={items}>
<Button <Button
size="sm" size="sm"
className={classNames(className, 'text-gray-800 !px-2 truncate')} className={classNames(
className,
'text-gray-800 !px-2 truncate',
activeWorkspace === null && 'italic opacity-disabled',
)}
{...buttonProps} {...buttonProps}
> >
{activeWorkspace?.name} {activeWorkspace?.name ?? 'Workspace'}
</Button> </Button>
</Dropdown> </Dropdown>
); );

View File

@@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
color?: 'danger' | 'success' | 'gray'; color?: 'danger' | 'warning' | 'success' | 'gray';
} }
export function Banner({ children, className, color = 'gray' }: Props) { export function Banner({ children, className, color = 'gray' }: Props) {
return ( return (
@@ -14,6 +14,7 @@ export function Banner({ children, className, color = 'gray' }: Props) {
className, className,
'border border-dashed italic px-3 py-2 rounded select-auto cursor-text', 'border border-dashed italic px-3 py-2 rounded select-auto cursor-text',
color === 'gray' && 'border-gray-500/60 bg-gray-300/10 text-gray-800', color === 'gray' && 'border-gray-500/60 bg-gray-300/10 text-gray-800',
color === 'warning' && 'border-orange-500/60 bg-orange-300/10 text-orange-800',
color === 'danger' && 'border-red-500/60 bg-red-300/10 text-red-800', color === 'danger' && 'border-red-500/60 bg-red-300/10 text-red-800',
color === 'success' && 'border-green-500/60 bg-green-300/10 text-green-800', color === 'success' && 'border-green-500/60 bg-green-300/10 text-green-800',
)} )}

View File

@@ -2,7 +2,7 @@ import classNames from 'classnames';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useHotKey } from '../../hooks/useHotKey'; import { useKey } from 'react-use';
import { Overlay } from '../Overlay'; import { Overlay } from '../Overlay';
import { Heading } from './Heading'; import { Heading } from './Heading';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
@@ -36,7 +36,15 @@ export function Dialog({
[description], [description],
); );
useHotKey('popup.close', onClose); useKey(
'Escape',
() => {
if (!open) return;
onClose();
},
{},
[open],
);
return ( return (
<Overlay open={open} onClose={onClose} portalName="dialog"> <Overlay open={open} onClose={onClose} portalName="dialog">

View File

@@ -244,13 +244,16 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
} }
}; };
useHotKey('popup.close', () => { useKey(
if (filter !== '') { 'Escape',
setFilter(''); () => {
} else { if (!isOpen) return;
handleClose(); if (filter !== '') setFilter('');
} else handleClose();
}); },
{},
[isOpen, filter, setFilter, handleClose],
);
const handlePrev = useCallback(() => { const handlePrev = useCallback(() => {
setSelectedIndex((currIndex) => { setSelectedIndex((currIndex) => {
@@ -286,15 +289,27 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
}); });
}, [items]); }, [items]);
useKey('ArrowUp', (e) => { useKey(
e.preventDefault(); 'ArrowUp',
handlePrev(); (e) => {
}); if (!isOpen) return;
e.preventDefault();
handlePrev();
},
{},
[isOpen],
);
useKey('ArrowDown', (e) => { useKey(
e.preventDefault(); 'ArrowDown',
handleNext(); (e) => {
}); if (!isOpen) return;
e.preventDefault();
handleNext();
},
{},
[isOpen],
);
const handleSelect = useCallback( const handleSelect = useCallback(
(i: DropdownItem) => { (i: DropdownItem) => {
@@ -409,7 +424,6 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
)} )}
{containerStyles && ( {containerStyles && (
<VStack <VStack
space={0.5}
ref={initMenu} ref={initMenu}
style={menuStyles} style={menuStyles}
className={classNames( className={classNames(

View File

@@ -0,0 +1,14 @@
import { type DecorationSet, MatchDecorator, type ViewUpdate } from '@codemirror/view';
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
*/
export class BetterMatchDecorator extends MatchDecorator {
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
if (!update.startState.selection.eq(update.state.selection)) {
return super.createDeco(update.view);
} else {
return super.updateDeco(update, deco);
}
}
}

View File

@@ -4,11 +4,6 @@
.cm-editor { .cm-editor {
@apply w-full block text-base; @apply w-full block text-base;
* {
@apply cursor-text;
@apply caret-transparent !important;
}
.cm-cursor { .cm-cursor {
@apply border-gray-800 !important; @apply border-gray-800 !important;
} }
@@ -32,17 +27,25 @@
.cm-scroller { .cm-scroller {
/* Inherit line-height from outside */ /* Inherit line-height from outside */
line-height: inherit; line-height: inherit;
* {
@apply cursor-text;
@apply caret-transparent !important;
}
} }
/* Don't show selection on blurred input */ /* Don't show selection on blurred input */
.cm-selectionBackground { .cm-selectionBackground {
@apply bg-transparent; @apply bg-transparent;
} }
&.cm-focused .cm-selectionBackground { &.cm-focused .cm-selectionBackground {
@apply bg-selection; @apply bg-selection;
} }
/* Style gutters */ /* Style gutters */
.cm-gutters { .cm-gutters {
@apply border-0 text-gray-500/50; @apply border-0 text-gray-500/50;
@@ -66,6 +69,14 @@
@apply text-red-700 dark:text-red-800 bg-red-300/30 border-red-300/80 border-opacity-40 hover:border-red-300 hover:bg-red-300/40; @apply text-red-700 dark:text-red-800 bg-red-300/30 border-red-300/80 border-opacity-40 hover:border-red-300 hover:bg-red-300/40;
} }
} }
.hyperlink-widget {
& > * {
@apply underline;
}
-webkit-text-security: none;
}
} }
&.cm-singleline { &.cm-singleline {
@@ -100,10 +111,10 @@
@apply font-mono text-[0.75rem]; @apply font-mono text-[0.75rem];
/* /*
* Round corners or they'll stick out of the editor bounds of editor is rounded. * Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this * Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine. * is probably fine.
*/ */
@apply rounded-lg; @apply rounded-lg;
} }
} }
@@ -164,17 +175,95 @@
@apply h-full flex items-center; @apply h-full flex items-center;
/* Break characters on line wrapping mode, useful for URL field. /* 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 { &.cm-lineWrapping {
@apply break-all; @apply break-all;
} }
} }
} }
.cm-tooltip.cm-tooltip-hover {
@apply shadow-lg bg-gray-200 rounded text-gray-700 border border-gray-500 z-50 pointer-events-auto text-xs;
@apply px-2 py-1;
a {
@apply text-yellow-500 font-bold;
&:hover {
@apply underline;
}
&::after {
@apply text-yellow-600 bg-yellow-600 h-3 w-3 ml-1;
content: '';
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
-webkit-mask-size: contain;
display: inline-block;
}
}
}
/* NOTE: Extra selector required to override default styles */ /* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip { .cm-tooltip.cm-tooltip-autocomplete {
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-[0.75rem]; @apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-300 z-50 pointer-events-auto text-xs;
.cm-completionIcon {
@apply italic font-mono;
&::after {
content: 'x' !important; /* Default (eg. for GraphQL) */
}
&.cm-completionIcon-class::after {
content: 'o' !important;
}
&.cm-completionIcon-constant::after {
content: 'c' !important;
}
&.cm-completionIcon-enum::after {
content: 'e' !important;
}
&.cm-completionIcon-function::after {
content: 'z' !important;
}
&.cm-completionIcon-interface::after {
content: 'i' !important;
}
&.cm-completionIcon-keyword::after {
content: 'k' !important;
}
&.cm-completionIcon-method::after {
content: 'm' !important;
}
&.cm-completionIcon-namespace::after {
content: 'n' !important;
}
&.cm-completionIcon-property::after {
content: 'a' !important;
}
&.cm-completionIcon-text::after {
content: 'x' !important;
}
&.cm-completionIcon-type::after {
content: 't' !important;
}
&.cm-completionIcon-variable::after {
content: 'x' !important;
}
}
&.cm-completionInfo-right { &.cm-completionInfo-right {
@apply ml-1 -mt-0.5 text-sm; @apply ml-1 -mt-0.5 text-sm;
@@ -202,7 +291,7 @@
} }
.cm-completionIcon { .cm-completionIcon {
@apply text-sm flex items-center pb-0.5 mr-2 flex-shrink-0; @apply text-xs flex items-center pb-0.5 flex-shrink-0;
} }
.cm-completionLabel { .cm-completionLabel {
@@ -216,7 +305,7 @@
} }
.cm-editor .cm-panels { .cm-editor .cm-panels {
@apply bg-transparent border-0 text-gray-800 z-50; @apply bg-gray-100 backdrop-blur-sm p-1 mb-1 text-gray-800 z-20 rounded-md;
input, input,
button { button {
@@ -224,20 +313,24 @@
} }
button { button {
@apply appearance-none bg-none bg-gray-800 text-gray-100 focus:bg-gray-900 cursor-default; @apply appearance-none bg-none bg-gray-200 hocus:bg-gray-300 hocus:text-gray-950 border-0 text-gray-800 cursor-default;
}
button[name='close'] {
@apply text-gray-600 hocus:text-gray-900 px-2 -mr-1.5 !important;
} }
input { input {
@apply bg-gray-50 border border-highlight focus:border-focus outline-none; @apply bg-gray-50 border border-gray-500/50 focus:border-focus outline-none;
}
label {
@apply focus-within:text-gray-950;
} }
/* Hide the "All" button */ /* Hide the "All" button */
button[name='select'] { button[name='select'] {
@apply hidden; @apply hidden;
} }
} }
/* Add default icon. Needs low priority so it can be overwritten */
.cm-completionIcon::after {
content: '𝑥';
}

View File

@@ -179,7 +179,9 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
doc: `${defaultValue ?? ''}`, doc: `${defaultValue ?? ''}`,
extensions: [ extensions: [
languageCompartment.of(langExt), languageCompartment.of(langExt),
placeholderCompartment.current.of([]), placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '')),
),
wrapLinesCompartment.current.of([]), wrapLinesCompartment.current.of([]),
...getExtensions({ ...getExtensions({
container, container,

View File

@@ -18,7 +18,7 @@ import {
} from '@codemirror/language'; } from '@codemirror/language';
import { lintKeymap } from '@codemirror/lint'; import { lintKeymap } from '@codemirror/lint';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search'; import { searchKeymap } from '@codemirror/search';
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { import {
crosshairCursor, crosshairCursor,
@@ -122,11 +122,8 @@ export const baseExtensions = [
history(), history(),
dropCursor(), dropCursor(),
drawSelection(), drawSelection(),
// TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({ autocompletion({
// closeOnBlur: false, closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
compareCompletions: (a, b) => { compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost // Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0); return (a.boost ?? 0) - (b.boost ?? 0);
@@ -156,7 +153,6 @@ export const multiLineExtensions = [
rectangularSelection(), rectangularSelection(),
crosshairCursor(), crosshairCursor(),
highlightActiveLineGutter(), highlightActiveLineGutter(),
highlightSelectionMatches({ minSelectionLength: 2 }),
keymap.of([ keymap.of([
indentWithTab, indentWithTab,
...closeBracketsKeymap, ...closeBracketsKeymap,

View File

@@ -0,0 +1,98 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, hoverTooltip, MatchDecorator, ViewPlugin } from '@codemirror/view';
import { EditorView } from 'codemirror';
const REGEX =
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))/g;
const tooltip = hoverTooltip(
(view, pos, side) => {
const { from, text } = view.state.doc.lineAt(pos);
let match;
let found: { start: number; end: number } | null = null;
while ((match = REGEX.exec(text))) {
const start = from + match.index;
const end = start + match[0].length;
if (pos >= start && pos <= end) {
found = { start, end };
break;
}
}
if (found == null) {
return null;
}
if ((found.start == pos && side < 0) || (found.end == pos && side > 0)) {
return null;
}
return {
pos: found.start,
end: found.end,
create() {
const dom = document.createElement('a');
dom.textContent = 'Open in browser';
dom.href = text.substring(found!.start - from, found!.end - from);
dom.target = '_blank';
dom.rel = 'noopener noreferrer';
return { dom };
},
};
},
{
hoverTime: 100,
},
);
const decorator = function () {
const placeholderMatcher = new MatchDecorator({
regexp: REGEX,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) {
return Decoration.replace({});
}
}
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return Decoration.replace({});
}
return Decoration.mark({
class: 'hyperlink-widget',
});
},
});
return ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
}
},
{
decorations: (instance) => instance.placeholders,
provide: (plugin) =>
EditorView.bidiIsolatedRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
}),
},
);
};
export const hyperlink = [tooltip, decorator()];

View File

@@ -1,11 +1,9 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view'; import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view'; import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import { BetterMatchDecorator } from '../BetterMatchDecorator';
class PlaceholderWidget extends WidgetType { class PlaceholderWidget extends WidgetType {
constructor( constructor(readonly name: string, readonly isExistingVariable: boolean) {
readonly name: string,
readonly isExistingVariable: boolean,
) {
super(); super();
} }
eq(other: PlaceholderWidget) { eq(other: PlaceholderWidget) {
@@ -25,19 +23,6 @@ class PlaceholderWidget extends WidgetType {
} }
} }
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
*/
class BetterMatchDecorator extends MatchDecorator {
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
if (!update.startState.selection.eq(update.state.selection)) {
return super.createDeco(update.view);
} else {
return super.updateDeco(update, deco);
}
}
}
export const placeholders = function (variables: { name: string }[]) { export const placeholders = function (variables: { name: string }[]) {
const placeholderMatcher = new BetterMatchDecorator({ const placeholderMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g, regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,

View File

@@ -1,7 +1,8 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { GrpcRequest, HttpRequest } from '../../lib/models';
interface Props { interface Props {
children: string; request: HttpRequest | GrpcRequest;
className?: string; className?: string;
} }
@@ -16,10 +17,17 @@ const methodMap: Record<string, string> = {
grpc: 'GRPC', grpc: 'GRPC',
}; };
export function HttpMethodTag({ children: method, className }: Props) { export function HttpMethodTag({ request, className }: Props) {
const method =
request.model === 'http_request' && request.bodyType === 'graphql'
? 'GQL'
: request.model === 'grpc_request'
? 'GRPC'
: request.method;
const m = method.toLowerCase(); const m = method.toLowerCase();
return ( return (
<span className={classNames(className, 'text-2xs font-mono')}> <span className={classNames(className, 'text-2xs font-mono opacity-50')}>
{methodMap[m] ?? m.slice(0, 3).toUpperCase()} {methodMap[m] ?? m.slice(0, 3).toUpperCase()}
</span> </span>
); );

View File

@@ -33,3 +33,7 @@ export function Link({ href, children, className, ...other }: Props) {
</RouterLink> </RouterLink>
); );
} }
export function FeedbackLink() {
return <Link href="https://yaak.canny.io">Feedback</Link>;
}

View File

@@ -1,17 +1,20 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useDebouncedState } from '../../hooks/useDebouncedState'; import { useDebouncedState } from '../../hooks/useDebouncedState';
import { useFilterResponse } from '../../hooks/useFilterResponse'; import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useResponseBodyText } from '../../hooks/useResponseBodyText'; import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useResponseContentType } from '../../hooks/useResponseContentType';
import { useToggle } from '../../hooks/useToggle'; import { useToggle } from '../../hooks/useToggle';
import { tryFormatJson, tryFormatXml } from '../../lib/formatters'; import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
import type { HttpResponse } from '../../lib/models'; import type { HttpResponse } from '../../lib/models';
import { Editor } from '../core/Editor'; import { Editor } from '../core/Editor';
import { hyperlink } from '../core/Editor/hyperlink/extension';
import { IconButton } from '../core/IconButton'; import { IconButton } from '../core/IconButton';
import { Input } from '../core/Input'; import { Input } from '../core/Input';
const extraExtensions = [hyperlink];
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
pretty: boolean; pretty: boolean;
@@ -21,7 +24,7 @@ export function TextViewer({ response, pretty }: Props) {
const [isSearching, toggleIsSearching] = useToggle(); const [isSearching, toggleIsSearching] = useToggle();
const [filterText, setDebouncedFilterText, setFilterText] = useDebouncedState<string>('', 400); const [filterText, setDebouncedFilterText, setFilterText] = useDebouncedState<string>('', 400);
const contentType = useResponseContentType(response); const contentType = useContentTypeFromHeaders(response.headers);
const rawBody = useResponseBodyText(response) ?? ''; const rawBody = useResponseBodyText(response) ?? '';
const formattedBody = const formattedBody =
pretty && contentType?.includes('json') pretty && contentType?.includes('json')
@@ -87,6 +90,7 @@ export function TextViewer({ response, pretty }: Props) {
defaultValue={body} defaultValue={body}
contentType={contentType} contentType={contentType}
actions={actions} actions={actions}
extraExtensions={extraExtensions}
/> />
); );
} }

View File

@@ -1,5 +1,4 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { NAMESPACE_GLOBAL } from '../lib/keyValueStore';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useCookieJars } from './useCookieJars'; import { useCookieJars } from './useCookieJars';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
@@ -9,9 +8,9 @@ export function useActiveCookieJar() {
const cookieJars = useCookieJars(); const cookieJars = useCookieJars();
const kv = useKeyValue<string | null>({ const kv = useKeyValue<string | null>({
namespace: NAMESPACE_GLOBAL, namespace: 'global',
key: ['activeCookieJar', workspaceId ?? 'n/a'], key: ['activeCookieJar', workspaceId ?? 'n/a'],
defaultValue: null, fallback: null,
}); });
const activeCookieJar = cookieJars.find((cookieJar) => cookieJar.id === kv.value); const activeCookieJar = cookieJars.find((cookieJar) => cookieJar.id === kv.value);

View File

@@ -0,0 +1,9 @@
import { useMemo } from 'react';
import type { HttpHeader } from '../lib/models';
export function useContentTypeFromHeaders(headers: HttpHeader[] | null): string | null {
return useMemo(
() => headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null,
[headers],
);
}

View File

@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import type { DropdownItem } from '../components/core/Dropdown';
import { Icon } from '../components/core/Icon';
import { BODY_TYPE_GRAPHQL } from '../lib/models';
import { useCreateFolder } from './useCreateFolder';
import { useCreateGrpcRequest } from './useCreateGrpcRequest';
import { useCreateHttpRequest } from './useCreateHttpRequest';
export function useCreateDropdownItems({
hideFolder,
hideIcons,
folderId,
}: {
hideFolder?: boolean;
hideIcons?: boolean;
folderId?: string;
} = {}): DropdownItem[] {
const createHttpRequest = useCreateHttpRequest();
const createGrpcRequest = useCreateGrpcRequest();
const createFolder = useCreateFolder();
return useMemo<DropdownItem[]>(
() => [
{
key: 'create-http-request',
label: 'HTTP Request',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createHttpRequest.mutate({ folderId }),
},
{
key: 'create-graphql-request',
label: 'GraphQL Query',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () =>
createHttpRequest.mutate({
folderId,
bodyType: BODY_TYPE_GRAPHQL,
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json' }],
}),
},
{
key: 'create-grpc-request',
label: 'gRPC Call',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createGrpcRequest.mutate({ folderId }),
},
...((hideFolder
? []
: [
{
type: 'separator',
},
{
key: 'create-folder',
label: 'Folder',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId }),
},
]) as DropdownItem[]),
],
[createFolder, createGrpcRequest, createHttpRequest, folderId, hideFolder, hideIcons],
);
}

View File

@@ -2,20 +2,35 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import type { Folder } from '../lib/models'; import type { Folder } from '../lib/models';
import { useActiveRequest } from './useActiveRequest';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { foldersQueryKey } from './useFolders'; import { foldersQueryKey } from './useFolders';
import { usePrompt } from './usePrompt';
export function useCreateFolder() { export function useCreateFolder() {
const workspaceId = useActiveWorkspaceId(); const workspaceId = useActiveWorkspaceId();
const activeRequest = useActiveRequest();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const prompt = usePrompt();
return useMutation<Folder, unknown, Partial<Pick<Folder, 'name' | 'sortPriority' | 'folderId'>>>({ return useMutation<Folder, unknown, Partial<Pick<Folder, 'name' | 'sortPriority' | 'folderId'>>>({
mutationFn: (patch) => { mutationFn: async (patch) => {
if (workspaceId === null) { if (workspaceId === null) {
throw new Error("Cannot create folder when there's no active workspace"); throw new Error("Cannot create folder when there's no active workspace");
} }
patch.name = patch.name || 'New Folder'; patch.name =
patch.name ||
(await prompt({
id: 'new-folder',
name: 'name',
label: 'Name',
defaultValue: 'Folder',
title: 'New Folder',
confirmLabel: 'Create',
placeholder: 'Name',
}));
patch.sortPriority = patch.sortPriority || -Date.now(); patch.sortPriority = patch.sortPriority || -Date.now();
patch.folderId = patch.folderId || activeRequest?.folderId;
return invoke('cmd_create_folder', { workspaceId, ...patch }); return invoke('cmd_create_folder', { workspaceId, ...patch });
}, },
onSettled: () => trackEvent('folder', 'create'), onSettled: () => trackEvent('folder', 'create'),

View File

@@ -3,13 +3,14 @@ import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import type { GrpcRequest } from '../lib/models'; import type { GrpcRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId'; import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveRequest } from './useActiveRequest';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes'; import { useAppRoutes } from './useAppRoutes';
export function useCreateGrpcRequest() { export function useCreateGrpcRequest() {
const workspaceId = useActiveWorkspaceId(); const workspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId(); const activeEnvironmentId = useActiveEnvironmentId();
const activeRequest = null; const activeRequest = useActiveRequest();
const routes = useAppRoutes(); const routes = useAppRoutes();
return useMutation< return useMutation<
@@ -24,13 +25,13 @@ export function useCreateGrpcRequest() {
if (patch.sortPriority === undefined) { if (patch.sortPriority === undefined) {
if (activeRequest != null) { if (activeRequest != null) {
// Place above currently-active request // Place above currently-active request
// patch.sortPriority = activeRequest.sortPriority + 0.0001; patch.sortPriority = activeRequest.sortPriority + 0.0001;
} else { } else {
// Place at the very top // Place at the very top
patch.sortPriority = -Date.now(); patch.sortPriority = -Date.now();
} }
} }
// patch.folderId = patch.folderId; // TODO: || activeRequest?.folderId; patch.folderId = patch.folderId || activeRequest?.folderId;
return invoke('cmd_create_grpc_request', { workspaceId, name: '', ...patch }); return invoke('cmd_create_grpc_request', { workspaceId, name: '', ...patch });
}, },
onSettled: () => trackEvent('grpc_request', 'create'), onSettled: () => trackEvent('grpc_request', 'create'),

View File

@@ -16,7 +16,9 @@ export function useCreateHttpRequest() {
return useMutation< return useMutation<
HttpRequest, HttpRequest,
unknown, unknown,
Partial<Pick<HttpRequest, 'name' | 'sortPriority' | 'folderId' | 'bodyType' | 'method'>> Partial<
Pick<HttpRequest, 'name' | 'sortPriority' | 'folderId' | 'bodyType' | 'method' | 'headers'>
>
>({ >({
mutationFn: (patch) => { mutationFn: (patch) => {
if (workspaceId === null) { if (workspaceId === null) {

View File

@@ -3,12 +3,25 @@ import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import type { Workspace } from '../lib/models'; import type { Workspace } from '../lib/models';
import { useAppRoutes } from './useAppRoutes'; import { useAppRoutes } from './useAppRoutes';
import { usePrompt } from './usePrompt';
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) { export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
const routes = useAppRoutes(); const routes = useAppRoutes();
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({ const prompt = usePrompt();
mutationFn: (patch) => { return useMutation<Workspace, unknown, Partial<Pick<Workspace, 'name'>>>({
return invoke('cmd_create_workspace', patch); mutationFn: async ({ name: patchName }) => {
const name =
patchName ??
(await prompt({
id: 'new-workspace',
name: 'name',
label: 'Name',
defaultValue: 'My Workspace',
title: 'New Workspace',
confirmLabel: 'Create',
placeholder: 'My Workspace',
}));
return invoke('cmd_create_workspace', { name });
}, },
onSettled: () => trackEvent('workspace', 'create'), onSettled: () => trackEvent('workspace', 'create'),
onSuccess: async (workspace) => { onSuccess: async (workspace) => {

View File

@@ -1,11 +1,21 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import type { HttpRequest } from '../lib/models'; import type { GrpcRequest, HttpRequest } from '../lib/models';
import { useDeleteAnyGrpcRequest } from './useDeleteAnyGrpcRequest';
import { useDeleteAnyHttpRequest } from './useDeleteAnyHttpRequest'; import { useDeleteAnyHttpRequest } from './useDeleteAnyHttpRequest';
export function useDeleteRequest(id: string | null) { export function useDeleteRequest(request: HttpRequest | GrpcRequest | null) {
const deleteAnyRequest = useDeleteAnyHttpRequest(); const deleteAnyHttpRequest = useDeleteAnyHttpRequest();
const deleteAnyGrpcRequest = useDeleteAnyGrpcRequest();
return useMutation<HttpRequest | null, string>({ return useMutation<void, string>({
mutationFn: () => deleteAnyRequest.mutateAsync(id ?? 'n/a'), mutationFn: async () => {
if (request?.model === 'http_request') {
await deleteAnyHttpRequest.mutateAsync(request.id);
} else if (request?.model === 'grpc_request') {
await deleteAnyGrpcRequest.mutateAsync(request.id);
} else {
// Request is null
}
},
}); });
} }

View File

@@ -1,10 +1,12 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import { setKeyValue } from '../lib/keyValueStore';
import type { GrpcRequest } from '../lib/models'; import type { GrpcRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId'; import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes'; import { useAppRoutes } from './useAppRoutes';
import { protoFilesArgs, useGrpcProtoFiles } from './useGrpcProtoFiles';
export function useDuplicateGrpcRequest({ export function useDuplicateGrpcRequest({
id, id,
@@ -16,6 +18,7 @@ export function useDuplicateGrpcRequest({
const activeWorkspaceId = useActiveWorkspaceId(); const activeWorkspaceId = useActiveWorkspaceId();
const activeEnvironmentId = useActiveEnvironmentId(); const activeEnvironmentId = useActiveEnvironmentId();
const routes = useAppRoutes(); const routes = useAppRoutes();
const protoFiles = useGrpcProtoFiles(id);
return useMutation<GrpcRequest, string>({ return useMutation<GrpcRequest, string>({
mutationFn: async () => { mutationFn: async () => {
if (id === null) throw new Error("Can't duplicate a null grpc request"); if (id === null) throw new Error("Can't duplicate a null grpc request");
@@ -23,6 +26,9 @@ export function useDuplicateGrpcRequest({
}, },
onSettled: () => trackEvent('grpc_request', 'duplicate'), onSettled: () => trackEvent('grpc_request', 'duplicate'),
onSuccess: async (request) => { onSuccess: async (request) => {
// Also copy proto files to new request
await setKeyValue({ ...protoFilesArgs(request.id), value: protoFiles.value ?? [] });
if (navigateAfter && activeWorkspaceId !== null) { if (navigateAfter && activeWorkspaceId !== null) {
routes.navigate('request', { routes.navigate('request', {
workspaceId: activeWorkspaceId, workspaceId: activeWorkspaceId,

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import { emit } from '@tauri-apps/api/event'; import { emit } from '@tauri-apps/api/event';
import { trackEvent } from '../lib/analytics';
import { minPromiseMillis } from '../lib/minPromiseMillis'; import { minPromiseMillis } from '../lib/minPromiseMillis';
import type { GrpcConnection, GrpcRequest } from '../lib/models'; import type { GrpcConnection, GrpcRequest } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId'; import { useActiveEnvironmentId } from './useActiveEnvironmentId';
@@ -21,24 +22,28 @@ export function useGrpc(
const go = useMutation<void, string>({ const go = useMutation<void, string>({
mutationFn: async () => await invoke('cmd_grpc_go', { requestId, environmentId, protoFiles }), mutationFn: async () => await invoke('cmd_grpc_go', { requestId, environmentId, protoFiles }),
onSettled: () => trackEvent('grpc_request', 'send'),
}); });
const send = useMutation({ const send = useMutation({
mutationFn: async ({ message }: { message: string }) => mutationFn: async ({ message }: { message: string }) =>
await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, { Message: message }), await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, { Message: message }),
onSettled: () => trackEvent('grpc_connection', 'send'),
}); });
const cancel = useMutation({ const cancel = useMutation({
mutationKey: ['grpc_cancel', conn?.id ?? 'n/a'], mutationKey: ['grpc_cancel', conn?.id ?? 'n/a'],
mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Cancel'), mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Cancel'),
onSettled: () => trackEvent('grpc_connection', 'cancel'),
}); });
const commit = useMutation({ const commit = useMutation({
mutationKey: ['grpc_commit', conn?.id ?? 'n/a'], mutationKey: ['grpc_commit', conn?.id ?? 'n/a'],
mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'), mutationFn: async () => await emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Commit'),
onSettled: () => trackEvent('grpc_connection', 'commit'),
}); });
const debouncedUrl = useDebouncedValue<string>(req?.url ?? 'n/a', 1000); const debouncedUrl = useDebouncedValue<string>(req?.url ?? 'n/a', 500);
const reflect = useQuery<ReflectResponseService[], string>({ const reflect = useQuery<ReflectResponseService[], string>({
enabled: req != null, enabled: req != null,
queryKey: ['grpc_reflect', req?.id ?? 'n/a', debouncedUrl], queryKey: ['grpc_reflect', req?.id ?? 'n/a', debouncedUrl],

View File

@@ -1,10 +1,12 @@
import { NAMESPACE_GLOBAL } from '../lib/keyValueStore';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
export function useGrpcProtoFiles(activeRequestId: string | null) { export function protoFilesArgs(requestId: string | null) {
return useKeyValue<string[]>({ return {
namespace: NAMESPACE_GLOBAL, namespace: 'global' as const,
key: ['proto_files', activeRequestId ?? 'n/a'], key: ['proto_files', requestId ?? 'n/a'],
defaultValue: [], };
}); }
export function useGrpcProtoFiles(activeRequestId: string | null) {
return useKeyValue<string[]>({ ...protoFilesArgs(activeRequestId), fallback: [] });
} }

View File

@@ -4,8 +4,9 @@ import { capitalize } from '../lib/capitalize';
import { debounce } from '../lib/debounce'; import { debounce } from '../lib/debounce';
import { useOsInfo } from './useOsInfo'; import { useOsInfo } from './useOsInfo';
const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta'];
export type HotkeyAction = export type HotkeyAction =
| 'popup.close'
| 'environmentEditor.toggle' | 'environmentEditor.toggle'
| 'hotkeys.showHelp' | 'hotkeys.showHelp'
| 'grpc_request.send' | 'grpc_request.send'
@@ -20,7 +21,6 @@ export type HotkeyAction =
| 'urlBar.focus'; | 'urlBar.focus';
const hotkeys: Record<HotkeyAction, string[]> = { const hotkeys: Record<HotkeyAction, string[]> = {
'popup.close': ['Escape'],
'environmentEditor.toggle': ['CmdCtrl+Shift+e'], 'environmentEditor.toggle': ['CmdCtrl+Shift+e'],
'grpc_request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'], 'grpc_request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
'hotkeys.showHelp': ['CmdCtrl+Shift+/'], 'hotkeys.showHelp': ['CmdCtrl+Shift+/'],
@@ -36,7 +36,6 @@ const hotkeys: Record<HotkeyAction, string[]> = {
}; };
const hotkeyLabels: Record<HotkeyAction, string> = { const hotkeyLabels: Record<HotkeyAction, string> = {
'popup.close': 'Close Dropdown',
'environmentEditor.toggle': 'Edit Environments', 'environmentEditor.toggle': 'Edit Environments',
'grpc_request.send': 'Send Message', 'grpc_request.send': 'Send Message',
'hotkeys.showHelp': 'Show Keyboard Shortcuts', 'hotkeys.showHelp': 'Show Keyboard Shortcuts',
@@ -61,17 +60,6 @@ export function useHotKey(
action: HotkeyAction | null, action: HotkeyAction | null,
callback: (e: KeyboardEvent) => void, callback: (e: KeyboardEvent) => void,
options: Options = {}, options: Options = {},
) {
useAnyHotkey((hkAction, e) => {
if (hkAction === action) {
callback(e);
}
}, options);
}
export function useAnyHotkey(
callback: (action: HotkeyAction, e: KeyboardEvent) => void,
options: Options,
) { ) {
const currentKeys = useRef<Set<string>>(new Set()); const currentKeys = useRef<Set<string>>(new Set());
const callbackRef = useRef(callback); const callbackRef = useRef(callback);
@@ -83,7 +71,7 @@ export function useAnyHotkey(
}, [callback]); }, [callback]);
useEffect(() => { useEffect(() => {
// Sometimes the keyup event doesn't fire, so we clear the keys after a timeout // Sometimes the keyup event doesn't fire (eg, cmd+Tab), so we clear the keys after a timeout
const clearCurrentKeys = debounce(() => currentKeys.current.clear(), 5000); const clearCurrentKeys = debounce(() => currentKeys.current.clear(), 5000);
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
@@ -91,29 +79,55 @@ export function useAnyHotkey(
return; return;
} }
currentKeys.current.add(normalizeKey(e.key, os)); const key = normalizeKey(e.key, os);
// Don't add hold keys
if (HOLD_KEYS.includes(key)) {
return;
}
currentKeys.current.add(key);
const currentKeysWithModifiers = new Set(currentKeys.current);
if (e.altKey) currentKeysWithModifiers.add(normalizeKey('Alt', os));
if (e.ctrlKey) currentKeysWithModifiers.add(normalizeKey('Control', os));
if (e.metaKey) currentKeysWithModifiers.add(normalizeKey('Meta', os));
if (e.shiftKey) currentKeysWithModifiers.add(normalizeKey('Shift', os));
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) { for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
for (const hkKey of hkKeys) { for (const hkKey of hkKeys) {
if (hkAction !== action) {
continue;
}
const keys = hkKey.split('+'); const keys = hkKey.split('+');
if ( if (
keys.length === currentKeys.current.size && keys.length === currentKeysWithModifiers.size &&
keys.every((key) => currentKeys.current.has(key)) keys.every((key) => currentKeysWithModifiers.has(key))
) { ) {
// Triggered hotkey!
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
callbackRef.current(hkAction, e); callbackRef.current(e);
} }
} }
} }
clearCurrentKeys(); clearCurrentKeys();
}; };
const up = (e: KeyboardEvent) => { const up = (e: KeyboardEvent) => {
if (options.enable === false) { if (options.enable === false) {
return; return;
} }
currentKeys.current.delete(normalizeKey(e.key, os)); const key = normalizeKey(e.key, os);
currentKeys.current.delete(key);
// Clear all keys if no longer holding modifier
// HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ;
// As you see, the ":" is not removed because it turned into ";" when shift was released
const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
if (!isHoldingModifier) {
currentKeys.current.clear();
}
}; };
document.addEventListener('keydown', down, { capture: true }); document.addEventListener('keydown', down, { capture: true });
document.addEventListener('keyup', up, { capture: true }); document.addEventListener('keyup', up, { capture: true });

View File

@@ -22,7 +22,7 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
const [refetchKey, setRefetchKey] = useState<number>(0); const [refetchKey, setRefetchKey] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [introspection, setIntrospection] = useLocalStorage<IntrospectionQuery>( const [introspection, setIntrospection] = useLocalStorage<IntrospectionQuery | null>(
`introspection:${baseRequest.id}`, `introspection:${baseRequest.id}`,
); );
@@ -61,7 +61,10 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
const runIntrospection = () => { const runIntrospection = () => {
fetchIntrospection() fetchIntrospection()
.catch((e) => setError(e.message)) .catch((e) => {
setIntrospection(null);
setError(e.message);
})
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
@@ -77,10 +80,14 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
setRefetchKey((k) => k + 1); setRefetchKey((k) => k + 1);
}, []); }, []);
const schema = useMemo( const schema = useMemo(() => {
() => (introspection ? buildClientSchema(introspection) : undefined), try {
[introspection], return introspection ? buildClientSchema(introspection) : undefined;
); // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
setError('message' in e ? e.message : String(e));
}
}, [introspection]);
return { schema, isLoading, error, refetch }; return { schema, isLoading, error, refetch };
} }

View File

@@ -18,16 +18,16 @@ export function keyValueQueryKey({
export function useKeyValue<T extends Object | null>({ export function useKeyValue<T extends Object | null>({
namespace = DEFAULT_NAMESPACE, namespace = DEFAULT_NAMESPACE,
key, key,
defaultValue, fallback,
}: { }: {
namespace?: string; namespace?: 'app' | 'no_sync' | 'global';
key: string | string[]; key: string | string[];
defaultValue: T; fallback: T;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const query = useQuery<T>({ const query = useQuery<T>({
queryKey: keyValueQueryKey({ namespace, key }), queryKey: keyValueQueryKey({ namespace, key }),
queryFn: async () => getKeyValue({ namespace, key, fallback: defaultValue }), queryFn: async () => getKeyValue({ namespace, key, fallback }),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
@@ -40,7 +40,7 @@ export function useKeyValue<T extends Object | null>({
const set = useCallback( const set = useCallback(
async (value: ((v: T) => T) | T) => { async (value: ((v: T) => T) | T) => {
if (typeof value === 'function') { if (typeof value === 'function') {
await getKeyValue({ namespace, key, fallback: defaultValue }).then((kv) => { await getKeyValue({ namespace, key, fallback }).then((kv) => {
const newV = value(kv); const newV = value(kv);
if (newV === kv) return; if (newV === kv) return;
return mutate.mutateAsync(newV); return mutate.mutateAsync(newV);
@@ -51,10 +51,10 @@ export function useKeyValue<T extends Object | null>({
await mutate.mutateAsync(value); await mutate.mutateAsync(value);
} }
}, },
[defaultValue, key, mutate, namespace], [fallback, key, mutate, namespace],
); );
const reset = useCallback(async () => mutate.mutateAsync(defaultValue), [mutate, defaultValue]); const reset = useCallback(async () => mutate.mutateAsync(fallback), [mutate, fallback]);
return useMemo( return useMemo(
() => ({ () => ({

View File

@@ -1,13 +1,13 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { getKeyValue, NAMESPACE_GLOBAL } from '../lib/keyValueStore'; import { getKeyValue } from '../lib/keyValueStore';
import { useActiveEnvironmentId } from './useActiveEnvironmentId'; import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useEnvironments } from './useEnvironments'; import { useEnvironments } from './useEnvironments';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
const kvKey = (workspaceId: string) => 'recent_environments::' + workspaceId; const kvKey = (workspaceId: string) => 'recent_environments::' + workspaceId;
const namespace = NAMESPACE_GLOBAL; const namespace = 'global';
const defaultValue: string[] = []; const fallback: string[] = [];
export function useRecentEnvironments() { export function useRecentEnvironments() {
const environments = useEnvironments(); const environments = useEnvironments();
@@ -16,7 +16,7 @@ export function useRecentEnvironments() {
const kv = useKeyValue<string[]>({ const kv = useKeyValue<string[]>({
key: kvKey(activeWorkspaceId ?? 'n/a'), key: kvKey(activeWorkspaceId ?? 'n/a'),
namespace, namespace,
defaultValue, fallback,
}); });
// Set history when active request changes // Set history when active request changes
@@ -41,6 +41,6 @@ export async function getRecentEnvironments(workspaceId: string) {
return getKeyValue<string[]>({ return getKeyValue<string[]>({
namespace, namespace,
key: kvKey(workspaceId), key: kvKey(workspaceId),
fallback: defaultValue, fallback,
}); });
} }

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { getKeyValue, NAMESPACE_GLOBAL } from '../lib/keyValueStore'; import { getKeyValue } from '../lib/keyValueStore';
import { useActiveRequestId } from './useActiveRequestId'; import { useActiveRequestId } from './useActiveRequestId';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useGrpcRequests } from './useGrpcRequests'; import { useGrpcRequests } from './useGrpcRequests';
@@ -7,8 +7,8 @@ import { useHttpRequests } from './useHttpRequests';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
const kvKey = (workspaceId: string) => 'recent_requests::' + workspaceId; const kvKey = (workspaceId: string) => 'recent_requests::' + workspaceId;
const namespace = NAMESPACE_GLOBAL; const namespace = 'global';
const defaultValue: string[] = []; const fallback: string[] = [];
export function useRecentRequests() { export function useRecentRequests() {
const httpRequests = useHttpRequests(); const httpRequests = useHttpRequests();
@@ -20,7 +20,7 @@ export function useRecentRequests() {
const kv = useKeyValue<string[]>({ const kv = useKeyValue<string[]>({
key: kvKey(activeWorkspaceId ?? 'n/a'), key: kvKey(activeWorkspaceId ?? 'n/a'),
namespace, namespace,
defaultValue, fallback,
}); });
// Set history when active request changes // Set history when active request changes
@@ -45,6 +45,6 @@ export async function getRecentRequests(workspaceId: string) {
return getKeyValue<string[]>({ return getKeyValue<string[]>({
namespace, namespace,
key: kvKey(workspaceId), key: kvKey(workspaceId),
fallback: defaultValue, fallback,
}); });
} }

View File

@@ -1,12 +1,12 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { getKeyValue, NAMESPACE_GLOBAL } from '../lib/keyValueStore'; import { getKeyValue } from '../lib/keyValueStore';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
import { useWorkspaces } from './useWorkspaces'; import { useWorkspaces } from './useWorkspaces';
const kvKey = () => 'recent_workspaces'; const kvKey = () => 'recent_workspaces';
const namespace = NAMESPACE_GLOBAL; const namespace = 'global';
const defaultValue: string[] = []; const fallback: string[] = [];
export function useRecentWorkspaces() { export function useRecentWorkspaces() {
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
@@ -14,7 +14,7 @@ export function useRecentWorkspaces() {
const kv = useKeyValue<string[]>({ const kv = useKeyValue<string[]>({
key: kvKey(), key: kvKey(),
namespace, namespace,
defaultValue, fallback,
}); });
// Set history when active request changes // Set history when active request changes
@@ -25,7 +25,7 @@ export function useRecentWorkspaces() {
return [activeWorkspaceId, ...withoutCurrent]; return [activeWorkspaceId, ...withoutCurrent];
}).catch(console.error); }).catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [activeWorkspaceId]);
const onlyValidIds = useMemo( const onlyValidIds = useMemo(
() => kv.value?.filter((id) => workspaces.some((w) => w.id === id)) ?? [], () => kv.value?.filter((id) => workspaces.some((w) => w.id === id)) ?? [],
@@ -39,6 +39,6 @@ export async function getRecentWorkspaces() {
return getKeyValue<string[]>({ return getKeyValue<string[]>({
namespace, namespace,
key: kvKey(), key: kvKey(),
fallback: defaultValue, fallback: fallback,
}); });
} }

View File

@@ -1,9 +0,0 @@
import { useMemo } from 'react';
import type { HttpResponse } from '../lib/models';
export function useResponseContentType(response: HttpResponse | null): string | null {
return useMemo(
() => response?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null,
[response],
);
}

View File

@@ -6,11 +6,11 @@ import { trackEvent } from '../lib/analytics';
import type { HttpResponse } from '../lib/models'; import type { HttpResponse } from '../lib/models';
import { getHttpRequest } from '../lib/store'; import { getHttpRequest } from '../lib/store';
import { useActiveCookieJar } from './useActiveCookieJar'; import { useActiveCookieJar } from './useActiveCookieJar';
import { useActiveEnvironmentId } from './useActiveEnvironmentId'; import { useActiveEnvironment } from './useActiveEnvironment';
import { useAlert } from './useAlert'; import { useAlert } from './useAlert';
export function useSendAnyRequest(options: { download?: boolean } = {}) { export function useSendAnyRequest(options: { download?: boolean } = {}) {
const environmentId = useActiveEnvironmentId(); const environment = useActiveEnvironment();
const alert = useAlert(); const alert = useAlert();
const { activeCookieJar } = useActiveCookieJar(); const { activeCookieJar } = useActiveCookieJar();
return useMutation<HttpResponse | null, string, string | null>({ return useMutation<HttpResponse | null, string, string | null>({
@@ -33,7 +33,7 @@ export function useSendAnyRequest(options: { download?: boolean } = {}) {
return invoke('cmd_send_http_request', { return invoke('cmd_send_http_request', {
requestId: id, requestId: id,
environmentId, environmentId: environment?.id,
downloadDir: downloadDir, downloadDir: downloadDir,
cookieJarId: activeCookieJar?.id, cookieJarId: activeCookieJar?.id,
}); });

View File

@@ -1,14 +1,13 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
export function useSidebarHidden() { export function useSidebarHidden() {
const activeWorkspaceId = useActiveWorkspaceId(); const activeWorkspaceId = useActiveWorkspaceId();
const { set, value } = useKeyValue<boolean>({ const { set, value } = useKeyValue<boolean>({
namespace: NAMESPACE_NO_SYNC, namespace: 'no_sync',
key: ['sidebar_hidden', activeWorkspaceId ?? 'n/a'], key: ['sidebar_hidden', activeWorkspaceId ?? 'n/a'],
defaultValue: false, fallback: false,
}); });
return useMemo(() => { return useMemo(() => {

View File

@@ -18,6 +18,7 @@ export function trackEvent(
| 'workspace', | 'workspace',
action: action:
| 'cancel' | 'cancel'
| 'commit'
| 'create' | 'create'
| 'delete' | 'delete'
| 'delete_many' | 'delete_many'

View File

@@ -1,11 +1,8 @@
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import type { KeyValue } from './models'; import type { KeyValue } from './models';
export const NAMESPACE_GLOBAL = 'global';
export const NAMESPACE_NO_SYNC = 'no_sync';
export async function setKeyValue<T>({ export async function setKeyValue<T>({
namespace = NAMESPACE_GLOBAL, namespace = 'global',
key, key,
value, value,
}: { }: {
@@ -21,7 +18,7 @@ export async function setKeyValue<T>({
} }
export async function getKeyValue<T>({ export async function getKeyValue<T>({
namespace = NAMESPACE_GLOBAL, namespace = 'global',
key, key,
fallback, fallback,
}: { }: {

View File

@@ -1,6 +1,7 @@
export const BODY_TYPE_NONE = null; export const BODY_TYPE_NONE = null;
export const BODY_TYPE_GRAPHQL = 'graphql'; export const BODY_TYPE_GRAPHQL = 'graphql';
export const BODY_TYPE_JSON = 'application/json'; export const BODY_TYPE_JSON = 'application/json';
export const BODY_TYPE_BINARY = 'binary';
export const BODY_TYPE_OTHER = 'other'; export const BODY_TYPE_OTHER = 'other';
export const BODY_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded'; export const BODY_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded';
export const BODY_TYPE_FORM_MULTIPART = 'multipart/form-data'; export const BODY_TYPE_FORM_MULTIPART = 'multipart/form-data';

View File

@@ -1,8 +1,8 @@
import { appWindow } from '@tauri-apps/api/window'; import { appWindow } from '@tauri-apps/api/window';
import { NAMESPACE_NO_SYNC, getKeyValue, setKeyValue } from './keyValueStore'; import { getKeyValue, setKeyValue } from './keyValueStore';
const key = ['window_pathname', appWindow.label]; const key = ['window_pathname', appWindow.label];
const namespace = NAMESPACE_NO_SYNC; const namespace = 'no_sync';
const fallback = undefined; const fallback = undefined;
export async function setPathname(value: string) { export async function setPathname(value: string) {

View File

@@ -29,7 +29,7 @@
} }
a, a,
a * { a[href] * {
@apply cursor-pointer !important; @apply cursor-pointer !important;
} }
@@ -65,6 +65,10 @@
} }
} }
.rtl {
direction: rtl;
}
iframe { iframe {
&::-webkit-scrollbar-corner, &::-webkit-scrollbar-corner,
&::-webkit-scrollbar { &::-webkit-scrollbar {

View File

@@ -20,9 +20,10 @@ if (osType !== 'Darwin') {
const settings = await getSettings(); const settings = await getSettings();
setAppearanceOnDocument(settings.appearance as Appearance); setAppearanceOnDocument(settings.appearance as Appearance);
document.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
// Don't go back in history on backspace // Hack to not go back in history on backspace. Check for document body
if (e.key === 'Backspace') e.preventDefault(); // or else it will prevent backspace in input fields.
if (e.key === 'Backspace' && e.target === document.body) e.preventDefault();
}); });
createRoot(document.getElementById('root') as HTMLElement).render( createRoot(document.getElementById('root') as HTMLElement).render(