Compare commits

..

18 Commits

Author SHA1 Message Date
Gregory Schier
66813d67fe Autofocus buttons 2023-04-11 14:04:23 -07:00
Gregory Schier
a38691ed53 Better opening workspaces and redirect workspace to recent request 2023-04-11 11:11:36 -07:00
Gregory Schier
deeefdcfbf Button disabled style opacity 2023-04-10 16:03:45 -07:00
Gregory Schier
db292511b1 Dropdown keys and pointer events 2023-04-10 16:02:29 -07:00
Gregory Schier
1a5334c1ce Upgrade deno core 2023-04-10 11:16:25 -07:00
Gregory Schier
11002abe39 Tweak response history 2023-04-09 23:15:51 -07:00
Gregory Schier
d922dcb062 Fixed multi-window model sync 2023-04-09 22:32:47 -07:00
Gregory Schier
6fcaa18e86 Tweak recent requests 2023-04-09 22:25:00 -07:00
Gregory Schier
7664c941dd Toggle settings 2023-04-09 22:12:16 -07:00
Gregory Schier
6f5cb528c6 Fix sidebar request focus 2023-04-09 22:03:41 -07:00
Gregory Schier
ebb78922f0 More stuff on sidebar 2023-04-09 21:52:04 -07:00
Gregory Schier
2285fe9f1c Small tweaks 2023-04-09 15:32:13 -07:00
Gregory Schier
38ba8625d8 Request history navigator 2023-04-09 15:26:54 -07:00
Gregory Schier
ab5681c7ad Enter name on create workspace 2023-04-09 12:27:02 -07:00
Gregory Schier
f66dcb9267 Rename workspace 2023-04-09 12:23:41 -07:00
Gregory Schier
1b6cfbac77 Sidebar hover transitions 2023-04-06 16:30:46 -07:00
Gregory Schier
4c27e788ea Remove some more key value usage 2023-04-06 16:26:56 -07:00
Gregory Schier
769da0b052 A bunch of tweaks 2023-04-06 16:05:25 -07:00
55 changed files with 1083 additions and 374 deletions

View File

@@ -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"
}],
}]
}
};

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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": [

View File

@@ -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();

View File

@@ -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>,

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Yaak",
"version": "2023.0.14"
"version": "2023.0.15"
},
"tauri": {
"windows": [],

View File

@@ -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>

View File

@@ -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 />
</>
);
}

View File

@@ -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;

View File

@@ -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;
};

View 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>
);
}

View File

@@ -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'} />,

View File

@@ -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 ? (

View File

@@ -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 (

View File

@@ -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 && <>&nbsp;&bull;&nbsp;{activeResponse.elapsed}ms</>}
{activeResponse.body.length > 0 && (
<>&nbsp;&bull;&nbsp;{(activeResponse.body.length / 1000).toFixed(1)} KB</>
)}
<HStack space={2}>
<StatusTag showReason response={activeResponse} />
{activeResponse.elapsed > 0 && (
<>
<span>&bull;</span>
<span>{activeResponse.elapsed}ms</span>
</>
)}
{activeResponse.body.length > 0 && (
<>
<span>&bull;</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>&bull;</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}

View File

@@ -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}
/>
);
});

View File

@@ -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 (
<>

View File

@@ -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"

View File

@@ -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 (

View File

@@ -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 && (

View File

@@ -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];

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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),

View File

@@ -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>
);
}

View File

@@ -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) {

View File

@@ -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
View 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>
);
}

View File

@@ -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>();

View File

@@ -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>();

View File

@@ -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],
);
}

View File

@@ -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 }),
});
});
}

View File

@@ -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();

View File

@@ -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) => {

View 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),
);
},
});
}

View File

@@ -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 });
}
},
});
}

View File

@@ -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>?
</>
),
});

View File

@@ -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 () => {

View 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 }),
});
});
}

View 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);
}

View File

@@ -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];
}

View File

@@ -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,
});

View File

@@ -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]);
}

View 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)),
);
},
});
}

View File

@@ -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;
}

View File

@@ -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 {