mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-19 07:26:59 +01:00
Compare commits
18 Commits
v2023.0.14
...
v2023.0.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66813d67fe | ||
|
|
a38691ed53 | ||
|
|
deeefdcfbf | ||
|
|
db292511b1 | ||
|
|
1a5334c1ce | ||
|
|
11002abe39 | ||
|
|
d922dcb062 | ||
|
|
6fcaa18e86 | ||
|
|
7664c941dd | ||
|
|
6f5cb528c6 | ||
|
|
ebb78922f0 | ||
|
|
2285fe9f1c | ||
|
|
38ba8625d8 | ||
|
|
ab5681c7ad | ||
|
|
f66dcb9267 | ||
|
|
1b6cfbac77 | ||
|
|
4c27e788ea | ||
|
|
769da0b052 |
@@ -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"
|
||||
}],
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Yaak App</title>
|
||||
<!-- <script src="http://localhost:8097"></script>-->
|
||||
<script src="http://localhost:8097"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: white;
|
||||
|
||||
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"
|
||||
|
||||
@@ -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,7 +21,9 @@ 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::{
|
||||
AppHandle, Menu, MenuItem, RunEvent, State, Submenu, TitleBarStyle, Window, WindowUrl, Wry,
|
||||
};
|
||||
use tauri::{CustomMenuItem, Manager, WindowEvent};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -362,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,
|
||||
@@ -436,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,
|
||||
@@ -490,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>,
|
||||
@@ -503,11 +537,6 @@ 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() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
@@ -538,7 +567,7 @@ fn main() {
|
||||
})
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
new_window,
|
||||
workspaces,
|
||||
get_request,
|
||||
requests,
|
||||
@@ -546,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,
|
||||
@@ -560,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, .. } => {
|
||||
@@ -574,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(
|
||||
@@ -602,10 +633,18 @@ fn create_window(handle: &AppHandle<Wry>) -> Window<Wry> {
|
||||
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
|
||||
@@ -625,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();
|
||||
@@ -649,11 +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.14"
|
||||
"version": "2023.0.15"
|
||||
},
|
||||
"tauri": {
|
||||
"windows": [],
|
||||
|
||||
@@ -6,7 +6,6 @@ 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,
|
||||
@@ -28,7 +27,6 @@ export function App() {
|
||||
<DialogProvider>
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
<TauriListeners />
|
||||
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
|
||||
</Suspense>
|
||||
</DialogProvider>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom';
|
||||
import { routePaths } from '../hooks/useRoutes';
|
||||
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';
|
||||
@@ -8,6 +11,7 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
errorElement: <RouteError />,
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
@@ -19,7 +23,7 @@ const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
|
||||
element: <Workspace />,
|
||||
element: <WorkspaceOrRedirect />,
|
||||
},
|
||||
{
|
||||
path: routePaths.request({
|
||||
@@ -35,3 +39,29 @@ const router = createBrowserRouter([
|
||||
export function AppRouter() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
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,8 +1,10 @@
|
||||
import type { HTMLAttributes, ReactElement } from 'react';
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
|
||||
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';
|
||||
@@ -15,24 +17,39 @@ interface Props {
|
||||
export function RequestActionsDropdown({ requestId, children }: Props) {
|
||||
const deleteRequest = useDeleteRequest(requestId);
|
||||
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const { appearance, toggleAppearance } = useTheme();
|
||||
|
||||
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>⌘D</HotKey>,
|
||||
rightSlot: <HotKey modifier="Meta" keyName="D" />,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
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'} />,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -40,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();
|
||||
@@ -62,7 +62,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
label: 'Preview',
|
||||
options: {
|
||||
value: viewMode,
|
||||
onChange: toggleViewMode,
|
||||
onChange: setViewMode,
|
||||
items: [
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'Raw', value: 'raw' },
|
||||
@@ -81,7 +81,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
value: 'headers',
|
||||
},
|
||||
],
|
||||
[activeResponse?.headers, toggleViewMode, viewMode],
|
||||
[activeResponse?.headers, setViewMode, viewMode],
|
||||
);
|
||||
|
||||
// Don't render until we know the view mode
|
||||
@@ -103,7 +103,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
<HStack
|
||||
alignItems="center"
|
||||
className={classnames(
|
||||
'italic text-gray-700 text-sm w-full flex-shrink-0',
|
||||
'text-gray-700 text-sm w-full flex-shrink-0',
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
'-mb-1.5',
|
||||
)}
|
||||
@@ -111,21 +111,33 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
{activeResponse && (
|
||||
<HStack alignItems="center" className="w-full">
|
||||
<div className="whitespace-nowrap px-3">
|
||||
<StatusTag response={activeResponse} />
|
||||
{activeResponse.elapsed > 0 && <> • {activeResponse.elapsed}ms</>}
|
||||
{activeResponse.body.length > 0 && (
|
||||
<> • {(activeResponse.body.length / 1000).toFixed(1)} KB</>
|
||||
)}
|
||||
<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={[
|
||||
{
|
||||
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,
|
||||
@@ -133,7 +145,13 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
...responses.slice(0, 10).map((r) => ({
|
||||
label: r.status + ' - ' + r.elapsed + ' ms',
|
||||
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),
|
||||
})),
|
||||
@@ -165,7 +183,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
</TabContent>
|
||||
<TabContent value="body">
|
||||
{!activeResponse.body ? (
|
||||
<EmptyStateText>No Response</EmptyStateText>
|
||||
<EmptyStateText>Empty Body</EmptyStateText>
|
||||
) : viewMode === 'pretty' && contentType.includes('html') ? (
|
||||
<Webview
|
||||
body={activeResponse.body}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
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 { useDeleteRequest } from '../hooks/useDeleteRequest';
|
||||
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 { isResponseLoading } from '../lib/models';
|
||||
import { Button } from './core/Button';
|
||||
import { Icon } from './core/Icon';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { StatusTag } from './core/StatusTag';
|
||||
@@ -26,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
|
||||
@@ -45,20 +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>
|
||||
</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();
|
||||
|
||||
@@ -109,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>
|
||||
))}
|
||||
@@ -127,18 +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 deleteRequest = useDeleteRequest(requestId);
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const activeRequestId = useActiveRequestId();
|
||||
const isActive = activeRequestId === requestId;
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async (el: HTMLInputElement) => {
|
||||
@@ -153,23 +267,8 @@ 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);
|
||||
}
|
||||
if (active && (e.key === 'Backspace' || e.key === 'Delete')) {
|
||||
e.preventDefault();
|
||||
deleteRequest.mutate();
|
||||
}
|
||||
},
|
||||
[active, deleteRequest],
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
async (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
@@ -185,49 +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
|
||||
tabIndex={0}
|
||||
color="custom"
|
||||
size="xs"
|
||||
to={`/workspaces/${workspaceId}/requests/${requestId}`}
|
||||
draggable={false} // Item should drag, not the link
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
justify="start"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={classnames(
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
active
|
||||
? 'bg-highlight text-gray-900'
|
||||
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-highlightSecondary',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{latestResponse && (
|
||||
<div className="ml-auto">
|
||||
{isResponseLoading(latestResponse) ? (
|
||||
<Icon spin size="sm" icon="update" />
|
||||
) : (
|
||||
<StatusTag className="text-2xs dark:opacity-80" response={latestResponse} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
@@ -240,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);
|
||||
|
||||
@@ -272,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),
|
||||
@@ -289,8 +398,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
|
||||
className={classnames(isDragging && 'opacity-20')}
|
||||
requestName={requestName}
|
||||
requestId={requestId}
|
||||
workspaceId={workspaceId}
|
||||
active={active}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
||||
import { useCreateRequest } from '../hooks/useCreateRequest';
|
||||
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
|
||||
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 activeRequestId = useActiveRequestId();
|
||||
const createRequest = useCreateRequest({ navigateAfter: true });
|
||||
const duplicateRequest = useDuplicateRequest({ id: activeRequestId, navigateAfter: true });
|
||||
|
||||
const handleCreateRequest = useCallback(() => {
|
||||
createRequest.mutate({});
|
||||
}, [createRequest]);
|
||||
|
||||
useTauriEvent('new_request', () => {
|
||||
createRequest.mutate({});
|
||||
});
|
||||
// TODO: Put this somewhere better
|
||||
useTauriEvent('duplicate_request', () => {
|
||||
duplicateRequest.mutate();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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();
|
||||
@@ -139,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"
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import classnames from 'classnames';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
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;
|
||||
@@ -19,44 +25,125 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
|
||||
const activeWorkspace = useActiveWorkspace();
|
||||
const activeWorkspaceId = activeWorkspace?.id ?? null;
|
||||
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
|
||||
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
|
||||
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
|
||||
const routes = useRoutes();
|
||||
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',
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
onSelect: async () => {
|
||||
const name = await prompt({
|
||||
title: 'Rename Workspace',
|
||||
description: (
|
||||
<>
|
||||
Enter a new name for <InlineCode>{activeWorkspace?.name}</InlineCode>
|
||||
</>
|
||||
),
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
defaultValue: activeWorkspace?.name,
|
||||
});
|
||||
updateWorkspace.mutate({ name });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'New Workspace',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
|
||||
},
|
||||
{
|
||||
label: 'Delete Workspace',
|
||||
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';
|
||||
@@ -24,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,9 +47,10 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
|
||||
() =>
|
||||
classnames(
|
||||
className,
|
||||
'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',
|
||||
@@ -56,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',
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-highlightSecondary 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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -2,11 +2,12 @@ import classnames from 'classnames';
|
||||
import type { HttpResponse } from '../../lib/models';
|
||||
|
||||
interface Props {
|
||||
response: Pick<HttpResponse, 'status' | 'error'>;
|
||||
response: Pick<HttpResponse, 'status' | 'statusReason' | 'error'>;
|
||||
className?: string;
|
||||
showReason?: boolean;
|
||||
}
|
||||
|
||||
export function StatusTag({ response, className }: Props) {
|
||||
export function StatusTag({ response, className, showReason }: Props) {
|
||||
const { status, error } = response;
|
||||
const label = error ? 'ERR' : status;
|
||||
return (
|
||||
@@ -22,7 +23,7 @@ export function StatusTag({ response, className }: Props) {
|
||||
status >= 500 && 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{label} {showReason && response.statusReason && response.statusReason}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,17 +73,17 @@ 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 ? '' : 'text-gray-600 hover:text-gray-800',
|
||||
'!px-0 mr-4 ml-[1px]',
|
||||
'!px-2 ml-[1px]',
|
||||
);
|
||||
|
||||
if ('options' in t) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,16 +3,12 @@ import { invoke } from '@tauri-apps/api';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { getRequest } from '../lib/store';
|
||||
import { useActiveRequestId } from './useActiveRequestId';
|
||||
import { useConfirm } from './useConfirm';
|
||||
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();
|
||||
const confirm = useConfirm();
|
||||
|
||||
return useMutation<HttpRequest | null, string>({
|
||||
@@ -23,7 +19,7 @@ export function useDeleteRequest(id: string | null) {
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>?
|
||||
Permanently delete <InlineCode>{request?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
@@ -39,9 +35,6 @@ export function useDeleteRequest(id: string | null) {
|
||||
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
|
||||
(requests ?? []).filter((r) => r.id !== requestId),
|
||||
);
|
||||
if (activeRequestId === requestId) {
|
||||
routes.navigate('workspace', { workspaceId });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ 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(workspace: Workspace | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const routes = useRoutes();
|
||||
const routes = useAppRoutes();
|
||||
const confirm = useConfirm();
|
||||
|
||||
return useMutation<Workspace | null, string>({
|
||||
@@ -21,7 +21,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
|
||||
variant: 'delete',
|
||||
description: (
|
||||
<>
|
||||
Are you sure you want to delete <InlineCode>{workspace?.name}</InlineCode>?
|
||||
Permanently delete <InlineCode>{workspace?.name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
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,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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user