mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-16 14:06:49 +01:00
Compare commits
39 Commits
v2023.0.6
...
v2023.0.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66813d67fe | ||
|
|
a38691ed53 | ||
|
|
deeefdcfbf | ||
|
|
db292511b1 | ||
|
|
1a5334c1ce | ||
|
|
11002abe39 | ||
|
|
d922dcb062 | ||
|
|
6fcaa18e86 | ||
|
|
7664c941dd | ||
|
|
6f5cb528c6 | ||
|
|
ebb78922f0 | ||
|
|
2285fe9f1c | ||
|
|
38ba8625d8 | ||
|
|
ab5681c7ad | ||
|
|
f66dcb9267 | ||
|
|
1b6cfbac77 | ||
|
|
4c27e788ea | ||
|
|
769da0b052 | ||
|
|
6b60c86300 | ||
|
|
30c1b5e8c7 | ||
|
|
10af9b6f99 | ||
|
|
aa8c066f2d | ||
|
|
b913b74449 | ||
|
|
b71adce50b | ||
|
|
0fbb44c701 | ||
|
|
de335e8637 | ||
|
|
2999f63a4c | ||
|
|
2abc5e6f0b | ||
|
|
639de4321e | ||
|
|
b3c461afdd | ||
|
|
7d154800a0 | ||
|
|
b48ed0399e | ||
|
|
c5d6e7d74a | ||
|
|
e82f915363 | ||
|
|
3128e9ce76 | ||
|
|
bc0e86757c | ||
|
|
fec99916c2 | ||
|
|
3b5d059b11 | ||
|
|
c3fe2acc8a |
@@ -8,9 +8,9 @@ module.exports = {
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"eslint-config-prettier"
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
project: ["./tsconfig.json"]
|
||||
},
|
||||
ignorePatterns: ["src-tauri/**/*"],
|
||||
settings: {
|
||||
@@ -25,13 +25,13 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"jsx-a11y/no-autofocus": "warn",
|
||||
"jsx-a11y/no-autofocus": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"@typescript-eslint/consistent-type-imports": ["error", {
|
||||
prefer: "type-imports",
|
||||
disallowTypeAnnotations: true,
|
||||
fixStyle: "separate-type-imports"
|
||||
}],
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
21
.github/workflows/artifacts.yml
vendored
21
.github/workflows/artifacts.yml
vendored
@@ -5,19 +5,22 @@ on:
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# platform: [ ubuntu-latest, macos-latest, windows-latest ]
|
||||
platform: [ macos-latest ]
|
||||
include:
|
||||
- os: macos-12
|
||||
target: aarch64-apple-darwin
|
||||
- os: macos-12
|
||||
target: x86_64-apple-darwin
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: 'aarch64-apple-darwin,x86_64-apple-darwin'
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Cache Rust
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
@@ -31,7 +34,7 @@ jobs:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-latest'
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
@@ -55,7 +58,7 @@ jobs:
|
||||
with:
|
||||
tagName: 'v__VERSION__'
|
||||
releaseName: 'Release __VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseDraft: false
|
||||
releaseBody: '<!-- Release Notes -->'
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: '--target universal-apple-darwin'
|
||||
args: '--target ${{ matrix.target }}'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"tauri-dev": "YAAK_ENV=development tauri dev",
|
||||
"tauri-build": "tauri build",
|
||||
"tauri": "tauri",
|
||||
"build": "npm run build:frontend",
|
||||
"dev": "vite dev",
|
||||
"lint": "tsc && eslint . --ext .ts,.tsx",
|
||||
|
||||
12
src-tauri/Cargo.lock
generated
12
src-tauri/Cargo.lock
generated
@@ -679,9 +679,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deno_core"
|
||||
version = "0.178.0"
|
||||
version = "0.179.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d58bd9e43979857fd26f4696f8e9f39fb645539ef3e604264521b408daf1d92b"
|
||||
checksum = "8c9307ca2299cb7b0bdaa345cbdc82a252a8e4e5a4463e28f44c715d55e460fb"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -704,9 +704,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deno_ops"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f494a90671467e3de74b557b3c1fe805aad87c7239580d2be8f2dddde971824"
|
||||
checksum = "04610f07342fbb33a2b7ea7aa16a95ab71adb13a0ce858a8d1a1414660a83e3e"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"pmutil",
|
||||
@@ -3161,9 +3161,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_v8"
|
||||
version = "0.89.0"
|
||||
version = "0.90.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9541cff99b1b9da15461aada44f09577af1f614add71f2fedc250c7e650a0383"
|
||||
checksum = "916ca7852a4c5f0ba59ce4a46301bf7c7ad573c2c89a0fe67e90fe30dcbd6f7d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"derive_more",
|
||||
|
||||
@@ -25,7 +25,7 @@ http = "0.2.8"
|
||||
reqwest = { version = "0.11.14", features = ["json"] }
|
||||
tokio = { version = "1.25.0", features = ["sync"] }
|
||||
futures = "0.3.26"
|
||||
deno_core = "0.178.0"
|
||||
deno_core = "0.179.0"
|
||||
deno_ast = { version = "0.25.0", features = ["transpiling"] }
|
||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time", "offline"] }
|
||||
uuid = "1.3.0"
|
||||
|
||||
8
src-tauri/macos/entitlements.plist
Normal file
8
src-tauri/macos/entitlements.plist
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -506,6 +506,16 @@
|
||||
},
|
||||
"query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n "
|
||||
},
|
||||
"e0f41023d877d94b7609ce910a71bd89c4827a558654b8ae14d85e6ba86990cf": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
}
|
||||
},
|
||||
"query": "\n UPDATE workspaces SET (name, updated_at) =\n (?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
|
||||
},
|
||||
"e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": {
|
||||
"describe": {
|
||||
"columns": [
|
||||
|
||||
@@ -21,8 +21,10 @@ use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use tauri::regex::Regex;
|
||||
use tauri::{AppHandle, Menu, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, Wry};
|
||||
use tauri::{CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowEvent};
|
||||
use tauri::{
|
||||
AppHandle, Menu, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, WindowUrl, Wry,
|
||||
};
|
||||
use tauri::{CustomMenuItem, Manager, WindowEvent};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use window_ext::WindowExt;
|
||||
@@ -79,11 +81,8 @@ async fn actually_send_ephemeral_request(
|
||||
let start = std::time::Instant::now();
|
||||
let mut url_string = request.url.to_string();
|
||||
|
||||
let mut variables = HashMap::new();
|
||||
variables.insert("PROJECT_ID", "project_123");
|
||||
variables.insert("TOKEN", "s3cret");
|
||||
variables.insert("DOMAIN", "schier.co");
|
||||
variables.insert("BASE_URL", "https://schier.co");
|
||||
let variables: HashMap<&str, &str> = HashMap::new();
|
||||
// variables.insert("", "");
|
||||
|
||||
let re = Regex::new(r"\$\{\[\s*([^]\s]+)\s*]}").expect("Failed to create regex");
|
||||
url_string = re
|
||||
@@ -104,6 +103,7 @@ async fn actually_send_ephemeral_request(
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
// .danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.expect("Failed to build client");
|
||||
|
||||
@@ -188,12 +188,23 @@ async fn actually_send_ephemeral_request(
|
||||
|
||||
let raw_response = client.execute(sendable_req).await;
|
||||
|
||||
let p = app_handle
|
||||
.path_resolver()
|
||||
.resolve_resource("plugins/plugin.ts")
|
||||
.expect("failed to resolve resource");
|
||||
let plugin_rel_path = "plugins/plugin.ts";
|
||||
let plugin_path = match app_handle.path_resolver().resolve_resource(plugin_rel_path) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return response_err(
|
||||
response,
|
||||
format!("Plugin not found at {}", plugin_rel_path),
|
||||
&app_handle,
|
||||
pool,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
|
||||
runtime::run_plugin_sync(p.to_str().unwrap()).unwrap();
|
||||
if let Err(e) = runtime::run_plugin_sync(plugin_path.to_str().unwrap()) {
|
||||
return response_err(response, e.to_string(), &app_handle, pool).await;
|
||||
}
|
||||
|
||||
match raw_response {
|
||||
Ok(v) => {
|
||||
@@ -353,6 +364,21 @@ async fn duplicate_request(
|
||||
emit_and_return(&window, "updated_model", request)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_workspace(
|
||||
workspace: models::Workspace,
|
||||
window: Window<Wry>,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::Workspace, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
|
||||
let updated_workspace = models::update_workspace(workspace, pool)
|
||||
.await
|
||||
.expect("Failed to update request");
|
||||
|
||||
emit_and_return(&window, "updated_model", updated_workspace)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_request(
|
||||
request: models::HttpRequest,
|
||||
@@ -427,6 +453,17 @@ async fn get_request(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_workspace(
|
||||
id: &str,
|
||||
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
|
||||
) -> Result<models::Workspace, String> {
|
||||
let pool = &*db_instance.lock().await;
|
||||
models::get_workspace(id, pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn responses(
|
||||
request_id: &str,
|
||||
@@ -481,6 +518,12 @@ async fn workspaces(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn new_window(window: Window<Wry>, url: &str) -> Result<(), String> {
|
||||
create_window(&window.app_handle(), Some(url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_workspace(
|
||||
window: Window<Wry>,
|
||||
@@ -494,18 +537,8 @@ async fn delete_workspace(
|
||||
emit_and_return(&window, "deleted_model", workspace)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
|
||||
let tray_menu = SystemTrayMenu::new().add_item(quit);
|
||||
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||
|
||||
tauri::Builder::default()
|
||||
.system_tray(system_tray)
|
||||
.setup(|app| {
|
||||
let dir = match is_dev() {
|
||||
true => current_dir().unwrap(),
|
||||
@@ -533,22 +566,8 @@ fn main() {
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.on_system_tray_event(|app, event| {
|
||||
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
|
||||
match id.as_str() {
|
||||
"quit" => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
"hide" => {
|
||||
let window = app.get_window("main").unwrap();
|
||||
window.hide().unwrap();
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
new_window,
|
||||
workspaces,
|
||||
get_request,
|
||||
requests,
|
||||
@@ -556,8 +575,10 @@ fn main() {
|
||||
send_ephemeral_request,
|
||||
duplicate_request,
|
||||
create_request,
|
||||
get_workspace,
|
||||
create_workspace,
|
||||
delete_workspace,
|
||||
update_workspace,
|
||||
update_request,
|
||||
delete_request,
|
||||
responses,
|
||||
@@ -570,7 +591,7 @@ fn main() {
|
||||
.expect("error while running tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
RunEvent::Ready => {
|
||||
create_window(app_handle);
|
||||
create_window(app_handle, None);
|
||||
}
|
||||
|
||||
// ExitRequested { api, .. } => {
|
||||
@@ -584,7 +605,7 @@ fn is_dev() -> bool {
|
||||
env.unwrap_or("production") != "production"
|
||||
}
|
||||
|
||||
fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
|
||||
fn create_window(handle: &AppHandle<Wry>, url: Option<&str>) -> Window<Wry> {
|
||||
let default_menu = Menu::os_default("Yaak".to_string().as_str());
|
||||
let mut test_menu = Menu::new()
|
||||
.add_item(
|
||||
@@ -608,6 +629,22 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
|
||||
.add_item(
|
||||
CustomMenuItem::new("focus_url".to_string(), "Focus URL").accelerator("CmdOrCtrl+l"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("new_request".to_string(), "New Request")
|
||||
.accelerator("CmdOrCtrl+n"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("toggle_settings".to_string(), "Toggle Settings")
|
||||
.accelerator("CmdOrCtrl+,"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("duplicate_request".to_string(), "Duplicate Request")
|
||||
.accelerator("CmdOrCtrl+d"),
|
||||
)
|
||||
.add_item(
|
||||
CustomMenuItem::new("focus_sidebar".to_string(), "Focus Sidebar")
|
||||
.accelerator("CmdOrCtrl+1"),
|
||||
)
|
||||
.add_item(CustomMenuItem::new("new_window".to_string(), "New Window"));
|
||||
if is_dev() {
|
||||
test_menu = test_menu
|
||||
@@ -627,19 +664,23 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
|
||||
let window_num = handle.windows().len();
|
||||
let window_id = format!("wnd_{}", window_num);
|
||||
let menu = default_menu.add_submenu(submenu);
|
||||
let win = tauri::WindowBuilder::new(handle, window_id, tauri::WindowUrl::App("".into()))
|
||||
.menu(menu)
|
||||
.fullscreen(false)
|
||||
.resizable(true)
|
||||
.inner_size(1100.0, 600.0)
|
||||
.hidden_title(true)
|
||||
.title(match is_dev() {
|
||||
true => "Yaak Dev",
|
||||
false => "Yaak",
|
||||
})
|
||||
.title_bar_style(TitleBarStyle::Overlay)
|
||||
.build()
|
||||
.expect("failed to build window");
|
||||
let win = tauri::WindowBuilder::new(
|
||||
handle,
|
||||
window_id,
|
||||
WindowUrl::App(url.unwrap_or_default().into()),
|
||||
)
|
||||
.menu(menu)
|
||||
.fullscreen(false)
|
||||
.resizable(true)
|
||||
.inner_size(1100.0, 600.0)
|
||||
.hidden_title(true)
|
||||
.title(match is_dev() {
|
||||
true => "Yaak Dev",
|
||||
false => "Yaak",
|
||||
})
|
||||
.title_bar_style(TitleBarStyle::Overlay)
|
||||
.build()
|
||||
.expect("failed to build window");
|
||||
|
||||
let win2 = win.clone();
|
||||
let handle2 = handle.clone();
|
||||
@@ -651,9 +692,13 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
|
||||
"zoom_out" => win2.emit("zoom", -1).unwrap(),
|
||||
"toggle_sidebar" => win2.emit("toggle_sidebar", true).unwrap(),
|
||||
"focus_url" => win2.emit("focus_url", true).unwrap(),
|
||||
"focus_sidebar" => win2.emit("focus_sidebar", true).unwrap(),
|
||||
"send_request" => win2.emit("send_request", true).unwrap(),
|
||||
"new_request" => _ = win2.emit("new_request", true).unwrap(),
|
||||
"toggle_settings" => _ = win2.emit("toggle_settings", true).unwrap(),
|
||||
"duplicate_request" => _ = win2.emit("duplicate_request", true).unwrap(),
|
||||
"refresh" => win2.eval("location.reload()").unwrap(),
|
||||
"new_window" => _ = create_window(&handle2),
|
||||
"new_window" => _ = create_window(&handle2, None),
|
||||
"toggle_devtools" => {
|
||||
if win2.is_devtools_open() {
|
||||
win2.close_devtools();
|
||||
|
||||
@@ -244,6 +244,7 @@ pub async fn upsert_request(
|
||||
};
|
||||
let headers_json = Json(headers);
|
||||
let auth_json = Json(authentication);
|
||||
let trimmed_name = name.trim();
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO http_requests (
|
||||
@@ -274,7 +275,7 @@ pub async fn upsert_request(
|
||||
"#,
|
||||
id,
|
||||
workspace_id,
|
||||
name,
|
||||
trimmed_name,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
@@ -423,6 +424,25 @@ pub async fn update_response_if_id(
|
||||
return update_response(response, pool).await;
|
||||
}
|
||||
|
||||
pub async fn update_workspace(
|
||||
workspace: Workspace,
|
||||
pool: &Pool<Sqlite>,
|
||||
) -> Result<Workspace, sqlx::Error> {
|
||||
let trimmed_name = workspace.name.trim();
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE workspaces SET (name, updated_at) =
|
||||
(?, CURRENT_TIMESTAMP) WHERE id = ?;
|
||||
"#,
|
||||
trimmed_name,
|
||||
workspace.id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Failed to update workspace");
|
||||
get_workspace(&workspace.id, pool).await
|
||||
}
|
||||
|
||||
pub async fn update_response(
|
||||
response: HttpResponse,
|
||||
pool: &Pool<Sqlite>,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Yaak",
|
||||
"version": "2023.0.6"
|
||||
"version": "2023.0.15"
|
||||
},
|
||||
"tauri": {
|
||||
"windows": [],
|
||||
@@ -52,6 +52,7 @@
|
||||
},
|
||||
"macOS": {
|
||||
"exceptionDomain": "",
|
||||
"entitlements": "macos/entitlements.plist",
|
||||
"frameworks": []
|
||||
},
|
||||
"windows": {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { persistQueryClient } from '@tanstack/react-query-persist-client';
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import { Suspense } from 'react';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
@@ -9,33 +6,18 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { AppRouter } from './AppRouter';
|
||||
import { DialogProvider } from './DialogContext';
|
||||
import { TauriListeners } from './TauriListeners';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
logger: undefined,
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
refetchOnWindowFocus: true,
|
||||
networkMode: 'offlineFirst',
|
||||
|
||||
// It's a desktop app, so this isn't necessary
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const localStoragePersister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
throttleTime: 1000, // 1 second
|
||||
});
|
||||
|
||||
persistQueryClient({
|
||||
queryClient,
|
||||
persister: localStoragePersister,
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -45,8 +27,7 @@ export function App() {
|
||||
<DialogProvider>
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
<TauriListeners />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
|
||||
</Suspense>
|
||||
</DialogProvider>
|
||||
</DndProvider>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
Navigate,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { routePaths } from '../hooks/useRoutes';
|
||||
import { setLastLocation } from '../lib/lastLocation';
|
||||
import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom';
|
||||
import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { GlobalHooks } from './GlobalHooks';
|
||||
import RouteError from './RouteError';
|
||||
import Workspace from './Workspace';
|
||||
import Workspaces from './Workspaces';
|
||||
@@ -16,7 +11,7 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
errorElement: <RouteError />,
|
||||
element: <RouterRoot />,
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
@@ -28,7 +23,7 @@ const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
|
||||
element: <Workspace />,
|
||||
element: <WorkspaceOrRedirect />,
|
||||
},
|
||||
{
|
||||
path: routePaths.request({
|
||||
@@ -45,10 +40,28 @@ export function AppRouter() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
function RouterRoot() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
setLastLocation(pathname).catch(console.error);
|
||||
}, [pathname]);
|
||||
return <Outlet />;
|
||||
function WorkspaceOrRedirect() {
|
||||
const recentRequests = useRecentRequests();
|
||||
const requests = useRequests();
|
||||
const request = requests.find((r) => r.id === recentRequests[0]);
|
||||
const routes = useAppRoutes();
|
||||
|
||||
if (request === undefined) {
|
||||
return <Workspace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to={routes.paths.request({ workspaceId: request.workspaceId, requestId: request.id })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<GlobalHooks />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,13 +45,20 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<DialogContext.Provider value={state}>
|
||||
{children}
|
||||
{dialogs.map(({ id, render, ...props }) => (
|
||||
<Dialog open key={id} onClose={() => actions.hide(id)} {...props}>
|
||||
{render({ hide: () => actions.hide(id) })}
|
||||
</Dialog>
|
||||
{dialogs.map((props: DialogEntry) => (
|
||||
<DialogInstance key={props.id} {...props} />
|
||||
))}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
function DialogInstance({ id, render, ...props }: DialogEntry) {
|
||||
const { actions } = useContext(DialogContext);
|
||||
return (
|
||||
<Dialog open onClose={() => actions.hide(id)} {...props}>
|
||||
{render({ hide: () => actions.hide(id) })}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const useDialog = () => useContext(DialogContext).actions;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
|
||||
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
|
||||
import { modelsEq } from '../lib/models';
|
||||
|
||||
export function TauriListeners() {
|
||||
export function GlobalHooks() {
|
||||
const queryClient = useQueryClient();
|
||||
const { wasUpdatedExternally } = useRequestUpdateKey(null);
|
||||
|
||||
@@ -30,9 +30,7 @@ export function TauriListeners() {
|
||||
: null;
|
||||
|
||||
if (queryKey === null) {
|
||||
if (payload.model) {
|
||||
console.log('Unrecognized created model:', payload);
|
||||
}
|
||||
console.log('Unrecognized created model:', payload);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,9 +54,7 @@ export function TauriListeners() {
|
||||
: null;
|
||||
|
||||
if (queryKey === null) {
|
||||
if (payload.model) {
|
||||
console.log('Unrecognized updated model:', payload);
|
||||
}
|
||||
console.log('Unrecognized updated model:', payload);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,7 +111,8 @@ const shouldIgnoreEvent = (payload: Model, windowLabel: string) =>
|
||||
windowLabel === appWindow.label && payload.model !== 'http_response';
|
||||
|
||||
const shouldIgnoreModel = (payload: Model) => {
|
||||
if (payload.model === 'http_response') return false;
|
||||
if (payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC) return false;
|
||||
return true;
|
||||
if (payload.model === 'key_value') {
|
||||
return payload.namespace === NAMESPACE_NO_SYNC;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -71,7 +71,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
|
||||
const dialog = useDialog();
|
||||
|
||||
return (
|
||||
<div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
|
||||
<div className="pb-2 h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
|
||||
<Editor
|
||||
contentType="application/graphql"
|
||||
defaultValue={query ?? ''}
|
||||
|
||||
9
src-web/components/ImageView.tsx
Normal file
9
src-web/components/ImageView.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
interface Props {
|
||||
data: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function ImageView({ data }: Props) {
|
||||
// const dataUri = `data:image/png;base64,${window.btoa(data)}`;
|
||||
return <div>Image preview not supported until binary response support is added</div>;
|
||||
}
|
||||
83
src-web/components/RecentRequestsDropdown.tsx
Normal file
83
src-web/components/RecentRequestsDropdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useKey, useKeyPressEvent } from 'react-use';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { Button } from './core/Button';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import type { DropdownItem, DropdownRef } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
|
||||
export function RecentRequestsDropdown() {
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const activeRequest = useActiveRequest();
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const recentRequestIds = useRecentRequests();
|
||||
const requests = useRequests();
|
||||
const routes = useAppRoutes();
|
||||
|
||||
useKeyPressEvent('Control', undefined, () => {
|
||||
// Key up
|
||||
dropdownRef.current?.select?.();
|
||||
});
|
||||
|
||||
useKey(
|
||||
'Tab',
|
||||
(e) => {
|
||||
if (!e.ctrlKey || recentRequestIds.length === 0) return;
|
||||
|
||||
if (!dropdownRef.current?.isOpen) {
|
||||
// Set to 1 because the first item is the active request
|
||||
dropdownRef.current?.open(e.shiftKey ? -1 : 0);
|
||||
}
|
||||
|
||||
if (e.shiftKey) dropdownRef.current?.prev?.();
|
||||
else dropdownRef.current?.next?.();
|
||||
},
|
||||
undefined,
|
||||
[recentRequestIds.length],
|
||||
);
|
||||
|
||||
const items = useMemo<DropdownItem[]>(() => {
|
||||
if (activeWorkspaceId === null) return [];
|
||||
|
||||
const recentRequestItems: DropdownItem[] = [];
|
||||
for (const id of recentRequestIds) {
|
||||
const request = requests.find((r) => r.id === id);
|
||||
if (request === undefined) continue;
|
||||
|
||||
recentRequestItems.push({
|
||||
key: request.id,
|
||||
label: request.name,
|
||||
leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length + 1} />,
|
||||
onSelect: () => {
|
||||
routes.navigate('request', {
|
||||
requestId: request.id,
|
||||
workspaceId: activeWorkspaceId,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// No recent requests to show
|
||||
if (recentRequestItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return recentRequestItems.slice(0, 20);
|
||||
}, [activeWorkspaceId, recentRequestIds, requests, routes]);
|
||||
|
||||
return (
|
||||
<Dropdown ref={dropdownRef} items={items}>
|
||||
<Button
|
||||
disabled={activeRequest === null}
|
||||
size="sm"
|
||||
className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none"
|
||||
>
|
||||
{activeRequest?.name ?? 'No Request'}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { HTMLAttributes, ReactElement } from 'react';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import React, { useRef } from 'react';
|
||||
import { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
|
||||
import { useRequest } from '../hooks/useRequest';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import type { DropdownRef } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { HotKey } from './core/HotKey';
|
||||
import { Icon } from './core/Icon';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
|
||||
interface Props {
|
||||
requestId: string;
|
||||
@@ -13,37 +15,45 @@ interface Props {
|
||||
}
|
||||
|
||||
export function RequestActionsDropdown({ requestId, children }: Props) {
|
||||
const request = useRequest(requestId ?? null);
|
||||
const deleteRequest = useDeleteRequest(requestId ?? null);
|
||||
const deleteRequest = useDeleteRequest(requestId);
|
||||
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
|
||||
const confirm = useConfirm();
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const { appearance, toggleAppearance } = useTheme();
|
||||
|
||||
useTauriEvent('toggle_settings', () => {
|
||||
dropdownRef.current?.toggle();
|
||||
});
|
||||
|
||||
// TODO: Put this somewhere better
|
||||
useTauriEvent('duplicate_request', () => {
|
||||
duplicateRequest.mutate();
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
ref={dropdownRef}
|
||||
items={[
|
||||
{
|
||||
key: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
onSelect: duplicateRequest.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
rightSlot: <HotKey modifier="Meta" keyName="D" />,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
onSelect: async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Request',
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
deleteRequest.mutate();
|
||||
}
|
||||
},
|
||||
onSelect: deleteRequest.mutate,
|
||||
variant: 'danger',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
},
|
||||
{ type: 'separator', label: 'Yaak Settings' },
|
||||
{
|
||||
key: 'appearance',
|
||||
label: appearance === 'dark' ? 'Light Theme' : 'Dark Theme',
|
||||
onSelect: toggleAppearance,
|
||||
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -162,7 +162,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
|
||||
label="Request"
|
||||
onChangeValue={setActiveTab}
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-2"
|
||||
tabListClassName="mt-2 !mb-1.5"
|
||||
>
|
||||
<TabContent value="auth">
|
||||
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
|
||||
|
||||
@@ -2,7 +2,8 @@ import useResizeObserver from '@react-hook/resize-observer';
|
||||
import classnames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
||||
import { clamp } from '../lib/clamp';
|
||||
import { RequestPane } from './RequestPane';
|
||||
import { ResizeHandle } from './ResizeHandle';
|
||||
@@ -24,10 +25,10 @@ const STACK_VERTICAL_WIDTH = 650;
|
||||
export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [vertical, setVertical] = useState<boolean>(false);
|
||||
const widthKv = useKeyValue<number>({ key: 'body_width', defaultValue: DEFAULT });
|
||||
const heightKv = useKeyValue<number>({ key: 'body_height', defaultValue: DEFAULT });
|
||||
const width = widthKv.value ?? DEFAULT;
|
||||
const height = heightKv.value ?? DEFAULT;
|
||||
const [widthRaw, setWidth] = useLocalStorage<number>(`body_width::${useActiveWorkspaceId()}`);
|
||||
const [heightRaw, setHeight] = useLocalStorage<number>(`body_height::${useActiveWorkspaceId()}`);
|
||||
const width = widthRaw ?? DEFAULT;
|
||||
const height = heightRaw ?? DEFAULT;
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
@@ -63,8 +64,8 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
};
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => (vertical ? heightKv.set(DEFAULT) : widthKv.set(DEFAULT)),
|
||||
[heightKv, vertical, widthKv],
|
||||
() => (vertical ? setHeight(DEFAULT) : setWidth(DEFAULT)),
|
||||
[setHeight, vertical, setWidth],
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
@@ -89,7 +90,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
MIN_HEIGHT_PX,
|
||||
maxHeightPx,
|
||||
);
|
||||
heightKv.set(newHeightPx / containerRect.height);
|
||||
setHeight(newHeightPx / containerRect.height);
|
||||
} else {
|
||||
const maxWidthPx = containerRect.width - MIN_WIDTH_PX;
|
||||
const newWidthPx = clamp(
|
||||
@@ -97,7 +98,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
MIN_WIDTH_PX,
|
||||
maxWidthPx,
|
||||
);
|
||||
widthKv.set(newWidthPx / containerRect.width);
|
||||
setWidth(newWidthPx / containerRect.width);
|
||||
}
|
||||
},
|
||||
up: (e: MouseEvent) => {
|
||||
@@ -110,7 +111,7 @@ export const RequestResponse = memo(function RequestResponse({ style }: Props) {
|
||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||
setIsResizing(true);
|
||||
},
|
||||
[width, height, vertical, heightKv, widthKv],
|
||||
[width, height, vertical, setHeight, setWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,8 +30,8 @@ export function ResizeHandle({
|
||||
style={style}
|
||||
className={classnames(
|
||||
className,
|
||||
'group z-10 flex cursor-ew-resize',
|
||||
vertical ? 'w-full h-3 cursor-ns-resize' : 'h-full w-3 cursor-ew-resize',
|
||||
'group z-10 flex',
|
||||
vertical ? 'w-full h-3 cursor-row-resize' : 'h-full w-3 cursor-col-resize',
|
||||
justify === 'center' && 'justify-center',
|
||||
justify === 'end' && 'justify-end',
|
||||
justify === 'start' && 'justify-start',
|
||||
@@ -46,9 +46,9 @@ export function ResizeHandle({
|
||||
{isResizing && (
|
||||
<div
|
||||
className={classnames(
|
||||
'fixed -left-20 -right-20 -top-20 -bottom-20 cursor-ew-resize',
|
||||
vertical && 'cursor-ns-resize',
|
||||
!vertical && 'cursor-ew-resize',
|
||||
'fixed -left-20 -right-20 -top-20 -bottom-20',
|
||||
vertical && 'cursor-row-resize',
|
||||
!vertical && 'cursor-col-resize',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,13 +8,13 @@ interface Props {
|
||||
|
||||
export function ResponseHeaders({ headers }: Props) {
|
||||
return (
|
||||
<dl className="text-xs w-full font-mono">
|
||||
<dl className="text-xs w-full h-full font-mono overflow-auto">
|
||||
{headers.map((h, i) => {
|
||||
return (
|
||||
<HStack
|
||||
space={3}
|
||||
key={i}
|
||||
className={classnames(i > 0 && 'border-t border-highlightSecondary', 'py-1')}
|
||||
className={classnames(i > 0 ? 'border-t border-highlightSecondary py-1' : 'pb-1')}
|
||||
>
|
||||
<dd className="w-1/3 text-violet-600 select-text cursor-text">{h.name}</dd>
|
||||
<dt className="w-2/3 select-text cursor-text break-all">{h.value}</dt>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useResponses } from '../hooks/useResponses';
|
||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||
import { tryFormatJson } from '../lib/formatters';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
import { isResponseLoading } from '../lib/models';
|
||||
import { pluralize } from '../lib/pluralize';
|
||||
import { Banner } from './core/Banner';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
@@ -17,10 +18,12 @@ import { Editor } from './core/Editor';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { StatusColor } from './core/StatusColor';
|
||||
import { StatusTag } from './core/StatusTag';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { Webview } from './core/Webview';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { ImageView } from './ImageView';
|
||||
import { ResponseHeaders } from './ResponseHeaders';
|
||||
|
||||
interface Props {
|
||||
@@ -37,7 +40,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
const activeResponse: HttpResponse | null = pinnedResponseId
|
||||
? responses.find((r) => r.id === pinnedResponseId) ?? null
|
||||
: responses[responses.length - 1] ?? null;
|
||||
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
|
||||
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
|
||||
const [activeTab, setActiveTab] = useActiveTab();
|
||||
@@ -52,9 +55,20 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
[activeResponse],
|
||||
);
|
||||
|
||||
const tabs = useMemo(
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{ label: 'Body', value: 'body' },
|
||||
{
|
||||
value: 'body',
|
||||
label: 'Preview',
|
||||
options: {
|
||||
value: viewMode,
|
||||
onChange: setViewMode,
|
||||
items: [
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'Raw', value: 'raw' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center">
|
||||
@@ -67,9 +81,12 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
value: 'headers',
|
||||
},
|
||||
],
|
||||
[activeResponse?.headers],
|
||||
[activeResponse?.headers, setViewMode, viewMode],
|
||||
);
|
||||
|
||||
// Don't render until we know the view mode
|
||||
if (viewMode === undefined) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
@@ -80,105 +97,122 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
|
||||
)}
|
||||
>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className={classnames(
|
||||
'italic text-gray-700 text-sm w-full flex-shrink-0',
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
'-mb-1.5',
|
||||
)}
|
||||
>
|
||||
{activeResponse && (
|
||||
<>
|
||||
<div className="whitespace-nowrap p-3 py-2">
|
||||
<StatusColor statusCode={activeResponse.status}>
|
||||
{activeResponse.status}
|
||||
{activeResponse.statusReason && ` ${activeResponse.statusReason}`}
|
||||
</StatusColor>
|
||||
•
|
||||
{activeResponse.elapsed}ms •
|
||||
{Math.round(activeResponse.body.length / 1000)} KB
|
||||
</div>
|
||||
{activeResponse?.error && <Banner className="m-2">{activeResponse.error}</Banner>}
|
||||
{activeResponse && !activeResponse.error && !isResponseLoading(activeResponse) && (
|
||||
<>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className={classnames(
|
||||
'text-gray-700 text-sm w-full flex-shrink-0',
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
'-mb-1.5',
|
||||
)}
|
||||
>
|
||||
{activeResponse && (
|
||||
<HStack alignItems="center" className="w-full">
|
||||
<div className="whitespace-nowrap px-3">
|
||||
<HStack space={2}>
|
||||
<StatusTag showReason response={activeResponse} />
|
||||
{activeResponse.elapsed > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{activeResponse.elapsed}ms</span>
|
||||
</>
|
||||
)}
|
||||
{activeResponse.body.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{(activeResponse.body.length / 1000).toFixed(1)} KB</span>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
|
||||
onSelect: toggleViewMode,
|
||||
},
|
||||
{ type: 'separator', label: 'Actions' },
|
||||
{
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length <= 1,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
...responses.slice(0, 10).map((r) => ({
|
||||
label: r.status + ' - ' + r.elapsed + ' ms',
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
|
||||
onSelect: () => setPinnedResponseId(r.id),
|
||||
})),
|
||||
]}
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
key: 'clear-single',
|
||||
label: 'Clear Response',
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
key: 'clear-all',
|
||||
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length <= 1,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
...responses.slice(0, 10).map((r) => ({
|
||||
key: r.id,
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
<StatusTag className="text-xs" response={r} />
|
||||
<span>•</span> <span>{r.elapsed}ms</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
|
||||
onSelect: () => setPinnedResponseId(r.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon="triangleDown"
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChangeValue={setActiveTab}
|
||||
label="Response"
|
||||
tabs={tabs}
|
||||
className="ml-3 mr-1"
|
||||
tabListClassName="mt-1.5"
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon="triangleDown"
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{activeResponse?.error ? (
|
||||
<Banner className="m-2">{activeResponse.error}</Banner>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChangeValue={setActiveTab}
|
||||
label="Response"
|
||||
className="px-3"
|
||||
tabs={tabs}
|
||||
>
|
||||
<TabContent value="body">
|
||||
{activeResponse === null ? (
|
||||
<EmptyStateText>No Response</EmptyStateText>
|
||||
) : viewMode === 'pretty' && contentType.includes('html') ? (
|
||||
<Webview
|
||||
body={activeResponse.body}
|
||||
contentType={contentType}
|
||||
url={activeResponse.url}
|
||||
/>
|
||||
) : viewMode === 'pretty' && contentType.includes('json') ? (
|
||||
<Editor
|
||||
readOnly
|
||||
forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={tryFormatJson(activeResponse?.body)}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : activeResponse?.body ? (
|
||||
<Editor
|
||||
readOnly
|
||||
forceUpdateKey={activeResponse.updatedAt}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={activeResponse?.body}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : null}
|
||||
</TabContent>
|
||||
<TabContent value="headers">
|
||||
<ResponseHeaders headers={activeResponse?.headers ?? []} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
<TabContent value="headers">
|
||||
<ResponseHeaders headers={activeResponse?.headers ?? []} />
|
||||
</TabContent>
|
||||
<TabContent value="body">
|
||||
{!activeResponse.body ? (
|
||||
<EmptyStateText>Empty Body</EmptyStateText>
|
||||
) : viewMode === 'pretty' && contentType.includes('html') ? (
|
||||
<Webview
|
||||
body={activeResponse.body}
|
||||
contentType={contentType}
|
||||
url={activeResponse.url}
|
||||
/>
|
||||
) : viewMode === 'pretty' && contentType.includes('json') ? (
|
||||
<Editor
|
||||
readOnly
|
||||
forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={tryFormatJson(activeResponse?.body)}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : contentType.startsWith('image') ? (
|
||||
<ImageView data={activeResponse?.body} />
|
||||
) : activeResponse?.body ? (
|
||||
<Editor
|
||||
readOnly
|
||||
forceUpdateKey={activeResponse.updatedAt}
|
||||
className="bg-gray-50 dark:!bg-gray-100"
|
||||
defaultValue={activeResponse?.body}
|
||||
contentType={contentType}
|
||||
/>
|
||||
) : null}
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,9 +15,14 @@ export default function RouteError() {
|
||||
<pre className="text-sm select-auto cursor-text bg-gray-100 p-3 rounded whitespace-normal">
|
||||
{message}
|
||||
</pre>
|
||||
<Button to="/" color="primary">
|
||||
Go Home
|
||||
</Button>
|
||||
<VStack space={2}>
|
||||
<Button to="/" color="primary">
|
||||
Go Home
|
||||
</Button>
|
||||
<Button color="secondary" onClick={() => window.location.reload()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ForwardedRef, KeyboardEvent } from 'react';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { XYCoord } from 'react-dnd';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useKey, useKeyPressEvent } from 'react-use';
|
||||
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useDeleteAnyRequest } from '../hooks/useDeleteAnyRequest';
|
||||
import { useLatestResponse } from '../hooks/useLatestResponse';
|
||||
import { useRequests } from '../hooks/useRequests';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
|
||||
import { useUpdateRequest } from '../hooks/useUpdateRequest';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { Button } from './core/Button';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { isResponseLoading } from '../lib/models';
|
||||
import { Icon } from './core/Icon';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { StatusTag } from './core/StatusTag';
|
||||
import { DropMarker } from './DropMarker';
|
||||
import { RequestActionsDropdown } from './RequestActionsDropdown';
|
||||
import { ToggleThemeButton } from './ToggleThemeButton';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -24,18 +29,120 @@ enum ItemTypes {
|
||||
}
|
||||
|
||||
export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
const { hidden } = useSidebarHidden();
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const unorderedRequests = useRequests();
|
||||
const activeRequest = useActiveRequest();
|
||||
const deleteAnyRequest = useDeleteAnyRequest();
|
||||
const routes = useAppRoutes();
|
||||
const requests = useMemo(
|
||||
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
|
||||
[unorderedRequests],
|
||||
);
|
||||
const [hasFocus, setHasFocus] = useState<boolean>(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>();
|
||||
|
||||
const focusActiveRequest = useCallback(
|
||||
(forcedIndex?: number) => {
|
||||
const index = forcedIndex ?? requests.findIndex((r) => r.id === activeRequestId);
|
||||
if (index < 0) return;
|
||||
setSelectedIndex(index >= 0 ? index : undefined);
|
||||
setHasFocus(true);
|
||||
sidebarRef.current?.focus();
|
||||
},
|
||||
[activeRequestId, requests],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(requestId: string) => {
|
||||
const index = requests.findIndex((r) => r.id === requestId);
|
||||
const request = requests[index];
|
||||
if (!request) return;
|
||||
routes.navigate('request', { requestId, workspaceId: request.workspaceId });
|
||||
setSelectedIndex(index);
|
||||
focusActiveRequest(index);
|
||||
},
|
||||
[focusActiveRequest, requests, routes],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (hasFocus) return;
|
||||
focusActiveRequest(selectedIndex ?? 0);
|
||||
}, [focusActiveRequest, hasFocus, selectedIndex]);
|
||||
|
||||
const handleBlur = useCallback(() => setHasFocus(false), []);
|
||||
|
||||
const handleDeleteKey = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!hasFocus) return;
|
||||
e.preventDefault();
|
||||
|
||||
const selectedRequest = requests[selectedIndex ?? -1];
|
||||
if (selectedRequest === undefined) return;
|
||||
deleteAnyRequest.mutate(selectedRequest.id);
|
||||
},
|
||||
[deleteAnyRequest, hasFocus, requests, selectedIndex],
|
||||
);
|
||||
|
||||
useKeyPressEvent('Backspace', handleDeleteKey);
|
||||
useKeyPressEvent('Delete', handleDeleteKey);
|
||||
|
||||
useTauriEvent(
|
||||
'focus_sidebar',
|
||||
() => {
|
||||
if (hidden || hasFocus) return;
|
||||
// Select 0 index on focus if none selected
|
||||
focusActiveRequest(selectedIndex ?? 0);
|
||||
},
|
||||
[focusActiveRequest, hidden, activeRequestId],
|
||||
);
|
||||
|
||||
useKeyPressEvent('Enter', (e) => {
|
||||
if (!hasFocus) return;
|
||||
const request = requests[selectedIndex ?? -1];
|
||||
if (!request || request.id === activeRequestId) return;
|
||||
e.preventDefault();
|
||||
routes.navigate('request', { requestId: request.id, workspaceId: request.workspaceId });
|
||||
});
|
||||
|
||||
useKey(
|
||||
'ArrowUp',
|
||||
() => {
|
||||
if (!hasFocus) return;
|
||||
let newIndex = (selectedIndex ?? requests.length) - 1;
|
||||
if (newIndex < 0) {
|
||||
newIndex = requests.length - 1;
|
||||
}
|
||||
setSelectedIndex(newIndex);
|
||||
},
|
||||
undefined,
|
||||
[hasFocus, requests, selectedIndex],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowDown',
|
||||
() => {
|
||||
if (!hasFocus) return;
|
||||
let newIndex = (selectedIndex ?? -1) + 1;
|
||||
if (newIndex > requests.length - 1) {
|
||||
newIndex = 0;
|
||||
}
|
||||
setSelectedIndex(newIndex);
|
||||
},
|
||||
undefined,
|
||||
[hasFocus, requests, selectedIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div aria-hidden={hidden} className="relative h-full">
|
||||
<div
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
ref={sidebarRef}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={hidden ? -1 : 0}
|
||||
className={classnames(className, 'h-full relative grid grid-rows-[minmax(0,1fr)_auto]')}
|
||||
>
|
||||
<VStack
|
||||
@@ -43,23 +150,26 @@ export const Sidebar = memo(function Sidebar({ className }: Props) {
|
||||
className="relative py-3 overflow-y-auto overflow-x-visible"
|
||||
draggable={false}
|
||||
>
|
||||
<SidebarItems activeRequestId={activeRequest?.id} requests={requests} />
|
||||
<SidebarItems
|
||||
selectedIndex={selectedIndex}
|
||||
requests={requests}
|
||||
focused={hasFocus}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</VStack>
|
||||
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end">
|
||||
<ToggleThemeButton />
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function SidebarItems({
|
||||
requests,
|
||||
activeRequestId,
|
||||
}: {
|
||||
interface SidebarItemsProps {
|
||||
requests: HttpRequest[];
|
||||
activeRequestId?: string;
|
||||
}) {
|
||||
focused: boolean;
|
||||
selectedIndex?: number;
|
||||
onSelect: (requestId: string) => void;
|
||||
}
|
||||
|
||||
function SidebarItems({ requests, focused, selectedIndex, onSelect }: SidebarItemsProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const updateRequest = useUpdateAnyRequest();
|
||||
|
||||
@@ -110,12 +220,13 @@ function SidebarItems({
|
||||
{hoveredIndex === i && <DropMarker />}
|
||||
<DraggableSidebarItem
|
||||
key={r.id}
|
||||
selected={selectedIndex === i}
|
||||
requestId={r.id}
|
||||
requestName={r.name}
|
||||
workspaceId={r.workspaceId}
|
||||
active={r.id === activeRequestId}
|
||||
onMove={handleMove}
|
||||
onEnd={handleEnd}
|
||||
useProminentStyles={focused}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
@@ -128,16 +239,20 @@ type SidebarItemProps = {
|
||||
className?: string;
|
||||
requestId: string;
|
||||
requestName: string;
|
||||
workspaceId: string;
|
||||
active?: boolean;
|
||||
useProminentStyles?: boolean;
|
||||
selected?: boolean;
|
||||
onSelect: (requestId: string) => void;
|
||||
};
|
||||
|
||||
const _SidebarItem = forwardRef(function SidebarItem(
|
||||
{ className, requestName, requestId, workspaceId, active }: SidebarItemProps,
|
||||
{ className, requestName, requestId, useProminentStyles, selected, onSelect }: SidebarItemProps,
|
||||
ref: ForwardedRef<HTMLLIElement>,
|
||||
) {
|
||||
const latestResponse = useLatestResponse(requestId);
|
||||
const updateRequest = useUpdateRequest(requestId);
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const isActive = activeRequestId === requestId;
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async (el: HTMLInputElement) => {
|
||||
@@ -152,19 +267,9 @@ const _SidebarItem = forwardRef(function SidebarItem(
|
||||
el?.select();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLElement>) => {
|
||||
// Hitting enter on active request during keyboard nav will start edit
|
||||
if (active && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
},
|
||||
[active],
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
async (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
@@ -179,54 +284,61 @@ const _SidebarItem = forwardRef(function SidebarItem(
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleStartEditing = useCallback(() => setEditing(true), [setEditing]);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
handleSubmitNameEdit(e.currentTarget).catch(console.error);
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
onSelect(requestId);
|
||||
}, [onSelect, requestId]);
|
||||
|
||||
return (
|
||||
<li ref={ref} className={classnames(className, 'block group/item px-2 pb-0.5')}>
|
||||
<div className="relative">
|
||||
<Button
|
||||
color="custom"
|
||||
size="sm"
|
||||
to={`/workspaces/${workspaceId}/requests/${requestId}`}
|
||||
draggable={false} // Item should drag, not the link
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
onClick={active ? () => setEditing(true) : undefined}
|
||||
justify="start"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={classnames(
|
||||
editing && 'focus-within:border-focus',
|
||||
active
|
||||
? 'bg-highlight text-gray-900'
|
||||
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
|
||||
// Move out of the way when trash is shown
|
||||
'group-hover/item:pr-7',
|
||||
)}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={handleFocus}
|
||||
defaultValue={requestName}
|
||||
className="bg-transparent outline-none w-full"
|
||||
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
|
||||
{requestName || 'New Request'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<RequestActionsDropdown requestId={requestId}>
|
||||
<IconButton
|
||||
color="custom"
|
||||
size="sm"
|
||||
title="Request Options"
|
||||
icon="dotsH"
|
||||
className={classnames(
|
||||
'absolute right-0 top-0 transition-opacity !opacity-0',
|
||||
'group-hover/item:!opacity-100 focus-visible:!opacity-100',
|
||||
)}
|
||||
<button
|
||||
tabIndex={-1}
|
||||
color="custom"
|
||||
onClick={handleSelect}
|
||||
draggable={false} // Item should drag, not the link
|
||||
onDoubleClick={handleStartEditing}
|
||||
data-active={isActive}
|
||||
data-selected={selected}
|
||||
className={classnames(
|
||||
// 'outline-none',
|
||||
'w-full flex items-center text-sm h-xs px-2 rounded-md transition-colors',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
isActive && 'bg-highlight text-gray-800',
|
||||
!isActive && 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
|
||||
selected && useProminentStyles && '!bg-violet-500/20 text-gray-900',
|
||||
)}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={handleFocus}
|
||||
defaultValue={requestName}
|
||||
className="bg-transparent outline-none w-full"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
</RequestActionsDropdown>
|
||||
</div>
|
||||
) : (
|
||||
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
|
||||
{requestName || 'New Request'}
|
||||
</span>
|
||||
)}
|
||||
{latestResponse && (
|
||||
<div className="ml-auto">
|
||||
{isResponseLoading(latestResponse) ? (
|
||||
<Icon spin size="sm" icon="update" />
|
||||
) : (
|
||||
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
@@ -239,17 +351,15 @@ type DraggableSidebarItemProps = SidebarItemProps & {
|
||||
|
||||
type DragItem = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
requestName: string;
|
||||
};
|
||||
|
||||
const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
||||
requestName,
|
||||
requestId,
|
||||
workspaceId,
|
||||
active,
|
||||
onMove,
|
||||
onEnd,
|
||||
...props
|
||||
}: DraggableSidebarItemProps) {
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
|
||||
@@ -271,7 +381,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
||||
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(
|
||||
() => ({
|
||||
type: ItemTypes.REQUEST,
|
||||
item: () => ({ id: requestId, requestName, workspaceId }),
|
||||
item: () => ({ id: requestId, requestName }),
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
options: { dropEffect: 'move' },
|
||||
end: () => onEnd(requestId),
|
||||
@@ -288,8 +398,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
||||
className={classnames(isDragging && 'opacity-20')}
|
||||
requestName={requestName}
|
||||
requestId={requestId}
|
||||
workspaceId={workspaceId}
|
||||
active={active}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useTauriEvent } from '../hooks/useTauriEvent';
|
||||
import { IconButton } from './core/IconButton';
|
||||
|
||||
export const SidebarActions = memo(function SidebarDisplayToggle() {
|
||||
export const SidebarActions = memo(function SidebarActions() {
|
||||
const { hidden, toggle } = useSidebarHidden();
|
||||
const createRequest = useCreateRequest({ navigateAfter: true });
|
||||
|
||||
const handleCreateRequest = useCallback(() => {
|
||||
createRequest.mutate({ name: 'New Request' });
|
||||
createRequest.mutate({});
|
||||
}, [createRequest]);
|
||||
|
||||
useTauriEvent('new_request', () => {
|
||||
createRequest.mutate({});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { IconButton } from './core/IconButton';
|
||||
|
||||
export function ToggleThemeButton() {
|
||||
const { appearance, toggleAppearance } = useTheme();
|
||||
return (
|
||||
<IconButton
|
||||
title={appearance === 'dark' ? 'Enable light mode' : 'Enable dark mode'}
|
||||
icon={appearance === 'dark' ? 'moon' : 'sun'}
|
||||
onClick={toggleAppearance}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const body = { gridArea: 'body' };
|
||||
const drag = { gridArea: 'drag' };
|
||||
|
||||
export default function Workspace() {
|
||||
const { set: setWidth, value: width, reset: resetWidth } = useSidebarWidth();
|
||||
const { setWidth, width, resetWidth } = useSidebarWidth();
|
||||
const { show, hide, hidden, toggle } = useSidebarHidden();
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
@@ -119,13 +119,6 @@ export default function Workspace() {
|
||||
!isResizing && 'transition-all',
|
||||
)}
|
||||
>
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
|
||||
style={head}
|
||||
>
|
||||
<WorkspaceHeader className="pointer-events-none" />
|
||||
</HeaderSize>
|
||||
{floating ? (
|
||||
<Overlay open={!hidden} portalName="sidebar" onClose={hide}>
|
||||
<motion.div
|
||||
@@ -146,11 +139,8 @@ export default function Workspace() {
|
||||
</Overlay>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={side}
|
||||
className={classnames('overflow-hidden bg-gray-100 border-r border-highlight')}
|
||||
>
|
||||
<Sidebar />
|
||||
<div style={side} className={classnames('overflow-hidden bg-gray-100')}>
|
||||
<Sidebar className="border-r border-highlight" />
|
||||
</div>
|
||||
<ResizeHandle
|
||||
className="-translate-x-3"
|
||||
@@ -162,6 +152,13 @@ export default function Workspace() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
className="w-full bg-gray-50 border-b border-b-highlight text-gray-900"
|
||||
style={head}
|
||||
>
|
||||
<WorkspaceHeader className="pointer-events-none" />
|
||||
</HeaderSize>
|
||||
<RequestResponse style={body} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import classnames from 'classnames';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
|
||||
import { useRoutes } from '../hooks/useRoutes';
|
||||
import { usePrompt } from '../hooks/usePrompt';
|
||||
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { useDialog } from './DialogContext';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
@@ -21,58 +25,125 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
|
||||
const activeWorkspace = useActiveWorkspace();
|
||||
const activeWorkspaceId = activeWorkspace?.id ?? null;
|
||||
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
|
||||
const deleteWorkspace = useDeleteWorkspace(activeWorkspaceId);
|
||||
const routes = useRoutes();
|
||||
const confirm = useConfirm();
|
||||
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
|
||||
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
|
||||
const dialog = useDialog();
|
||||
const prompt = usePrompt();
|
||||
const routes = useAppRoutes();
|
||||
|
||||
const items: DropdownItem[] = useMemo(() => {
|
||||
const workspaceItems = workspaces.map((w) => ({
|
||||
key: w.id,
|
||||
label: w.name,
|
||||
leftSlot: activeWorkspaceId === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => {
|
||||
if (w.id === activeWorkspaceId) return;
|
||||
routes.navigate('workspace', { workspaceId: w.id });
|
||||
onSelect: async () => {
|
||||
dialog.show({
|
||||
id: 'open-workspace',
|
||||
size: 'sm',
|
||||
title: 'Open Workspace',
|
||||
description: (
|
||||
<>
|
||||
Where would you like to open <InlineCode>{w.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
render: ({ hide }) => {
|
||||
return (
|
||||
<HStack space={2} justifyContent="end" className="mt-6">
|
||||
<Button
|
||||
className="focus"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
hide();
|
||||
routes.navigate('workspace', { workspaceId: w.id });
|
||||
}}
|
||||
>
|
||||
This Window
|
||||
</Button>
|
||||
<Button
|
||||
autoFocus
|
||||
className="focus"
|
||||
color="gray"
|
||||
rightSlot={<Icon icon="openNewWindow" />}
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await invoke('new_window', {
|
||||
url: routes.paths.workspace({ workspaceId: w.id }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
New Window
|
||||
</Button>
|
||||
</HStack>
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const activeWorkspaceItems: DropdownItem[] =
|
||||
workspaces.length <= 1
|
||||
? []
|
||||
: [
|
||||
...workspaceItems,
|
||||
{
|
||||
type: 'separator',
|
||||
label: activeWorkspace?.name,
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
...workspaceItems,
|
||||
...activeWorkspaceItems,
|
||||
{
|
||||
type: 'separator',
|
||||
label: 'Actions',
|
||||
},
|
||||
{
|
||||
label: 'New Workspace',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
|
||||
},
|
||||
{
|
||||
label: 'Delete Workspace',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
onSelect: async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Workspace',
|
||||
variant: 'delete',
|
||||
const name = await prompt({
|
||||
title: 'Rename Workspace',
|
||||
description: (
|
||||
<>
|
||||
Are you sure you want to delete <InlineCode>{activeWorkspace?.name}</InlineCode>?
|
||||
Enter a new name for <InlineCode>{activeWorkspace?.name}</InlineCode>
|
||||
</>
|
||||
),
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
defaultValue: activeWorkspace?.name,
|
||||
});
|
||||
if (confirmed) {
|
||||
deleteWorkspace.mutate();
|
||||
}
|
||||
updateWorkspace.mutate({ name });
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: deleteWorkspace.mutate,
|
||||
variant: 'danger',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
key: 'create-workspace',
|
||||
label: 'Create Workspace',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: async () => {
|
||||
const name = await prompt({
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
defaultValue: '',
|
||||
description: 'Enter a name for the new workspace',
|
||||
title: 'Create Workspace',
|
||||
});
|
||||
createWorkspace.mutate({ name });
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [
|
||||
workspaces,
|
||||
deleteWorkspace.mutate,
|
||||
activeWorkspaceId,
|
||||
routes,
|
||||
createWorkspace,
|
||||
confirm,
|
||||
prompt,
|
||||
activeWorkspace?.name,
|
||||
deleteWorkspace,
|
||||
updateWorkspace,
|
||||
createWorkspace,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { memo } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
|
||||
import { RequestActionsDropdown } from './RequestActionsDropdown';
|
||||
import { SidebarActions } from './SidebarActions';
|
||||
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
|
||||
@@ -13,6 +14,7 @@ interface Props {
|
||||
|
||||
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
|
||||
const activeRequest = useActiveRequest();
|
||||
|
||||
return (
|
||||
<HStack
|
||||
justifyContent="center"
|
||||
@@ -23,8 +25,8 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
|
||||
<SidebarActions />
|
||||
<WorkspaceActionsDropdown className="pointer-events-auto" />
|
||||
</HStack>
|
||||
<div className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
|
||||
{activeRequest?.name}
|
||||
<div className="pointer-events-none">
|
||||
<RecentRequestsDropdown />
|
||||
</div>
|
||||
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
|
||||
{activeRequest && (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useRoutes } from '../hooks/useRoutes';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { Heading } from './core/Heading';
|
||||
|
||||
export default function Workspaces() {
|
||||
const routes = useRoutes();
|
||||
const routes = useAppRoutes();
|
||||
const workspaces = useWorkspaces();
|
||||
const workspace = workspaces[0];
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import classnames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, memo, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const colorStyles = {
|
||||
@@ -15,8 +14,7 @@ const colorStyles = {
|
||||
danger: 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
|
||||
};
|
||||
|
||||
export type ButtonProps = HTMLAttributes<HTMLElement> & {
|
||||
to?: string;
|
||||
export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
color?: keyof typeof colorStyles;
|
||||
isLoading?: boolean;
|
||||
size?: 'sm' | 'md' | 'xs';
|
||||
@@ -25,19 +23,22 @@ export type ButtonProps = HTMLAttributes<HTMLElement> & {
|
||||
forDropdown?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const _Button = forwardRef<any, ButtonProps>(function Button(
|
||||
const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
to,
|
||||
isLoading,
|
||||
className,
|
||||
children,
|
||||
forDropdown,
|
||||
color,
|
||||
type = 'button',
|
||||
justify = 'center',
|
||||
size = 'md',
|
||||
rightSlot,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref,
|
||||
@@ -46,10 +47,10 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
|
||||
() =>
|
||||
classnames(
|
||||
className,
|
||||
'opacity-90 hover:opacity-100',
|
||||
'outline-none whitespace-nowrap',
|
||||
'flex-shrink-0 outline-none whitespace-nowrap',
|
||||
'focus-visible-or-class:ring',
|
||||
'rounded-md flex items-center',
|
||||
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
|
||||
colorStyles[color || 'default'],
|
||||
justify === 'start' && 'justify-start',
|
||||
justify === 'center' && 'justify-center',
|
||||
@@ -57,25 +58,17 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
|
||||
size === 'sm' && 'h-sm px-2.5 text-sm',
|
||||
size === 'xs' && 'h-xs px-2 text-sm',
|
||||
),
|
||||
[color, size, justify, className],
|
||||
[className, disabled, color, justify, size],
|
||||
);
|
||||
|
||||
if (typeof to === 'string') {
|
||||
return (
|
||||
<Link ref={ref} to={to} className={classes} {...props}>
|
||||
{children}
|
||||
{forDropdown && <Icon icon="chevronDown" className="ml-1 -mr-1" />}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<button ref={ref} className={classes} {...props}>
|
||||
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
|
||||
{children}
|
||||
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button ref={ref} type={type} className={classes} disabled={disabled} {...props}>
|
||||
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
|
||||
{children}
|
||||
{rightSlot && <div className="ml-1">{rightSlot}</div>}
|
||||
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export const Button = memo(_Button);
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import classnames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CountBadge({ count }: Props) {
|
||||
export function CountBadge({ count, className }: Props) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono"
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
</>
|
||||
<div
|
||||
aria-hidden
|
||||
className={classnames(
|
||||
className,
|
||||
'opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,13 @@ export function Dialog({
|
||||
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
|
||||
)}
|
||||
>
|
||||
<Heading className="text-xl font-semibold w-full" id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
{description && <p id={descriptionId}>{description}</p>}
|
||||
<div className="mt-4">{children}</div>
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!hideX && (
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
@@ -72,11 +79,6 @@ export function Dialog({
|
||||
className="ml-auto absolute right-1 top-1"
|
||||
/>
|
||||
)}
|
||||
<Heading className="text-xl font-semibold w-full" id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
{description && <p id={descriptionId}>{description}</p>}
|
||||
<div className="mt-6">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,20 @@ import classnames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
|
||||
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import React, {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKey, useKeyPressEvent } from 'react-use';
|
||||
import { Portal } from '../Portal';
|
||||
import { Button } from './Button';
|
||||
import { Separator } from './Separator';
|
||||
import { VStack } from './Stacks';
|
||||
|
||||
@@ -15,8 +26,10 @@ export type DropdownItemSeparator = {
|
||||
|
||||
export type DropdownItem =
|
||||
| {
|
||||
key: string;
|
||||
type?: 'default';
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
variant?: 'danger';
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
@@ -30,21 +43,52 @@ export interface DropdownProps {
|
||||
items: DropdownItem[];
|
||||
}
|
||||
|
||||
export function Dropdown({ children, items }: DropdownProps) {
|
||||
export interface DropdownRef {
|
||||
isOpen: boolean;
|
||||
open: (activeIndex?: number) => void;
|
||||
toggle: () => void;
|
||||
close?: () => void;
|
||||
next?: () => void;
|
||||
prev?: () => void;
|
||||
select?: () => void;
|
||||
}
|
||||
|
||||
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
|
||||
{ children, items }: DropdownProps,
|
||||
ref,
|
||||
) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
...menuRef.current,
|
||||
isOpen: open,
|
||||
toggle: () => setOpen(!open),
|
||||
open: (activeIndex?: number) => {
|
||||
if (activeIndex === undefined) {
|
||||
setDefaultSelectedIndex(undefined);
|
||||
} else {
|
||||
setDefaultSelectedIndex(activeIndex >= 0 ? activeIndex : items.length + activeIndex);
|
||||
}
|
||||
setOpen(true);
|
||||
},
|
||||
}));
|
||||
|
||||
const child = useMemo(() => {
|
||||
const existingChild = Children.only(children);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const props: any = {
|
||||
...existingChild.props,
|
||||
ref,
|
||||
ref: buttonRef,
|
||||
'aria-haspopup': 'true',
|
||||
onClick:
|
||||
existingChild.props?.onClick ??
|
||||
((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDefaultSelectedIndex(undefined);
|
||||
setOpen((o) => !o);
|
||||
}),
|
||||
};
|
||||
@@ -53,37 +97,48 @@ export function Dropdown({ children, items }: DropdownProps) {
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
ref.current?.focus();
|
||||
buttonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.setAttribute('aria-expanded', open.toString());
|
||||
buttonRef.current?.setAttribute('aria-expanded', open.toString());
|
||||
}, [open]);
|
||||
|
||||
const triggerRect = useMemo(() => {
|
||||
if (!open) return null;
|
||||
return ref.current?.getBoundingClientRect();
|
||||
return buttonRef.current?.getBoundingClientRect();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{child}
|
||||
{open && triggerRect && (
|
||||
<Menu items={items} triggerRect={triggerRect} onClose={handleClose} />
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
defaultSelectedIndex={defaultSelectedIndex}
|
||||
items={items}
|
||||
triggerRect={triggerRect}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface MenuProps {
|
||||
className?: string;
|
||||
defaultSelectedIndex?: number;
|
||||
items: DropdownProps['items'];
|
||||
triggerRect: DOMRect;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu(
|
||||
{ className, items, onClose, triggerRect, defaultSelectedIndex }: MenuProps,
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(defaultSelectedIndex ?? null);
|
||||
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
|
||||
|
||||
// Calculate the max height so we can scroll
|
||||
@@ -94,13 +149,23 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 });
|
||||
}, []);
|
||||
|
||||
// Close menu on space bar
|
||||
const handleMenuKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useKeyPressEvent('Escape', (e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
});
|
||||
|
||||
useKeyPressEvent('ArrowUp', (e) => {
|
||||
e.preventDefault();
|
||||
const handlePrev = useCallback(() => {
|
||||
setSelectedIndex((currIndex) => {
|
||||
let nextIndex = (currIndex ?? 0) - 1;
|
||||
const maxTries = items.length;
|
||||
@@ -115,10 +180,9 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
useKeyPressEvent('ArrowDown', (e) => {
|
||||
e.preventDefault();
|
||||
const handleNext = useCallback(() => {
|
||||
setSelectedIndex((currIndex) => {
|
||||
let nextIndex = (currIndex ?? -1) + 1;
|
||||
const maxTries = items.length;
|
||||
@@ -133,8 +197,44 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
useKey('ArrowUp', (e) => {
|
||||
e.preventDefault();
|
||||
handlePrev();
|
||||
});
|
||||
|
||||
useKey('ArrowDown', (e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
});
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
onClose();
|
||||
setSelectedIndex(null);
|
||||
if (i.type !== 'separator') {
|
||||
i.onSelect?.();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
close: onClose,
|
||||
prev: handlePrev,
|
||||
next: handleNext,
|
||||
select: () => {
|
||||
const item = items[selectedIndex ?? -1] ?? null;
|
||||
if (!item) return;
|
||||
handleSelect(item);
|
||||
},
|
||||
}),
|
||||
[handleNext, handlePrev, handleSelect, items, onClose, selectedIndex],
|
||||
);
|
||||
|
||||
const { containerStyles, triangleStyles } = useMemo<{
|
||||
containerStyles: CSSProperties;
|
||||
triangleStyles: CSSProperties;
|
||||
@@ -153,17 +253,6 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
return { containerStyles, triangleStyles };
|
||||
}, [triggerRect]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
onClose();
|
||||
setSelectedIndex(null);
|
||||
if (i.type !== 'separator') {
|
||||
i.onSelect?.();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
const index = items.findIndex((item) => item === i) ?? null;
|
||||
@@ -172,7 +261,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
[items],
|
||||
);
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Portal name="dropdown">
|
||||
@@ -181,6 +270,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
@@ -218,7 +308,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
key={i + item.label}
|
||||
key={item.key}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
@@ -230,7 +320,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
</FocusTrap>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface MenuItemProps {
|
||||
className?: string;
|
||||
@@ -257,23 +347,33 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
||||
if (item.type === 'separator') return <Separator className="my-1.5" />;
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
ref={initRef}
|
||||
size="xs"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={(e) => e.currentTarget.focus()}
|
||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
justify="start"
|
||||
className={classnames(
|
||||
className,
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 h-7 flex items-center text-sm text-gray-700 whitespace-nowrap pr-4',
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
|
||||
'focus:bg-highlight focus:text-gray-900 rounded',
|
||||
item.variant === 'danger' && 'text-red-600',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{item.leftSlot && <div className="w-6">{item.leftSlot}</div>}
|
||||
<div>{item.label}</div>
|
||||
{item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
|
||||
<div
|
||||
className={classnames(
|
||||
// Add padding on right when no right slot, for some visual balance
|
||||
!item.rightSlot && 'pr-4',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
/* Style gutters */
|
||||
.cm-gutters {
|
||||
@apply border-0 text-gray-500/50 opacity-95;
|
||||
@apply border-0 text-gray-500/50;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
@@ -183,20 +183,16 @@
|
||||
@apply bg-highlight text-gray-900;
|
||||
}
|
||||
|
||||
& > ul > li:hover {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5;
|
||||
@apply text-sm flex items-center pb-0.5 flex-shrink-0;
|
||||
}
|
||||
|
||||
|
||||
.cm-completionLabel {
|
||||
@apply text-gray-700;
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@apply ml-auto;
|
||||
@apply ml-auto pl-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface EditorProps {
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
singleLine?: boolean;
|
||||
wrapLines?: boolean;
|
||||
format?: (v: string) => string;
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
actions?: ReactNode;
|
||||
@@ -59,6 +60,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
format,
|
||||
autocomplete,
|
||||
actions,
|
||||
wrapLines,
|
||||
}: EditorProps,
|
||||
ref,
|
||||
) {
|
||||
@@ -93,6 +95,15 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
cm.current?.view.dispatch({ effects: effect });
|
||||
}, [placeholder]);
|
||||
|
||||
// Update wrap lines
|
||||
const wrapLinesCompartment = useRef(new Compartment());
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const ext = wrapLines ? [EditorView.lineWrapping] : [];
|
||||
const effect = wrapLinesCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects: effect });
|
||||
}, [wrapLines]);
|
||||
|
||||
// Update language extension when contentType changes
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
@@ -126,16 +137,15 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
doc: `${defaultValue ?? ''}`,
|
||||
extensions: [
|
||||
languageCompartment.of(langExt),
|
||||
placeholderCompartment.current.of(
|
||||
placeholderExt(placeholderElFromText(placeholder ?? '')),
|
||||
),
|
||||
placeholderCompartment.current.of([]),
|
||||
wrapLinesCompartment.current.of([]),
|
||||
...getExtensions({
|
||||
container,
|
||||
readOnly,
|
||||
singleLine,
|
||||
onChange: handleChange,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
readOnly,
|
||||
singleLine,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -3,18 +3,8 @@ import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
const openTag = '${[ ';
|
||||
const closeTag = ' ]}';
|
||||
|
||||
const variables = [
|
||||
{ name: 'DOMAIN' },
|
||||
{ name: 'BASE_URL' },
|
||||
{ name: 'CONTENT_THINGY' },
|
||||
{ name: 'TOKEN' },
|
||||
{ name: 'PROJECT_ID' },
|
||||
{ name: 'DUMMY' },
|
||||
{ name: 'DUMMY_2' },
|
||||
{ name: 'STRIPE_PUB_KEY' },
|
||||
{ name: 'RAILWAY_TOKEN' },
|
||||
{ name: 'SECRET' },
|
||||
{ name: 'PORT' },
|
||||
const variables: { name: string }[] = [
|
||||
// TODO: Put variables here
|
||||
];
|
||||
|
||||
const MIN_MATCH_VAR = 2;
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import classnames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export function HotKey({ children }: HTMLAttributes<HTMLSpanElement>) {
|
||||
interface Props {
|
||||
modifier: 'Meta' | 'Control' | 'Shift';
|
||||
keyName: string;
|
||||
}
|
||||
|
||||
const keys: Record<Props['modifier'], string> = {
|
||||
Control: '⌃',
|
||||
Meta: '⌘',
|
||||
Shift: '⇧',
|
||||
};
|
||||
|
||||
export function HotKey({ modifier, keyName }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={classnames(
|
||||
'bg-gray-400 bg-opacity-20 px-1.5 py-0.5 rounded text-sm',
|
||||
'font-mono text-gray-500 tracking-widest',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span className={classnames('text-sm text-gray-600')}>
|
||||
{keys[modifier]}
|
||||
{keyName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
MagicWandIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MoonIcon,
|
||||
OpenInNewWindowIcon,
|
||||
PaperPlaneIcon,
|
||||
Pencil2Icon,
|
||||
PlusCircledIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkIcon,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
UpdateIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import classnames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPanelHiddenIcon.svg';
|
||||
import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg';
|
||||
@@ -64,7 +67,9 @@ const icons = {
|
||||
magicWand: MagicWandIcon,
|
||||
magnifyingGlass: MagnifyingGlassIcon,
|
||||
moon: MoonIcon,
|
||||
openNewWindow: OpenInNewWindowIcon,
|
||||
paperPlane: PaperPlaneIcon,
|
||||
pencil: Pencil2Icon,
|
||||
plus: PlusIcon,
|
||||
plusCircle: PlusCircledIcon,
|
||||
question: QuestionMarkIcon,
|
||||
@@ -76,7 +81,7 @@ const icons = {
|
||||
triangleRight: TriangleRightIcon,
|
||||
update: UpdateIcon,
|
||||
x: Cross2Icon,
|
||||
empty: () => <span />,
|
||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import classnames from 'classnames';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { forwardRef, memo, useCallback } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { useTimedBoolean } from '../../hooks/useTimedBoolean';
|
||||
import type { ButtonProps } from './Button';
|
||||
import { Button } from './Button';
|
||||
@@ -15,7 +15,7 @@ type Props = IconProps &
|
||||
title: string;
|
||||
};
|
||||
|
||||
const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{
|
||||
showConfirm,
|
||||
icon,
|
||||
@@ -69,5 +69,3 @@ const _IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
export const IconButton = memo(_IconButton);
|
||||
|
||||
@@ -57,12 +57,12 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const handleOnFocus = useCallback(() => {
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
onFocus?.();
|
||||
}, [onFocus]);
|
||||
|
||||
const handleOnBlur = useCallback(() => {
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
@@ -107,8 +107,8 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
className={classnames(
|
||||
containerClassName,
|
||||
'relative w-full rounded-md text-gray-900',
|
||||
'border border-highlight',
|
||||
focused && 'border-focus',
|
||||
'border',
|
||||
focused ? 'border-focus' : 'border-highlight',
|
||||
!isValid && '!border-invalid',
|
||||
size === 'md' && 'h-md leading-md',
|
||||
size === 'sm' && 'h-sm leading-sm',
|
||||
@@ -125,8 +125,8 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
className={inputClassName}
|
||||
onFocus={handleOnFocus}
|
||||
onBlur={handleOnBlur}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
{type === 'password' && (
|
||||
|
||||
@@ -137,7 +137,7 @@ export const PairEditor = memo(function PairEditor({
|
||||
className={classnames(
|
||||
className,
|
||||
'@container',
|
||||
'pb-2 grid',
|
||||
'pb-2 grid overflow-auto max-h-full',
|
||||
// Move over the width of the drag handle
|
||||
'-ml-3',
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,7 @@ export function RadioDropdown<T = string | null>({
|
||||
return item;
|
||||
} else {
|
||||
return {
|
||||
key: item.label,
|
||||
label: item.label,
|
||||
shortLabel: item.shortLabel,
|
||||
onSelect: () => onChange(item.value),
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import classnames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
statusCode: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StatusColor({ statusCode, children }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={classnames(
|
||||
statusCode >= 100 && statusCode < 200 && 'text-green-600',
|
||||
statusCode >= 200 && statusCode < 300 && 'text-green-600',
|
||||
statusCode >= 300 && statusCode < 400 && 'text-pink-600',
|
||||
statusCode >= 400 && statusCode < 500 && 'text-orange-600',
|
||||
statusCode >= 500 && statusCode < 600 && 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
29
src-web/components/core/StatusTag.tsx
Normal file
29
src-web/components/core/StatusTag.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import classnames from 'classnames';
|
||||
import type { HttpResponse } from '../../lib/models';
|
||||
|
||||
interface Props {
|
||||
response: Pick<HttpResponse, 'status' | 'statusReason' | 'error'>;
|
||||
className?: string;
|
||||
showReason?: boolean;
|
||||
}
|
||||
|
||||
export function StatusTag({ response, className, showReason }: Props) {
|
||||
const { status, error } = response;
|
||||
const label = error ? 'ERR' : status;
|
||||
return (
|
||||
<span
|
||||
className={classnames(
|
||||
className,
|
||||
'font-mono',
|
||||
status >= 0 && status < 100 && 'text-red-600',
|
||||
status >= 100 && status < 200 && 'text-green-600',
|
||||
status >= 200 && status < 300 && 'text-green-600',
|
||||
status >= 300 && status < 400 && 'text-pink-600',
|
||||
status >= 400 && status < 500 && 'text-orange-600',
|
||||
status >= 500 && 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{label} {showReason && response.statusReason && response.statusReason}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -73,16 +73,18 @@ export function Tabs({
|
||||
aria-label={label}
|
||||
className={classnames(
|
||||
tabListClassName,
|
||||
'flex items-center overflow-x-auto hide-scrollbars mt-1 mb-2',
|
||||
'flex items-center overflow-x-auto overflow-y-visible hide-scrollbars mt-1 mb-2',
|
||||
// Give space for button focus states within overflow boundary.
|
||||
'px-2 -mx-2',
|
||||
'-mx-5 pl-3 py-1',
|
||||
)}
|
||||
>
|
||||
<HStack space={1} className="flex-shrink-0">
|
||||
<HStack space={2} className="flex-shrink-0">
|
||||
{tabs.map((t) => {
|
||||
const isActive = t.value === value;
|
||||
// const btnClassName = classnames(isActive ? 'bg-highlightSecondary' : 'text-gray-600');
|
||||
const btnClassName = classnames(isActive ? '' : 'text-gray-600', '!px-0 mr-4 ml-[1px]');
|
||||
const btnClassName = classnames(
|
||||
isActive ? '' : 'text-gray-600 hover:text-gray-800',
|
||||
'!px-2 ml-[1px]',
|
||||
);
|
||||
|
||||
if ('options' in t) {
|
||||
const option = t.options.items.find(
|
||||
@@ -147,7 +149,7 @@ export const TabContent = memo(function TabContent({
|
||||
<div
|
||||
tabIndex={-1}
|
||||
data-tab={value}
|
||||
className={classnames(className, 'tab-content', 'overflow-auto hidden w-full h-full')}
|
||||
className={classnames(className, 'tab-content', 'hidden w-full h-full')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -16,12 +16,12 @@ export function Webview({ body, url, contentType }: Props) {
|
||||
}, [url, body, contentType]);
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<div className="h-full pb-3">
|
||||
<iframe
|
||||
title="Response preview"
|
||||
srcDoc={contentForIframe}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="h-full w-full rounded-md border border-gray-100/20"
|
||||
className="h-full w-full rounded border border-highlightSecondary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from '../components/core/Button';
|
||||
import { HStack } from '../components/core/Stacks';
|
||||
|
||||
export interface ConfirmProps {
|
||||
hide: () => void;
|
||||
onHide: () => void;
|
||||
onResult: (result: boolean) => void;
|
||||
variant?: 'delete' | 'confirm';
|
||||
}
|
||||
@@ -18,29 +18,23 @@ const confirmButtonTexts: Record<NonNullable<ConfirmProps['variant']>, string> =
|
||||
confirm: 'Confirm',
|
||||
};
|
||||
|
||||
export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) {
|
||||
const focusRef = (el: HTMLButtonElement | null) => {
|
||||
setTimeout(() => {
|
||||
el?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
export function Confirm({ onHide, onResult, variant = 'confirm' }: ConfirmProps) {
|
||||
const handleHide = () => {
|
||||
onResult(false);
|
||||
hide();
|
||||
onHide();
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
onResult(true);
|
||||
hide();
|
||||
onHide();
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack space={2} justifyContent="end">
|
||||
<HStack space={2} justifyContent="end" className="mt-6">
|
||||
<Button className="focus" color="gray" onClick={handleHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="focus" ref={focusRef} color={colors[variant]} onClick={handleSuccess}>
|
||||
<Button autoFocus className="focus" color={colors[variant]} onClick={handleSuccess}>
|
||||
{confirmButtonTexts[variant]}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
48
src-web/hooks/Prompt.tsx
Normal file
48
src-web/hooks/Prompt.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '../components/core/Button';
|
||||
import type { InputProps } from '../components/core/Input';
|
||||
import { Input } from '../components/core/Input';
|
||||
import { HStack, VStack } from '../components/core/Stacks';
|
||||
|
||||
export interface PromptProps {
|
||||
onHide: () => void;
|
||||
onResult: (value: string) => void;
|
||||
label: InputProps['label'];
|
||||
name: InputProps['name'];
|
||||
defaultValue: InputProps['defaultValue'];
|
||||
}
|
||||
|
||||
export function Prompt({ onHide, label, name, defaultValue, onResult }: PromptProps) {
|
||||
const [value, setValue] = useState<string>(defaultValue ?? '');
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
onHide();
|
||||
onResult(value);
|
||||
},
|
||||
[onHide, onResult, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack space={6}>
|
||||
<Input
|
||||
hideLabel
|
||||
label={label}
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button className="focus" color="gray" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="focus" color="primary">
|
||||
Save
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { RouteParamsRequest } from './useRoutes';
|
||||
import type { RouteParamsRequest } from './useAppRoutes';
|
||||
|
||||
export function useActiveRequestId(): string | null {
|
||||
const { requestId } = useParams<RouteParamsRequest>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { RouteParamsWorkspace } from './useRoutes';
|
||||
import type { RouteParamsWorkspace } from './useAppRoutes';
|
||||
|
||||
export function useActiveWorkspaceId(): string | null {
|
||||
const { workspaceId } = useParams<RouteParamsWorkspace>();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export type RouteParamsWorkspace = {
|
||||
@@ -25,19 +26,22 @@ export const routePaths = {
|
||||
},
|
||||
};
|
||||
|
||||
export function useRoutes() {
|
||||
export function useAppRoutes() {
|
||||
const navigate = useNavigate();
|
||||
return {
|
||||
navigate<T extends keyof typeof routePaths>(
|
||||
path: T,
|
||||
...params: Parameters<(typeof routePaths)[T]>
|
||||
) {
|
||||
// Not sure how to make TS work here, but it's good from the
|
||||
// outside caller perspective.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolvedPath = routePaths[path](...(params as any));
|
||||
navigate(resolvedPath);
|
||||
},
|
||||
paths: routePaths,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
navigate<T extends keyof typeof routePaths>(
|
||||
path: T,
|
||||
...params: Parameters<(typeof routePaths)[T]>
|
||||
) {
|
||||
// Not sure how to make TS work here, but it's good from the
|
||||
// outside caller perspective.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolvedPath = routePaths[path](...(params as any));
|
||||
navigate(resolvedPath);
|
||||
},
|
||||
paths: routePaths,
|
||||
}),
|
||||
[navigate],
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function useConfirm() {
|
||||
description,
|
||||
hideX: true,
|
||||
size: 'sm',
|
||||
render: ({ hide }) => Confirm({ hide, variant, onResult }),
|
||||
render: ({ hide }) => Confirm({ onHide: hide, variant, onResult }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useAppRoutes } from './useAppRoutes';
|
||||
import { requestsQueryKey, useRequests } from './useRequests';
|
||||
import { useRoutes } from './useRoutes';
|
||||
|
||||
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
|
||||
const workspaceId = useActiveWorkspaceId();
|
||||
const routes = useRoutes();
|
||||
const routes = useAppRoutes();
|
||||
const requests = useRequests();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -16,8 +16,9 @@ export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean })
|
||||
if (workspaceId === null) {
|
||||
throw new Error("Cannot create request when there's no active workspace");
|
||||
}
|
||||
const sortPriority = maxSortPriority(requests) + 1000;
|
||||
return invoke('create_request', { sortPriority, workspaceId, ...patch });
|
||||
patch.name = patch.name || 'New Request';
|
||||
patch.sortPriority = patch.sortPriority || maxSortPriority(requests) + 1000;
|
||||
return invoke('create_request', { workspaceId, ...patch });
|
||||
},
|
||||
onSuccess: async (request) => {
|
||||
queryClient.setQueryData<HttpRequest[]>(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { Workspace } from '../lib/models';
|
||||
import { useRoutes } from './useRoutes';
|
||||
import { useAppRoutes } from './useAppRoutes';
|
||||
import { workspacesQueryKey } from './useWorkspaces';
|
||||
|
||||
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
|
||||
const routes = useRoutes();
|
||||
const routes = useAppRoutes();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
|
||||
mutationFn: (patch) => {
|
||||
|
||||
40
src-web/hooks/useDeleteAnyRequest.tsx
Normal file
40
src-web/hooks/useDeleteAnyRequest.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { getRequest } from '../lib/store';
|
||||
import { useConfirm } from './useConfirm';
|
||||
import { requestsQueryKey } from './useRequests';
|
||||
import { responsesQueryKey } from './useResponses';
|
||||
|
||||
export function useDeleteAnyRequest() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
return useMutation<HttpRequest | null, string, string>({
|
||||
mutationFn: async (id) => {
|
||||
const request = await getRequest(id);
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Request',
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Permanently delete <InlineCode>{request?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (!confirmed) return null;
|
||||
return invoke('delete_request', { requestId: id });
|
||||
},
|
||||
onSuccess: async (request) => {
|
||||
// Was it cancelled?
|
||||
if (request === null) return;
|
||||
|
||||
const { workspaceId, id: requestId } = request;
|
||||
queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted
|
||||
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
|
||||
(requests ?? []).filter((r) => r.id !== requestId),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { useActiveRequestId } from './useActiveRequestId';
|
||||
import { requestsQueryKey } from './useRequests';
|
||||
import { responsesQueryKey } from './useResponses';
|
||||
import { useRoutes } from './useRoutes';
|
||||
|
||||
export function useDeleteRequest(id: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const routes = useRoutes();
|
||||
return useMutation<HttpRequest, string>({
|
||||
mutationFn: async () => invoke('delete_request', { requestId: id }),
|
||||
onSuccess: async ({ workspaceId, id: requestId }) => {
|
||||
queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted
|
||||
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
|
||||
(requests ?? []).filter((r) => r.id !== requestId),
|
||||
);
|
||||
if (activeRequestId === requestId) {
|
||||
routes.navigate('workspace', { workspaceId });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
40
src-web/hooks/useDeleteRequest.tsx
Normal file
40
src-web/hooks/useDeleteRequest.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { getRequest } from '../lib/store';
|
||||
import { useConfirm } from './useConfirm';
|
||||
import { requestsQueryKey } from './useRequests';
|
||||
import { responsesQueryKey } from './useResponses';
|
||||
|
||||
export function useDeleteRequest(id: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
return useMutation<HttpRequest | null, string>({
|
||||
mutationFn: async () => {
|
||||
const request = await getRequest(id);
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Request',
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Permanently delete <InlineCode>{request?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (!confirmed) return null;
|
||||
return invoke('delete_request', { requestId: id });
|
||||
},
|
||||
onSuccess: async (request) => {
|
||||
// Was it cancelled?
|
||||
if (request === null) return;
|
||||
|
||||
const { workspaceId, id: requestId } = request;
|
||||
queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted
|
||||
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
|
||||
(requests ?? []).filter((r) => r.id !== requestId),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,20 +1,37 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import type { Workspace } from '../lib/models';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useAppRoutes } from './useAppRoutes';
|
||||
import { useConfirm } from './useConfirm';
|
||||
import { requestsQueryKey } from './useRequests';
|
||||
import { useRoutes } from './useRoutes';
|
||||
import { workspacesQueryKey } from './useWorkspaces';
|
||||
|
||||
export function useDeleteWorkspace(id: string | null) {
|
||||
export function useDeleteWorkspace(workspace: Workspace | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const routes = useRoutes();
|
||||
return useMutation<Workspace, string>({
|
||||
mutationFn: () => {
|
||||
return invoke('delete_workspace', { id });
|
||||
const routes = useAppRoutes();
|
||||
const confirm = useConfirm();
|
||||
|
||||
return useMutation<Workspace | null, string>({
|
||||
mutationFn: async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Workspace',
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Permanently delete <InlineCode>{workspace?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (!confirmed) return null;
|
||||
return invoke('delete_workspace', { id: workspace?.id });
|
||||
},
|
||||
onSuccess: async ({ id: workspaceId }) => {
|
||||
onSuccess: async (workspace) => {
|
||||
if (workspace === null) return;
|
||||
|
||||
const { id: workspaceId } = workspace;
|
||||
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) =>
|
||||
workspaces?.filter((workspace) => workspace.id !== workspaceId),
|
||||
);
|
||||
@@ -2,8 +2,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useAppRoutes } from './useAppRoutes';
|
||||
import { requestsQueryKey } from './useRequests';
|
||||
import { useRoutes } from './useRoutes';
|
||||
|
||||
export function useDuplicateRequest({
|
||||
id,
|
||||
@@ -13,7 +13,7 @@ export function useDuplicateRequest({
|
||||
navigateAfter: boolean;
|
||||
}) {
|
||||
const workspaceId = useActiveWorkspaceId();
|
||||
const routes = useRoutes();
|
||||
const routes = useAppRoutes();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<HttpRequest, string>({
|
||||
mutationFn: async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useResponses } from './useResponses';
|
||||
import { isResponseLoading } from '../lib/models';
|
||||
import { useLatestResponse } from './useLatestResponse';
|
||||
|
||||
export function useIsResponseLoading(requestId: string | null): boolean {
|
||||
const responses = useResponses(requestId);
|
||||
const response = responses[responses.length - 1];
|
||||
if (!response) return false;
|
||||
return !(response.body || response.status || response.error);
|
||||
const response = useLatestResponse(requestId);
|
||||
if (response === null) return false;
|
||||
return isResponseLoading(response);
|
||||
}
|
||||
|
||||
7
src-web/hooks/useLatestResponse.ts
Normal file
7
src-web/hooks/useLatestResponse.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
import { useResponses } from './useResponses';
|
||||
|
||||
export function useLatestResponse(requestId: string | null): HttpResponse | null {
|
||||
const responses = useResponses(requestId);
|
||||
return responses[responses.length - 1] ?? null;
|
||||
}
|
||||
30
src-web/hooks/usePrompt.ts
Normal file
30
src-web/hooks/usePrompt.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { DialogProps } from '../components/core/Dialog';
|
||||
import { useDialog } from '../components/DialogContext';
|
||||
import type { PromptProps } from './Prompt';
|
||||
import { Prompt } from './Prompt';
|
||||
|
||||
export function usePrompt() {
|
||||
const dialog = useDialog();
|
||||
return ({
|
||||
title,
|
||||
description,
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
}: {
|
||||
title: DialogProps['title'];
|
||||
description?: DialogProps['description'];
|
||||
name: PromptProps['name'];
|
||||
label: PromptProps['label'];
|
||||
defaultValue: PromptProps['defaultValue'];
|
||||
}) =>
|
||||
new Promise((onResult: PromptProps['onResult']) => {
|
||||
dialog.show({
|
||||
title,
|
||||
description,
|
||||
hideX: true,
|
||||
size: 'sm',
|
||||
render: ({ hide }) => Prompt({ onHide: hide, onResult, name, label, defaultValue }),
|
||||
});
|
||||
});
|
||||
}
|
||||
36
src-web/hooks/useRecentRequests.ts
Normal file
36
src-web/hooks/useRecentRequests.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createGlobalState, useEffectOnce, useLocalStorage } from 'react-use';
|
||||
import { useActiveRequestId } from './useActiveRequestId';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
|
||||
const useHistoryState = createGlobalState<string[]>([]);
|
||||
|
||||
export function useRecentRequests() {
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const [history, setHistory] = useHistoryState();
|
||||
const [lsState, setLSState] = useLocalStorage<string[]>(
|
||||
'recent_requests::' + activeWorkspaceId,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLSState(history);
|
||||
}, [history, setLSState]);
|
||||
|
||||
useEffectOnce(() => {
|
||||
if (lsState) {
|
||||
setHistory(lsState);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setHistory((h: string[]) => {
|
||||
if (activeRequestId === null) return h;
|
||||
const withoutCurrentRequest = h.filter((id) => id !== activeRequestId);
|
||||
return [activeRequestId, ...withoutCurrentRequest];
|
||||
});
|
||||
}, [activeRequestId, setHistory]);
|
||||
|
||||
return history.slice(1);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
export function useResponseViewMode(requestId?: string): [string | undefined, () => void] {
|
||||
const v = useKeyValue<string>({
|
||||
namespace: 'app',
|
||||
key: ['response_view_mode', requestId ?? 'n/a'],
|
||||
defaultValue: 'pretty',
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
v.set(v.value === 'pretty' ? 'raw' : 'pretty');
|
||||
};
|
||||
|
||||
return [v.value, toggle];
|
||||
export function useResponseViewMode(
|
||||
requestId?: string,
|
||||
): [string | undefined, (m: 'pretty' | 'raw') => void] {
|
||||
const [value, setValue] = useLocalStorage<'pretty' | 'raw'>(
|
||||
`response_view_mode::${requestId}`,
|
||||
'pretty',
|
||||
);
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
|
||||
export function useSidebarHidden() {
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const { set, value } = useKeyValue<boolean>({
|
||||
namespace: NAMESPACE_NO_SYNC,
|
||||
key: 'sidebar_hidden',
|
||||
key: ['sidebar_hidden', activeWorkspaceId ?? 'n/a'],
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
|
||||
export function useSidebarWidth() {
|
||||
return useKeyValue<number>({
|
||||
namespace: NAMESPACE_NO_SYNC,
|
||||
key: 'sidebar_width',
|
||||
defaultValue: 200,
|
||||
});
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const [width, setWidth] = useLocalStorage<number>(`sidebar_width::${activeWorkspaceId}`, 220);
|
||||
const resetWidth = useCallback(() => setWidth(220), [setWidth]);
|
||||
return useMemo(() => ({ width, setWidth, resetWidth }), [width, setWidth, resetWidth]);
|
||||
}
|
||||
|
||||
29
src-web/hooks/useUpdateWorkspace.ts
Normal file
29
src-web/hooks/useUpdateWorkspace.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { Workspace } from '../lib/models';
|
||||
import { getWorkspace } from '../lib/store';
|
||||
import { workspacesQueryKey } from './useWorkspaces';
|
||||
|
||||
export function useUpdateWorkspace(id: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, Partial<Workspace> | ((w: Workspace) => Workspace)>({
|
||||
mutationFn: async (v) => {
|
||||
const workspace = await getWorkspace(id);
|
||||
if (workspace == null) {
|
||||
throw new Error("Can't update a null workspace");
|
||||
}
|
||||
|
||||
const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v };
|
||||
await invoke('update_workspace', { workspace: newWorkspace });
|
||||
},
|
||||
onMutate: async (v) => {
|
||||
const workspace = await getWorkspace(id);
|
||||
if (workspace === null) return;
|
||||
|
||||
const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v };
|
||||
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(workspace), (workspaces) =>
|
||||
(workspaces ?? []).map((w) => (w.id === newWorkspace.id ? newWorkspace : w)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { getKeyValue, NAMESPACE_NO_SYNC, setKeyValue } from './keyValueStore';
|
||||
|
||||
export async function getLastLocation(): Promise<string> {
|
||||
return getKeyValue({ namespace: NAMESPACE_NO_SYNC, key: 'last_location', fallback: '/' });
|
||||
}
|
||||
|
||||
export async function setLastLocation(pathname: string): Promise<void> {
|
||||
return setKeyValue({ namespace: NAMESPACE_NO_SYNC, key: 'last_location', value: pathname });
|
||||
}
|
||||
|
||||
export async function syncLastLocation(): Promise<void> {
|
||||
const lastPathname = await getLastLocation();
|
||||
if (lastPathname !== window.location.pathname) {
|
||||
console.log(`Redirecting to last location: ${lastPathname}`);
|
||||
window.location.assign(lastPathname);
|
||||
}
|
||||
}
|
||||
@@ -72,3 +72,7 @@ export interface HttpResponse extends BaseModel {
|
||||
readonly url: string;
|
||||
readonly headers: HttpHeader[];
|
||||
}
|
||||
|
||||
export function isResponseLoading(response: HttpResponse): boolean {
|
||||
return !(response.body || response.status || response.error);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { HttpRequest } from './models';
|
||||
import type { HttpRequest, Workspace } from './models';
|
||||
|
||||
export async function getRequest(id: string | null): Promise<HttpRequest | null> {
|
||||
if (id === null) return null;
|
||||
@@ -9,3 +9,12 @@ export async function getRequest(id: string | null): Promise<HttpRequest | null>
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function getWorkspace(id: string | null): Promise<Workspace | null> {
|
||||
if (id === null) return null;
|
||||
const workspace: Workspace = (await invoke('get_workspace', { id })) ?? null;
|
||||
if (workspace == null) {
|
||||
return null;
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ const darkTheme: AppTheme = {
|
||||
colors: {
|
||||
gray: '#6b5b98',
|
||||
red: '#ff417b',
|
||||
orange: '#ff9411',
|
||||
orange: '#fd9014',
|
||||
yellow: '#e8d13f',
|
||||
green: '#43e76f',
|
||||
green: '#3fd265',
|
||||
blue: '#219dff',
|
||||
pink: '#f670f6',
|
||||
pink: '#ff6dff',
|
||||
violet: '#b176ff',
|
||||
},
|
||||
},
|
||||
@@ -31,11 +31,11 @@ const lightTheme: AppTheme = {
|
||||
colors: {
|
||||
gray: '#7f8fb0',
|
||||
red: '#ec3f87',
|
||||
orange: '#ff8b00',
|
||||
orange: '#ff8000',
|
||||
yellow: '#e7cf24',
|
||||
green: '#00d365',
|
||||
blue: '#0090ff',
|
||||
pink: '#f670f6',
|
||||
pink: '#ea6cea',
|
||||
violet: '#ac6cff',
|
||||
},
|
||||
},
|
||||
@@ -50,14 +50,6 @@ export function getAppearance(): Appearance {
|
||||
return getPreferredAppearance();
|
||||
}
|
||||
|
||||
export function toggleAppearance(): Appearance {
|
||||
const currentTheme =
|
||||
document.documentElement.getAttribute('data-appearance') ?? getPreferredAppearance();
|
||||
const newAppearance = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setAppearance(newAppearance);
|
||||
return newAppearance;
|
||||
}
|
||||
|
||||
export function setAppearance(a?: Appearance) {
|
||||
const appearance = a ?? getPreferredAppearance();
|
||||
const theme = appearance === 'dark' ? darkTheme : lightTheme;
|
||||
|
||||
@@ -33,20 +33,24 @@
|
||||
}
|
||||
|
||||
/* Style the scrollbars */
|
||||
::-webkit-scrollbar-corner,
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5 h-1.5;
|
||||
}
|
||||
* {
|
||||
::-webkit-scrollbar-corner,
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5 h-1.5;
|
||||
}
|
||||
|
||||
.scrollbar-track,
|
||||
::-webkit-scrollbar-corner,
|
||||
::-webkit-scrollbar {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
.scrollbar-track,
|
||||
::-webkit-scrollbar-corner,
|
||||
::-webkit-scrollbar {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thumb,
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-500/30 hover:bg-gray-500/50 rounded-full;
|
||||
&:hover {
|
||||
&.scrollbar-thumb,
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-500/30 hover:bg-gray-500/50 rounded-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
iframe {
|
||||
|
||||
@@ -2,12 +2,10 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './components/App';
|
||||
import { getKeyValue } from './lib/keyValueStore';
|
||||
import { syncLastLocation } from './lib/lastLocation';
|
||||
import { getPreferredAppearance, setAppearance } from './lib/theme/window';
|
||||
import './main.css';
|
||||
|
||||
setAppearance(await getKeyValue({ key: 'appearance', fallback: getPreferredAppearance() }));
|
||||
await syncLastLocation();
|
||||
|
||||
// root holds our app's root DOM Element:
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
|
||||
@@ -16,13 +16,13 @@ module.exports = {
|
||||
"xs": "0.8rem"
|
||||
},
|
||||
height: {
|
||||
"xs": "1.5rem",
|
||||
"xs": "1.75rem",
|
||||
"sm": "2.0rem",
|
||||
"md": "2.5rem"
|
||||
},
|
||||
lineHeight: {
|
||||
// HACK: Minus 2 to account for borders inside inputs
|
||||
"xs": "calc(1.5rem - 2px)",
|
||||
"xs": "calc(1.75rem - 2px)",
|
||||
"sm": "calc(2.0rem - 2px)",
|
||||
"md": "calc(2.5rem - 2px)"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user