Compare commits

...

19 Commits

Author SHA1 Message Date
Gregory Schier
8619c66ea4 Create request/folder with proper folderId 2024-09-03 08:06:30 -07:00
Gregory Schier
230e1f55c2 Separate active tabs per request 2024-09-03 07:52:35 -07:00
Gregory Schier
d4ab8897e2 Separate active tabs per request 2024-09-03 07:33:48 -07:00
Gregory Schier
184feaa22b Print plugin dir on failure to read 2024-09-03 07:18:00 -07:00
Gregory Schier
428aaad877 Clean up 2024-09-03 06:44:51 -07:00
Gregory Schier
33f1aa29e4 Request pane context (#70) 2024-09-03 06:18:25 -07:00
Gregory Schier
002acd05ee Request pane context (#69) 2024-09-02 14:36:55 -07:00
Gregory Schier
e6d7f4a928 Fix white screen 2024-09-02 12:52:42 -07:00
Gregory Schier
0bfafb284a Placeholder CM tags working 2024-09-02 12:35:05 -07:00
Gregory Schier
f8b317e94b Rename var 2024-08-30 05:39:58 -07:00
Gregory Schier
ef0fdb4b16 Check for updates less often on stable 2024-08-30 05:39:29 -07:00
Gregory Schier
f2f1d9affa Only check for updates once per day 2024-08-30 05:36:30 -07:00
Gregory Schier
c73262b037 URL path placeholders 2024-08-30 05:24:07 -07:00
Gregory Schier
f8936e7b76 Detect JSON APIs returning HTML content-type 2024-08-29 10:52:41 -07:00
Gregory Schier
0d20e0fe29 Better content-type detection for editor 2024-08-29 06:07:31 -07:00
Gregory Schier
ba626a6b3e Fix Windows/linux close icon color 2024-08-28 09:46:35 -07:00
Gregory Schier
1e18933178 Kill plugin manager before NSIS update on Windows 2024-08-28 09:14:39 -07:00
Gregory Schier
97a4770464 Remove tauri "unstable" feature to fix Codemirror selection 2024-08-28 06:48:02 -07:00
Gregory Schier
db02dbcaa4 Hotfix for window focusing 2024-08-27 16:56:04 -07:00
49 changed files with 790 additions and 248 deletions

4
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.6.1",
"date-fns": "^3.3.1",
"eventemitter3": "^5.0.1",
"fast-fuzzy": "^1.12.0",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
@@ -5750,8 +5751,7 @@
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
},
"node_modules/execa": {
"version": "7.2.0",

View File

@@ -49,6 +49,7 @@
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.6.1",
"date-fns": "^3.3.1",
"eventemitter3": "^5.0.1",
"fast-fuzzy": "^1.12.0",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",

7
src-tauri/Cargo.lock generated
View File

@@ -6738,6 +6738,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.2.0"
@@ -7568,6 +7574,7 @@ dependencies = [
"thiserror",
"tokio",
"tokio-stream",
"urlencoding",
"uuid",
"yaak_grpc",
"yaak_models",

View File

@@ -44,7 +44,7 @@ reqwest_cookie_store = "0.8.0"
serde = { version = "1.0.198", features = ["derive"] }
serde_json = { version = "1.0.116", features = ["raw_value"] }
serde_yaml = "0.9.34"
tauri = { workspace = true, features = ["unstable"] }
tauri = { workspace = true }
tauri-plugin-shell = { workspace = true }
tauri-plugin-clipboard-manager = "2.1.0-beta.7"
tauri-plugin-dialog = "2.0.0-rc.0"
@@ -58,6 +58,7 @@ tokio-stream = "0.1.15"
uuid = "1.7.0"
thiserror = "1.0.61"
mime_guess = "2.0.5"
urlencoding = "2.1.3"
[workspace.dependencies]
yaak_models = { path = "yaak_models" }

View File

@@ -23,7 +23,7 @@ use tauri::{Manager, Runtime, WebviewWindow};
use tokio::sync::oneshot;
use tokio::sync::watch::Receiver;
use yaak_models::models::{
Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader,
Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, HttpUrlParameter,
};
use yaak_models::queries::{get_workspace, update_response_if_id, upsert_cookie_jar};
@@ -40,13 +40,8 @@ pub async fn send_http_request<R: Runtime>(
.expect("Failed to get Workspace");
let cb = &*window.app_handle().state::<PluginTemplateCallback>();
let cb = cb.for_send();
let rendered_request = render_http_request(
&request,
&workspace,
environment.as_ref(),
&cb,
)
.await;
let rendered_request =
render_http_request(&request, &workspace, environment.as_ref(), &cb).await;
let mut url_string = rendered_request.url;
@@ -101,6 +96,23 @@ pub async fn send_http_request<R: Runtime>(
let client = client_builder.build().expect("Failed to build client");
// Render query parameters
let mut query_params = Vec::new();
for p in rendered_request.url_parameters {
if !p.enabled || p.name.is_empty() {
continue;
}
// Replace path parameters with values from URL parameters
let old_url_string = url_string.clone();
url_string = replace_path_placeholder(&p, url_string.as_str());
// Treat as regular param if wasn't used as path param
if old_url_string == url_string {
query_params.push((p.name, p.value));
}
}
let uri = match http::Uri::from_str(url_string.as_str()) {
Ok(u) => u,
Err(e) => {
@@ -127,7 +139,7 @@ pub async fn send_http_request<R: Runtime>(
let m = Method::from_bytes(rendered_request.method.to_uppercase().as_bytes())
.expect("Failed to create method");
let mut request_builder = client.request(m, url);
let mut request_builder = client.request(m, url).query(&query_params);
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
@@ -210,15 +222,6 @@ pub async fn send_http_request<R: Runtime>(
}
}
let mut query_params = Vec::new();
for p in rendered_request.url_parameters {
if !p.enabled || p.name.is_empty() {
continue;
}
query_params.push((p.name, p.value));
}
request_builder = request_builder.query(&query_params);
let request_body = rendered_request.body;
if let Some(body_type) = &rendered_request.body_type {
if request_body.contains_key("text") {
@@ -489,3 +492,119 @@ fn get_str_h<'a>(v: &'a HashMap<String, Value>, key: &str) -> &'a str {
Some(v) => v.as_str().unwrap_or_default(),
}
}
fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
if !p.enabled {
return url.to_string();
}
let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap();
let result = re
.replace_all(url, |cap: &regex::Captures| {
format!(
"{}{}{}",
cap[1].to_string(),
urlencoding::encode(p.value.as_str()),
cap[2].to_string()
)
})
.into_owned();
result
}
#[cfg(test)]
mod tests {
use crate::http_request::replace_path_placeholder;
use yaak_models::models::HttpUrlParameter;
#[test]
fn placeholder_middle() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo/bar"),
"https://example.com/xxx/bar",
);
}
#[test]
fn placeholder_end() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/xxx",
);
}
#[test]
fn placeholder_query() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo?:foo"),
"https://example.com/xxx?:foo",
);
}
#[test]
fn placeholder_missing() {
let p = HttpUrlParameter {
enabled: true,
name: "".to_string(),
value: "".to_string(),
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:missing"),
"https://example.com/:missing",
);
}
#[test]
fn placeholder_disabled() {
let p = HttpUrlParameter {
enabled: false,
name: ":foo".to_string(),
value: "xxx".to_string(),
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/:foo",
);
}
#[test]
fn placeholder_prefix() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "xxx".into(),
enabled: true,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foooo"),
"https://example.com/:foooo",
);
}
#[test]
fn placeholder_encode() {
let p = HttpUrlParameter {
name: ":foo".into(),
value: "Hello World".into(),
enabled: true,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:foo"),
"https://example.com/Hello%20World",
);
}
}

View File

@@ -2062,20 +2062,28 @@ async fn handle_plugin_event<R: Runtime>(app_handle: &AppHandle<R>, event: &Inte
if let Some(e) = response_event {
let plugin_manager: State<'_, PluginManager> = app_handle.state();
if let Err(e) = plugin_manager.reply(&event, &e).await {
warn!("Failed to reply to plugin manager: {}", e)
warn!("Failed to reply to plugin manager: {:?}", e)
}
}
}
// app_handle.get_focused_window locks, so this one is a non-locking version, safe for use in async context
fn get_focused_window_no_lock<R: Runtime>(app_handle: &AppHandle<R>) -> Option<WebviewWindow<R>> {
// TODO: Getting the focused window doesn't seem to work on Windows, so
// we'll need to pass the window label into plugin events instead.
if app_handle.webview_windows().len() == 1 {
debug!("Returning only webview window");
let w = app_handle
.webview_windows()
.iter()
.next()
.map(|w| w.1.clone());
return w;
}
app_handle
.windows()
.iter()
.find(|w| w.1.is_focused().unwrap_or(false))
.map(|w| w.1.clone())?
.webview_windows()
.iter()
.next()
.map(|(_, w)| w.to_owned())
.find(|w| w.1.is_focused().unwrap_or(false))
.map(|w| w.1.clone())
}

View File

@@ -2,14 +2,17 @@ use std::fmt::{Display, Formatter};
use std::time::SystemTime;
use log::info;
use tauri::AppHandle;
use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_updater::UpdaterExt;
use tokio::task::block_in_place;
use yaak_plugin_runtime::manager::PluginManager;
use crate::is_dev;
// Check for updates every 3 hours
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60 * 3;
const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12;
const MAX_UPDATE_CHECK_HOURS_BETA: u64 = 3;
const MAX_UPDATE_CHECK_HOURS_ALPHA: u64 = 1;
// Create updater struct
pub struct YaakUpdater {
@@ -49,6 +52,7 @@ impl YaakUpdater {
last_update_check: SystemTime::UNIX_EPOCH,
}
}
pub async fn force_check(
&mut self,
app_handle: &AppHandle,
@@ -58,8 +62,22 @@ impl YaakUpdater {
info!("Checking for updates mode={}", mode);
let h = app_handle.clone();
let update_check_result = app_handle
.updater_builder()
.on_before_exit(move || {
// Kill plugin manager before exit or NSIS installer will fail to replace sidecar
// while it's running.
// NOTE: This is only called on Windows
let h = h.clone();
block_in_place(|| {
tauri::async_runtime::block_on(async move {
info!("Shutting down plugin manager before update");
let plugin_manager = h.state::<PluginManager>();
plugin_manager.cleanup().await;
});
});
})
.header("X-Update-Mode", mode.to_string())?
.build()?
.check()
@@ -112,8 +130,13 @@ impl YaakUpdater {
app_handle: &AppHandle,
mode: UpdateMode,
) -> Result<bool, tauri_plugin_updater::Error> {
let ignore_check =
self.last_update_check.elapsed().unwrap().as_secs() < MAX_UPDATE_CHECK_SECONDS;
let update_period_seconds = match mode {
UpdateMode::Stable => MAX_UPDATE_CHECK_HOURS_STABLE,
UpdateMode::Beta => MAX_UPDATE_CHECK_HOURS_BETA,
UpdateMode::Alpha => MAX_UPDATE_CHECK_HOURS_ALPHA,
} * (60 * 60);
let seconds_since_last_check = self.last_update_check.elapsed().unwrap().as_secs();
let ignore_check = seconds_since_last_check < update_period_seconds;
if ignore_check {
return Ok(false);
}

View File

@@ -27,7 +27,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
tauri::async_runtime::block_on(async move {
let plugin_dirs = read_plugins_dir(&plugins_dir)
.await
.expect("Failed to read plugins dir");
.expect(format!("Failed to read plugins dir: {:?}", plugins_dir).as_str());
let manager = PluginManager::new(&app, plugin_dirs).await;
app.manage(manager);
Ok(())

View File

@@ -1,3 +1,4 @@
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import type { DropdownProps } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
@@ -7,7 +8,13 @@ interface Props extends Omit<DropdownProps, 'items'> {
}
export function CreateDropdown({ hideFolder, children, ...props }: Props) {
const items = useCreateDropdownItems({ hideFolder, hideIcons: true });
const activeRequest = useActiveRequest();
const folderId = activeRequest?.folderId ?? null;
const items = useCreateDropdownItems({
hideFolder,
hideIcons: true,
folderId,
});
return (
<Dropdown items={items} {...props}>
{children}

View File

@@ -128,7 +128,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto]">
<Editor
contentType="application/graphql"
language="graphql"
defaultValue={query ?? ''}
format={formatGraphQL}
heightMode="auto"
@@ -144,7 +144,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
</Separator>
<Editor
format={tryFormatJson}
contentType="application/json"
language="json"
defaultValue={JSON.stringify(variables, null, 2)}
heightMode="auto"
onChange={handleChangeVariables}

View File

@@ -185,7 +185,7 @@ export function GrpcEditor({
return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
contentType="application/json"
language="json"
autocompleteVariables
useTemplating
forceUpdateKey={request.id}

View File

@@ -1,4 +1,5 @@
import type { HttpResponse } from '@yaakapp/api';
import { useCopyHttpResponse } from '../hooks/useCopyHttpResponse';
import { useDeleteHttpResponse } from '../hooks/useDeleteHttpResponse';
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses';
import { useSaveResponse } from '../hooks/useSaveResponse';
@@ -25,6 +26,7 @@ export const RecentResponsesDropdown = function ResponsePane({
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const latestResponseId = responses[0]?.id ?? 'n/a';
const saveResponse = useSaveResponse(activeResponse);
const copyResponse = useCopyHttpResponse(activeResponse);
return (
<Dropdown
@@ -37,6 +39,14 @@ export const RecentResponsesDropdown = function ResponsePane({
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{
key: 'copy',
label: 'Copy to Clipboard',
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{
key: 'clear-single',
label: 'Delete',

View File

@@ -8,10 +8,12 @@ import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { useImportCurl } from '../hooks/useImportCurl';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestEditorEvent } from '../hooks/useRequestEditor';
import { useRequests } from '../hooks/useRequests';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { languageFromContentType } from '../lib/contentType';
import { tryFormatJson } from '../lib/formatters';
import {
AUTH_TYPE_BASIC,
@@ -33,6 +35,7 @@ import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import type { GenericCompletionOption } from './core/Editor/genericCompletion';
import { InlineCode } from './core/InlineCode';
import type { Pair } from './core/PairEditor';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
@@ -51,7 +54,14 @@ interface Props {
activeRequest: HttpRequest;
}
const useActiveTab = createGlobalState<string>('body');
const useActiveTab = createGlobalState<Record<string, string>>({});
const TAB_BODY = 'body';
const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth';
const DEFAULT_TAB = TAB_BODY;
export const RequestPane = memo(function RequestPane({
style,
@@ -62,7 +72,7 @@ export const RequestPane = memo(function RequestPane({
const requests = useRequests();
const activeRequestId = activeRequest.id;
const updateRequest = useUpdateAnyHttpRequest();
const [activeTab, setActiveTab] = useActiveTab();
const [activeTabs, setActiveTabs] = useActiveTab();
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const contentType = useContentTypeFromHeaders(activeRequest.headers);
@@ -88,10 +98,27 @@ export const RequestPane = memo(function RequestPane({
const toast = useToast();
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? '',
);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const index = items.findIndex((p) => p.name === name);
if (index >= 0) {
items[index]!.readOnlyName = true;
} else {
items.push({ name, value: '', enabled: true, readOnlyName: true });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(',') };
}, [activeRequest.url, activeRequest.urlParameters]);
const tabs: TabItem[] = useMemo(
() => [
{
value: 'body',
value: TAB_BODY,
options: {
value: activeRequest.bodyType,
items: [
@@ -157,16 +184,16 @@ export const RequestPane = memo(function RequestPane({
},
},
{
value: 'params',
value: TAB_PARAMS,
label: (
<div className="flex items-center">
Params
<CountBadge count={activeRequest.urlParameters.filter((p) => p.name).length} />
<CountBadge count={urlParameterPairs.length} />
</div>
),
},
{
value: 'headers',
value: TAB_HEADERS,
label: (
<div className="flex items-center">
Headers
@@ -175,7 +202,7 @@ export const RequestPane = memo(function RequestPane({
),
},
{
value: 'auth',
value: TAB_AUTH,
label: 'Auth',
options: {
value: activeRequest.authenticationType,
@@ -211,11 +238,11 @@ export const RequestPane = memo(function RequestPane({
activeRequest.bodyType,
activeRequest.headers,
activeRequest.method,
activeRequest.urlParameters,
activeRequestId,
handleContentTypeChange,
toast,
updateRequest,
urlParameterPairs,
],
);
@@ -269,6 +296,18 @@ export const RequestPane = memo(function RequestPane({
const { updateKey } = useRequestUpdateKey(activeRequestId ?? null);
const importCurl = useImportCurl();
const activeTab = activeTabs[activeRequestId] ?? DEFAULT_TAB;
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
useRequestEditorEvent('request_pane.focus_tab', () => {
setActiveTab(TAB_PARAMS);
});
return (
<div
style={style}
@@ -315,13 +354,14 @@ export const RequestPane = memo(function RequestPane({
isLoading={isLoading}
/>
<Tabs
key={activeRequest.id} // Freshen tabs on request change
value={activeTab}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2 !mb-1.5"
>
<TabContent value="auth">
<TabContent value={TAB_AUTH}>
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
<BasicAuth key={forceUpdateKey} request={activeRequest} />
) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? (
@@ -332,21 +372,21 @@ export const RequestPane = memo(function RequestPane({
</EmptyStateText>
)}
</TabContent>
<TabContent value="headers">
<TabContent value={TAB_HEADERS}>
<HeadersEditor
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
onChange={handleHeadersChange}
/>
</TabContent>
<TabContent value="params">
<TabContent value={TAB_PARAMS}>
<UrlParametersEditor
forceUpdateKey={forceUpdateKey}
urlParameters={activeRequest.urlParameters}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={handleUrlParametersChange}
/>
</TabContent>
<TabContent value="body">
<TabContent value={TAB_BODY}>
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor
forceUpdateKey={forceUpdateKey}
@@ -355,7 +395,7 @@ export const RequestPane = memo(function RequestPane({
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
contentType="application/json"
language="json"
onChange={handleBodyTextChange}
format={tryFormatJson}
/>
@@ -367,7 +407,7 @@ export const RequestPane = memo(function RequestPane({
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
contentType="text/xml"
language="xml"
onChange={handleBodyTextChange}
/>
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
@@ -402,6 +442,7 @@ export const RequestPane = memo(function RequestPane({
forceUpdateKey={forceUpdateKey}
useTemplating
autocompleteVariables
language={languageFromContentType(contentType)}
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}

View File

@@ -1,11 +1,11 @@
import type { HttpRequest } from '@yaakapp/api';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { createGlobalState } from 'react-use';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import type { HttpRequest } from '@yaakapp/api';
import { isResponseLoading } from '../lib/models';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
@@ -23,11 +23,10 @@ import { ResponseHeaders } from './ResponseHeaders';
import { ResponseInfo } from './ResponseInfo';
import { AudioViewer } from './responseViewers/AudioViewer';
import { CsvViewer } from './responseViewers/CsvViewer';
import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { PdfViewer } from './responseViewers/PdfViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
interface Props {
style?: CSSProperties;
@@ -35,18 +34,30 @@ interface Props {
activeRequest: HttpRequest;
}
const useActiveTab = createGlobalState<string>('body');
const useActiveTab = createGlobalState<Record<string, string>>({});
const TAB_BODY = 'body';
const TAB_HEADERS = 'headers';
const TAB_INFO = 'info';
const DEFAULT_TAB = TAB_BODY;
export const ResponsePane = memo(function ResponsePane({ style, className, activeRequest }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequest);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
const [activeTabs, setActiveTabs] = useActiveTab();
const contentType = useContentTypeFromHeaders(activeResponse?.headers ?? null);
const activeTab = activeTabs[activeRequest.id] ?? DEFAULT_TAB;
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
const tabs = useMemo<TabItem[]>(
() => [
{
value: 'body',
value: TAB_BODY,
label: 'Preview Mode',
options: {
value: viewMode,
@@ -58,6 +69,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
},
},
{
value: TAB_HEADERS,
label: (
<div className="flex items-center">
Headers
@@ -66,11 +78,10 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
/>
</div>
),
value: 'headers',
},
{
value: TAB_INFO,
label: 'Info',
value: 'info',
},
],
[activeResponse?.headers, contentType, setViewMode, viewMode],
@@ -143,20 +154,15 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
</Banner>
) : (
<Tabs
key={activeRequest.id} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
label="Response"
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3"
tabListClassName="mt-1.5"
>
<TabContent value="headers">
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value="info">
<ResponseInfo response={activeResponse} />
</TabContent>
<TabContent value="body">
<TabContent value={TAB_BODY}>
{!activeResponse.contentLength ? (
<div className="pb-2 h-full">
<EmptyStateText>Empty Body</EmptyStateText>
@@ -171,18 +177,22 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
<PdfViewer response={activeResponse} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : viewMode === 'pretty' && contentType?.includes('html') ? (
<WebPageViewer response={activeResponse} />
) : (
// ) : viewMode === 'pretty' && contentType?.includes('json') ? (
// <JsonAttributeTree attrValue={activeResponse} />
<TextViewer
className="-mr-2 bg-surface" // Pull to the right
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === 'pretty'}
/>
)}
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_INFO}>
<ResponseInfo response={activeResponse} />
</TabContent>
</Tabs>
)}
</div>

View File

@@ -198,7 +198,7 @@ export function SettingsAppearance() {
'};',
].join('\n')}
heightMode="auto"
contentType="application/javascript"
language="javascript"
/>
</VStack>
</VStack>

View File

@@ -117,7 +117,7 @@ export function SettingsDesign() {
'};',
].join('\n')}
heightMode="auto"
contentType="application/javascript"
language="javascript"
/>
<div className="flex flex-col gap-1">
<div className="flex flex-wrap gap-1">

View File

@@ -67,7 +67,7 @@ export const UrlBar = memo(function UrlBar({
wrapLines={isFocused}
hideLabel
useTemplating
contentType="url"
language="url"
className="pl-0 pr-1.5 py-0.5"
name="url"
label="Enter URL"

View File

@@ -1,23 +1,45 @@
import type { HttpRequest } from '@yaakapp/api';
import { useRequestEditorEvent } from '../hooks/useRequestEditor';
import type { PairEditorRef } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { VStack } from './core/Stacks';
import { useRef } from 'react';
type Props = {
forceUpdateKey: string;
urlParameters: HttpRequest['headers'];
pairs: HttpRequest['headers'];
onChange: (headers: HttpRequest['urlParameters']) => void;
};
export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange }: Props) {
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange }: Props) {
const pairEditor = useRef<PairEditorRef>(null);
useRequestEditorEvent(
'request_params.focus_value',
(name) => {
const pairIndex = pairs.findIndex((p) => p.name === name);
if (pairIndex >= 0) {
pairEditor.current?.focusValue(pairIndex);
} else {
console.log("Couldn't find pair to focus", { name, pairs });
}
},
[pairs],
);
return (
<PairOrBulkEditor
preferenceName="url_parameters"
valueAutocompleteVariables
nameAutocompleteVariables
namePlaceholder="param_name"
valuePlaceholder="Value"
pairs={urlParameters}
onChange={onChange}
forceUpdateKey={forceUpdateKey}
/>
<VStack className="h-full">
<PairOrBulkEditor
ref={pairEditor}
preferenceName="url_parameters"
valueAutocompleteVariables
nameAutocompleteVariables
namePlaceholder="param_name"
valuePlaceholder="Value"
pairs={pairs}
onChange={onChange}
forceUpdateKey={forceUpdateKey}
/>
</VStack>
);
}

View File

@@ -57,7 +57,7 @@ export function WindowControls({ className, onlyX }: Props) {
)}
<Button
color="custom"
className="!h-full px-4 text-text-subtle rounded-none hocus:bg-fg-danger hocus:text"
className="!h-full px-4 text-text-subtle rounded-none hocus:bg-danger hocus:text"
onClick={() => getCurrentWebviewWindow().close()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">

View File

@@ -36,18 +36,19 @@ export function BulkPairEditor({
forceUpdateKey={forceUpdateKey}
placeholder={`${namePlaceholder ?? 'name'}: ${valuePlaceholder ?? 'value'}`}
defaultValue={pairsText}
contentType="pairs"
language="pairs"
onChange={handleChange}
/>
);
}
function lineToPair(l: string): PairEditorProps['pairs'][0] {
const [name, ...values] = l.split(':');
function lineToPair(line: string): PairEditorProps['pairs'][0] {
const [, name, value] = line.match(/^(:?[^:]+):\s+([^$]*)/) ?? [];
const pair: PairEditorProps['pairs'][0] = {
enabled: true,
name: (name ?? '').trim(),
value: values.join(':').trim(),
value: (value ?? '').trim(),
};
return pair;
}

View File

@@ -19,7 +19,8 @@
}
.cm-line {
@apply w-full; /* Important! Ensure it spans the entire width */
@apply w-full;
/* Important! Ensure it spans the entire width */
@apply w-full text-text pl-1 pr-1.5;
}
@@ -169,9 +170,14 @@
}
}
.cm-wrapper.cm-readonly .cm-editor {
.cm-cursor {
@apply border-danger !important;
/* Cursor and mouse cursor for readonly mode */
.cm-wrapper.cm-readonly {
.cm-editor .cm-cursor {
@apply hidden !important;
}
&.cm-singleline .cm-line {
@apply cursor-default;
}
}

View File

@@ -19,6 +19,7 @@ import {
} from 'react';
import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironmentVariables';
import { parseTemplate } from '../../../hooks/useParseTemplate';
import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useSettings } from '../../../hooks/useSettings';
import { useTemplateFunctions } from '../../../hooks/useTemplateFunctions';
import { useDialog } from '../../DialogContext';
@@ -39,10 +40,11 @@ export { formatSdl } from 'format-graphql';
export interface EditorProps {
id?: string;
readOnly?: boolean;
disabled?: boolean;
type?: 'text' | 'password';
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string | null;
language?: 'javascript' | 'json' | 'html' | 'xml' | 'graphql' | 'url' | 'pairs' | 'text';
forceUpdateKey?: string | number;
autoFocus?: boolean;
autoSelect?: boolean;
@@ -71,7 +73,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
readOnly,
type = 'text',
heightMode,
contentType,
language = 'text',
autoFocus,
autoSelect,
placeholder,
@@ -226,12 +228,20 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
[dialog],
);
// Update language extension when contentType changes
const { focusParamValue } = useRequestEditor();
const onClickPathParameter = useCallback(
async (name: string) => {
focusParamValue(name);
},
[focusParamValue],
);
// Update the language extension when the language changes
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({
contentType,
language,
environmentVariables,
useTemplating,
autocomplete,
@@ -239,10 +249,11 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [
contentType,
language,
autocomplete,
useTemplating,
environmentVariables,
@@ -250,6 +261,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
]);
// Initialize the editor when ref mounts
@@ -265,7 +277,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({
contentType,
language,
useTemplating,
autocomplete,
environmentVariables,
@@ -273,6 +285,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickVariable,
onClickFunction,
onClickMissingVariable,
onClickPathParameter,
});
const state = EditorState.create({

View File

@@ -32,7 +32,7 @@ import {
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable, TemplateFunction } from '@yaakapp/api';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import { graphql } from 'cm6-graphql';
import { EditorView } from 'codemirror';
import type { EditorProps } from './index';
import { pairs } from './pairs/extension';
@@ -46,6 +46,10 @@ export const syntaxHighlightStyle = HighlightStyle.define([
color: 'var(--textSubtlest)',
fontStyle: 'italic',
},
{
tag: [t.emphasis],
textDecoration: 'underline',
},
{
tag: [t.paren, t.bracket, t.brace],
color: 'var(--textSubtle)',
@@ -64,19 +68,19 @@ export const syntaxHighlightStyle = HighlightStyle.define([
const syntaxTheme = EditorView.theme({}, { dark: true });
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql': graphqlLanguageSupport(),
'application/json': json(),
'application/javascript': javascript(),
'text/html': xml(), // HTML as XML because HTML is oddly slow
'application/xml': xml(),
'text/xml': xml(),
const syntaxExtensions: Record<NonNullable<EditorProps['language']>, LanguageSupport | null> = {
graphql: null,
json: json(),
javascript: javascript(),
html: xml(), // HTML as XML because HTML is oddly slow
xml: xml(),
url: url(),
pairs: pairs(),
text: text(),
};
export function getLanguageExtension({
contentType,
language,
useTemplating = false,
environmentVariables,
autocomplete,
@@ -84,18 +88,20 @@ export function getLanguageExtension({
onClickVariable,
onClickFunction,
onClickMissingVariable,
onClickPathParameter,
}: {
environmentVariables: EnvironmentVariable[];
templateFunctions: TemplateFunction[];
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
} & Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
if (justContentType === 'application/graphql') {
onClickPathParameter: (name: string) => void;
} & Pick<EditorProps, 'language' | 'useTemplating' | 'autocomplete'>) {
if (language === 'graphql') {
return graphql();
}
const base = syntaxExtensions[justContentType] ?? text();
const base = syntaxExtensions[language ?? 'text'] ?? text();
if (!useTemplating) {
return base;
}
@@ -108,6 +114,7 @@ export function getLanguageExtension({
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
});
}

View File

@@ -1,8 +1,8 @@
@top pairs { (Key? Sep Value)* }
@top pairs { (Key Sep Value "\n")* }
@tokens {
Sep { ":" }
Key { ![:]+ }
Key { ":"? ![:]+ }
Value { ![\n]+ }
}

View File

@@ -0,0 +1,6 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
pairs = 1,
Key = 2,
Sep = 3,
Value = 4

View File

@@ -3,17 +3,17 @@ import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "!QQQOPOOOYOQO'#CaO_OPO'#CaQQOPOOOOOO,58{,58{OdOQO,58{OOOO-E6_-E6_OOOO1G.g1G.g",
stateData: "i~OQQORPO~OSSO~ORTO~OSVO~O",
goto: "]UPPPPPVQRORUR",
states: "zQQOPOOOVOQO'#CaQQOPOOO[OSO,58{OOOO-E6_-E6_OaOQO1G.gOOOO7+$R7+$R",
stateData: "f~OQPO~ORRO~OSTO~OVUO~O",
goto: "]UPPPPPVQQORSQ",
nodeNames: "⚠ pairs Key Sep Value",
maxTerm: 6,
maxTerm: 7,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "#oRRVOYhYZ!UZ![h![!]#[!];'Sh;'S;=`#U<%lOhRoVQPSQOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!UQ!rSSQOY!mZ;'S!m;'S;=`#O<%lO!mQ#RP;=`<%l!mR#XP;=`<%lhR#cSRPSQOY!mZ;'S!m;'S;=`#O<%lO!m",
tokenizers: [0, 1],
tokenData: "$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh",
tokenizers: [0, 1, 2],
topRules: {"pairs":[0,1]},
tokenPrec: 0
tokenPrec: 0,
termNames: {"0":"⚠","1":"@top","2":"Key","3":"Sep","4":"Value","5":"(Key Sep Value \"\\n\")+","6":"␄","7":"\"\\n\""}
})

View File

@@ -18,6 +18,7 @@ export function twig({
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
}: {
base: LanguageSupport;
environmentVariables: EnvironmentVariable[];
@@ -26,6 +27,7 @@ export function twig({
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
onClickPathParameter: (name: string) => void;
}) {
const language = mixLanguage(base);
@@ -62,11 +64,11 @@ export function twig({
return [
language,
base.support,
templateTagsPlugin(options, onClickMissingVariable),
language.data.of({ autocomplete: completions }),
base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }),
base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
templateTagsPlugin(options, onClickMissingVariable, onClickPathParameter),
];
}

View File

@@ -2,9 +2,42 @@ import { syntaxTree } from '@codemirror/language';
import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common';
import { EditorView } from 'codemirror';
import type { TwigCompletionOption } from './completion';
class PathPlaceholderWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
constructor(readonly rawText: string, readonly startPos: number, readonly onClick: () => void) {
super();
this.#clickListenerCallback = () => {
this.onClick?.();
};
}
eq(other: PathPlaceholderWidget) {
return this.startPos === other.startPos && this.rawText === other.rawText;
}
toDOM() {
const elt = document.createElement('span');
elt.className = `x-theme-templateTag x-theme-templateTag--secondary template-tag`;
elt.textContent = this.rawText;
elt.addEventListener('click', this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
super.destroy(dom);
}
ignoreEvent() {
return false;
}
}
class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
@@ -62,20 +95,40 @@ function templateTags(
view: EditorView,
options: TwigCompletionOption[],
onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,
onClickPathParameter: (name: string) => void,
): DecorationSet {
const widgets: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
const tree = syntaxTree(view.state);
tree.iterate({
from,
to,
enter(node) {
if (node.name == 'Tag') {
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > node.from && r.to < node.to) {
return;
if (node.name === 'Text') {
// Find the `url` node and then jump into it to find the placeholders
for (let i = node.from; i < node.to; i++) {
const innerTree = syntaxTree(view.state).resolveInner(i);
if (innerTree.node.name === 'url') {
innerTree.toTree().iterate({
enter(node) {
if (node.name !== 'Placeholder') return;
if (isSelectionInsideNode(view, node)) return;
const globalFrom = innerTree.node.from + node.from;
const globalTo = innerTree.node.from + node.to;
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
const onClick = () => onClickPathParameter(rawText);
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
const deco = Decoration.replace({ widget, inclusive: false });
widgets.push(deco.range(globalFrom, globalTo));
},
});
break;
}
}
} else if (node.name === 'Tag') {
// Don't decorate if the cursor is inside the match
if (isSelectionInsideNode(view, node)) return;
const rawTag = view.state.doc.sliceString(node.from, node.to);
@@ -114,17 +167,28 @@ function templateTags(
export function templateTagsPlugin(
options: TwigCompletionOption[],
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void,
onClickPathParameter: (name: string) => void,
) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = templateTags(view, options, onClickMissingVariable);
this.decorations = templateTags(
view,
options,
onClickMissingVariable,
onClickPathParameter,
);
}
update(update: ViewUpdate) {
this.decorations = templateTags(update.view, options, onClickMissingVariable);
this.decorations = templateTags(
update.view,
options,
onClickMissingVariable,
onClickPathParameter,
);
}
},
{
@@ -136,13 +200,13 @@ export function templateTagsPlugin(
return view.plugin(plugin)?.decorations || Decoration.none;
});
},
eventHandlers: {
mousedown(e) {
const target = e.target as HTMLElement;
if (target.classList.contains('template-tag')) console.log('CLICKED TEMPLATE TAG');
// return toggleBoolean(view, view.posAtDOM(target));
},
},
},
);
}
function isSelectionInsideNode(view: EditorView, node: SyntaxNodeRef) {
for (const r of view.state.selection.ranges) {
if (r.from > node.from && r.to < node.to) return true;
}
return false;
}

View File

@@ -1,7 +1,8 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const Template = 1,
export const
Template = 1,
Tag = 2,
TagOpen = 3,
TagContent = 4,
Open = 3,
Close = 5,
Text = 6;
TagClose = 5,
Text = 6

View File

@@ -16,4 +16,3 @@ export const parser = LRParser.deserialize({
topRules: {"Template":[0,1]},
tokenPrec: 0
})

View File

@@ -2,6 +2,8 @@ import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
Protocol: t.comment,
Placeholder: t.emphasis,
// PathSegment: t.tagName,
// Port: t.attributeName,
// Host: t.variableName,
// Path: t.bool,

View File

@@ -1,18 +1,20 @@
@top url { Protocol? Host Port? Path? Query? }
Query {
"?" queryPair ("&" queryPair)*
}
Path { ("/" (Placeholder | PathSegment))+ }
Query { "?" queryPair ("&" queryPair)* }
@tokens {
Protocol { $[a-zA-Z]+ "://" }
Path { ("/" $[a-zA-Z0-9\-_.]*)+ }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
Port { ":" $[0-9]+ }
Host { $[a-zA-Z0-9-_.]+ }
Port { ":" $[0-9]+ }
Placeholder { ":" ![/?#]+ }
PathSegment { ![?#/]+ }
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
// Protocol/host overlaps, so give proto explicit precedence
@precedence { Protocol, Host }
@precedence { Placeholder, PathSegment }
}
@external propSource highlight from "./highlight"

View File

@@ -5,4 +5,6 @@ export const
Host = 3,
Port = 4,
Path = 5,
Query = 6
Placeholder = 6,
PathSegment = 7,
Query = 8

View File

@@ -3,16 +3,17 @@ import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "!jOQOPOOQYOPOOOTOPOOOeOQO'#CbQOOOOOQ`OPOOQ]OPOOOjOPO,58|OrOQO'#CcOwOPO1G.hOOOO,58},58}OOOO-E6a-E6a",
stateData: "!S~OQQORPO~OSUOTTOXRO~OYVO~OZWOWUa~OYYO~OZWOWUi~OQR~",
goto: "dWPPPPPPX^VSPTUQXVRZX",
nodeNames: "⚠ url Protocol Host Port Path Query",
maxTerm: 11,
states: "#SOQOPOOQYOPOOOTOPOOOeOQO'#CeOmOPO'#CaOxOSO'#CdQOOOOOQ`OPOOQ]OPOOOOOO,59P,59POOOO-E6c-E6cO}OPO,59OO!VOSO'#CfO![OPO1G.jOOOO,59Q,59QOOOO-E6d-E6d",
stateData: "!j~OQQORPO~OSWO[RO]TO~OUXOVXO~O[ROZTX]TX~O^ZO~O_[OZWa~O^^O~O_[OZWi~OQRUVU~",
goto: "rZPPPPP[PP`elTVPWVUPVWSSPWRYSQ]ZR_]",
nodeNames: "⚠ url Protocol Host Port Path Placeholder PathSegment Query",
maxTerm: 15,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "%[~RYvwq}!Ov!O!Pv!P!Q!_!Q![!y![!]#u!a!b$T!c!}$Y#R#Sv#T#o$Y~vOZ~P{URP}!Ov!O!Pv!Q![v!c!}v#R#Sv#T#ov~!dVT~}!O!_!O!P!_!P!Q!_!Q![!_!c!}!_#R#S!_#T#o!_R#QVYQRP}!Ov!O!Pv!Q![!y!_!`#g!c!}!y#R#Sv#T#o!yQ#lRYQ!Q![#g!c!}#g#T#o#g~#xP!Q![#{~$QPS~!Q![#{~$YOX~R$aWYQRP}!Ov!O!Pv!Q![!y![!]$y!_!`#g!c!}$Y#R#Sv#T#o$YP$|P!P!Q%PP%SP!P!Q%VP%[OQP",
tokenizers: [0, 1],
repeatNodeCount: 2,
tokenData: "+U~RdOs!atv!avw#Ow}!a}!O#i!O!P#i!P!Q$o!Q![$t![!]&|!]!a!a!a!b(w!b!c!a!c!}(|!}#R!a#R#S#i#S#T!a#T#o(|#o;'S!a;'S;=`!x<%lO!aQ!fUVQOs!at!P!a!Q!a!a!b;'S!a;'S;=`!x<%lO!aQ!{P;=`<%l!aR#VU_PVQOs!at!P!a!Q!a!a!b;'S!a;'S;=`!x<%lO!aR#p_RPVQOs!at}!a}!O#i!O!P#i!Q![#i![!a!a!b!c!a!c!}#i!}#R!a#R#S#i#S#T!a#T#o#i#o;'S!a;'S;=`!x<%lO!a~$tO[~V$}a^SRPVQOs!at}!a}!O#i!O!P#i!Q![$t![!_!a!_!`&S!`!a!a!b!c!a!c!}$t!}#R!a#R#S#i#S#T!a#T#o$t#o;'S!a;'S;=`!x<%lO!aU&ZZ^SVQOs!at!P!a!Q![&S![!a!a!b!c!a!c!}&S!}#T!a#T#o&S#o;'S!a;'S;=`!x<%lO!aR'RVVQOs'ht!P'h!Q![(X![!a'h!b;'S'h;'S;=`(R<%lO'hQ'oUUQVQOs'ht!P'h!Q!a'h!b;'S'h;'S;=`(R<%lO'hQ(UP;=`<%l'hR(bVSPUQVQOs'ht!P'h!Q![(X![!a'h!b;'S'h;'S;=`(R<%lO'h~(|O]~V)Vb^SRPVQOs!at}!a}!O#i!O!P#i!Q![$t![!]*_!]!_!a!_!`&S!`!a!a!b!c!a!c!}(|!}#R!a#R#S#i#S#T!a#T#o(|#o;'S!a;'S;=`!x<%lO!aR*dVVQOs!at!P!a!P!Q*y!Q!a!a!b;'S!a;'S;=`!x<%lO!aP*|P!P!Q+PP+UOQP",
tokenizers: [0, 1, 2],
topRules: {"url":[0,1]},
tokenPrec: 47
tokenPrec: 66,
termNames: {"0":"⚠","1":"@top","2":"Protocol","3":"Host","4":"Port","5":"Path","6":"Placeholder","7":"PathSegment","8":"Query","9":"(\"/\" (Placeholder | PathSegment))+","10":"(\"&\" queryPair)+","11":"␄","12":"\"/\"","13":"\"?\"","14":"queryPair","15":"\"&\""}
})

View File

@@ -14,7 +14,7 @@ export type InputProps = Omit<
> &
Pick<
EditorProps,
| 'contentType'
| 'language'
| 'useTemplating'
| 'autocomplete'
| 'forceUpdateKey'
@@ -22,6 +22,7 @@ export type InputProps = Omit<
| 'autoSelect'
| 'autocompleteVariables'
| 'onKeyDown'
| 'readOnly'
> & {
name: string;
type?: 'text' | 'password';
@@ -68,6 +69,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
size = 'md',
type = 'text',
validate,
readOnly,
...props
}: InputProps,
ref,
@@ -77,9 +79,10 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
const [focused, setFocused] = useState(false);
const handleFocus = useCallback(() => {
if (readOnly) return;
setFocused(true);
onFocus?.();
}, [onFocus]);
}, [onFocus, readOnly]);
const handleBlur = useCallback(() => {
setFocused(false);
@@ -179,6 +182,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
className={editorClassName}
onFocus={handleFocus}
onBlur={handleBlur}
readOnly={readOnly}
{...props}
/>
</HStack>

View File

@@ -1,6 +1,15 @@
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Fragment,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { v4 as uuid } from 'uuid';
@@ -16,6 +25,10 @@ import type { InputProps } from './Input';
import { Input } from './Input';
import { RadioDropdown } from './RadioDropdown';
export interface PairEditorRef {
focusValue(index: number): void;
}
export type PairEditorProps = {
pairs: Pair[];
onChange: (pairs: Pair[]) => void;
@@ -41,6 +54,7 @@ export type Pair = {
value: string;
contentType?: string;
isFile?: boolean;
readOnlyName?: boolean;
};
type PairContainer = {
@@ -48,24 +62,28 @@ type PairContainer = {
id: string;
};
export function PairEditor({
className,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
valueType,
onChange,
noScroll,
pairs: originalPairs,
valueAutocomplete,
valueAutocompleteVariables,
valuePlaceholder,
valueValidate,
allowFileValues,
}: PairEditorProps) {
const [forceFocusPairId, setForceFocusPairId] = useState<string | null>(null);
export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function PairEditor(
{
className,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
valueType,
onChange,
noScroll,
pairs: originalPairs,
valueAutocomplete,
valueAutocompleteVariables,
valuePlaceholder,
valueValidate,
allowFileValues,
}: PairEditorProps,
ref,
) {
const [forceFocusNamePairId, setForceFocusNamePairId] = useState<string | null>(null);
const [forceFocusValuePairId, setForceFocusValuePairId] = useState<string | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [pairs, setPairs] = useState<PairContainer[]>(() => {
// Remove empty headers on initial render
@@ -74,6 +92,13 @@ export function PairEditor({
return [...pairs, newPairContainer()];
});
useImperativeHandle(ref, () => ({
focusValue(index: number) {
const id = pairs[index]?.id ?? 'n/a';
setForceFocusValuePairId(id);
},
}));
useEffect(() => {
// Remove empty headers on initial render
// TODO: Make this not refresh the entire editor when forceUpdateKey changes, using some
@@ -134,17 +159,18 @@ export function PairEditor({
if (focusPrevious) {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index - 1]?.id ?? null;
setForceFocusPairId(id);
setForceFocusNamePairId(id);
}
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
},
[setPairsAndSave, setForceFocusPairId, pairs],
[setPairsAndSave, setForceFocusNamePairId, pairs],
);
const handleFocus = useCallback(
(pair: PairContainer) =>
setPairs((pairs) => {
setForceFocusPairId(null); // Remove focus override when something focused
setForceFocusNamePairId(null); // Remove focus override when something focused
setForceFocusValuePairId(null); // Remove focus override when something focused
const isLast = pair.id === pairs[pairs.length - 1]?.id;
return isLast ? [...pairs, newPairContainer()] : pairs;
}),
@@ -184,7 +210,8 @@ export function PairEditor({
nameAutocompleteVariables={nameAutocompleteVariables}
valueAutocompleteVariables={valueAutocompleteVariables}
valueType={valueType}
forceFocusPairId={forceFocusPairId}
forceFocusNamePairId={forceFocusNamePairId}
forceFocusValuePairId={forceFocusValuePairId}
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete}
@@ -203,7 +230,7 @@ export function PairEditor({
})}
</div>
);
}
});
enum ItemTypes {
ROW = 'pair-row',
@@ -212,7 +239,8 @@ enum ItemTypes {
type PairEditorRowProps = {
className?: string;
pairContainer: PairContainer;
forceFocusPairId?: string | null;
forceFocusNamePairId?: string | null;
forceFocusValuePairId?: string | null;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onChange: (pair: PairContainer) => void;
@@ -238,7 +266,8 @@ type PairEditorRowProps = {
function PairEditorRow({
allowFileValues,
className,
forceFocusPairId,
forceFocusNamePairId,
forceFocusValuePairId,
forceUpdateKey,
isLast,
nameAutocomplete,
@@ -254,19 +283,26 @@ function PairEditorRow({
valueAutocomplete,
valueAutocompleteVariables,
valuePlaceholder,
valueValidate,
valueType,
valueValidate,
}: PairEditorRowProps) {
const { id } = pairContainer;
const ref = useRef<HTMLDivElement>(null);
const prompt = usePrompt();
const nameInputRef = useRef<EditorView>(null);
const valueInputRef = useRef<EditorView>(null);
useEffect(() => {
if (forceFocusPairId === pairContainer.id) {
if (forceFocusNamePairId === pairContainer.id) {
nameInputRef.current?.focus();
}
}, [forceFocusPairId, pairContainer.id]);
}, [forceFocusNamePairId, pairContainer.id]);
useEffect(() => {
if (forceFocusValuePairId === pairContainer.id) {
valueInputRef.current?.focus();
}
}, [forceFocusValuePairId, pairContainer.id]);
const handleChangeEnabled = useMemo(
() => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }),
@@ -374,6 +410,7 @@ function PairEditorRow({
ref={nameInputRef}
hideLabel
useTemplating
readOnly={pairContainer.pair.readOnlyName}
size="sm"
require={!isLast && !!pairContainer.pair.enabled && !!pairContainer.pair.value}
validate={nameValidate}
@@ -398,6 +435,7 @@ function PairEditorRow({
/>
) : (
<Input
ref={valueInputRef}
hideLabel
useTemplating
size="sm"
@@ -476,7 +514,14 @@ function PairEditorRow({
</RadioDropdown>
) : (
<Dropdown
items={[{ key: 'delete', label: 'Delete', onSelect: handleDelete, variant: 'danger' }]}
items={[
{
key: 'delete',
label: 'Delete',
onSelect: handleDelete,
variant: 'danger',
},
]}
>
<IconButton
iconSize="sm"

View File

@@ -1,15 +1,19 @@
import classNames from 'classnames';
import { forwardRef } from 'react';
import { useKeyValue } from '../../hooks/useKeyValue';
import { BulkPairEditor } from './BulkPairEditor';
import { IconButton } from './IconButton';
import type { PairEditorProps } from './PairEditor';
import type { PairEditorProps, PairEditorRef } from './PairEditor';
import { PairEditor } from './PairEditor';
interface Props extends PairEditorProps {
preferenceName: string;
}
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOrBulkEditor(
{ preferenceName, ...props }: Props,
ref,
) {
const { value: useBulk, set: setUseBulk } = useKeyValue<boolean>({
namespace: 'global',
key: ['bulk_edit', preferenceName],
@@ -18,7 +22,7 @@ export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
return (
<div className="relative h-full w-full group/wrapper">
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor ref={ref} {...props} />}
<div className="absolute right-0 bottom-0">
<IconButton
size="sm"
@@ -34,4 +38,4 @@ export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
</div>
</div>
);
}
});

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { memo, useEffect, useRef } from 'react';
import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
@@ -39,31 +39,23 @@ export function Tabs({
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const handleTabChange = useCallback(
(value: string) => {
const tabs = ref.current?.querySelectorAll<HTMLDivElement>(`[data-tab]`);
for (const tab of tabs ?? []) {
const v = tab.getAttribute('data-tab');
if (v === value) {
tab.setAttribute('tabindex', '-1');
tab.setAttribute('data-state', 'active');
tab.setAttribute('aria-hidden', 'false');
tab.style.display = 'block';
} else {
tab.setAttribute('data-state', 'inactive');
tab.setAttribute('aria-hidden', 'true');
tab.style.display = 'none';
}
}
onChangeValue(value);
},
[onChangeValue],
);
// Update tabs when value changes
useEffect(() => {
if (value === undefined) return;
handleTabChange(value);
}, [handleTabChange, value]);
const tabs = ref.current?.querySelectorAll<HTMLDivElement>(`[data-tab]`);
for (const tab of tabs ?? []) {
const v = tab.getAttribute('data-tab');
if (v === value) {
tab.setAttribute('tabindex', '-1');
tab.setAttribute('data-state', 'active');
tab.setAttribute('aria-hidden', 'false');
tab.style.display = 'block';
} else {
tab.setAttribute('data-state', 'inactive');
tab.setAttribute('aria-hidden', 'true');
tab.style.display = 'none';
}
}
}, [value]);
return (
<div
@@ -103,7 +95,7 @@ export function Tabs({
onChange={t.options.onChange}
>
<button
onClick={isActive ? undefined : () => handleTabChange(t.value)}
onClick={isActive ? undefined : () => onChangeValue(t.value)}
className={btnClassName}
>
{option && 'shortLabel' in option
@@ -121,7 +113,7 @@ export function Tabs({
return (
<button
key={t.value}
onClick={() => handleTabChange(t.value)}
onClick={() => onChangeValue(t.value)}
className={btnClassName}
>
{t.label}

View File

@@ -0,0 +1,37 @@
import type { HttpResponse } from '@yaakapp/api';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { isJSON, languageFromContentType } from '../../lib/contentType';
import { BinaryViewer } from './BinaryViewer';
import { TextViewer } from './TextViewer';
import { WebPageViewer } from './WebPageViewer';
interface Props {
response: HttpResponse;
pretty: boolean;
textViewerClassName?: string;
}
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
const rawBody = useResponseBodyText(response);
let language = languageFromContentType(useContentTypeFromHeaders(response.headers));
// A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so
if (language === 'html' && isJSON(rawBody.data ?? '')) {
language = 'json';
}
if (rawBody.isLoading) {
return null;
}
if (rawBody.data == null) {
return <BinaryViewer response={response} />;
}
if (language === 'html' && pretty) {
return <WebPageViewer response={response} />;
} else {
return <TextViewer response={response} pretty={pretty} className={textViewerClassName} />;
}
}

View File

@@ -1,3 +1,4 @@
import type { HttpResponse } from '@yaakapp/api';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
@@ -8,8 +9,8 @@ import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useSaveResponse } from '../../hooks/useSaveResponse';
import { useToggle } from '../../hooks/useToggle';
import { isJSON, languageFromContentType } from '../../lib/contentType';
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
import type { HttpResponse } from '@yaakapp/api';
import { CopyButton } from '../CopyButton';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
@@ -45,9 +46,15 @@ export function TextViewer({ response, pretty, className }: Props) {
[setFilterTextMap, response],
);
const saveResponse = useSaveResponse(response);
const contentType = useContentTypeFromHeaders(response.headers);
const rawBody = useResponseBodyText(response);
const saveResponse = useSaveResponse(response);
let language = languageFromContentType(useContentTypeFromHeaders(response.headers));
// A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so
if (language === 'html' && isJSON(rawBody.data ?? '')) {
language = 'json';
}
const isSearching = filterText != null;
const filteredResponse = useFilterResponse({
@@ -63,9 +70,7 @@ export function TextViewer({ response, pretty, className }: Props) {
}
}, [isSearching, setFilterText]);
const isJson = contentType?.includes('json');
const isXml = contentType?.includes('xml') || contentType?.includes('html');
const canFilter = isJson || isXml;
const canFilter = language === 'json' || language === 'xml' || language === 'html';
const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = [];
@@ -82,7 +87,7 @@ export function TextViewer({ response, pretty, className }: Props) {
autoFocus
containerClassName="bg-surface"
size="sm"
placeholder={isJson ? 'JSONPath expression' : 'XPath expression'}
placeholder={language === 'json' ? 'JSONPath expression' : 'XPath expression'}
label="Filter expression"
name="filter"
defaultValue={filterText}
@@ -109,8 +114,8 @@ export function TextViewer({ response, pretty, className }: Props) {
canFilter,
filterText,
filteredResponse.error,
isJson,
isSearching,
language,
response.id,
setFilterText,
toggleSearch,
@@ -153,9 +158,9 @@ export function TextViewer({ response, pretty, className }: Props) {
}
const formattedBody =
pretty && contentType?.includes('json')
pretty && language === 'json'
? tryFormatJson(rawBody.data)
: pretty && contentType?.includes('xml')
: pretty && (language === 'xml' || language === 'html')
? tryFormatXml(rawBody.data)
: rawBody.data;
@@ -176,7 +181,7 @@ export function TextViewer({ response, pretty, className }: Props) {
className={className}
forceUpdateKey={body}
defaultValue={body}
contentType={contentType}
language={language}
actions={actions}
extraExtensions={extraExtensions}
/>

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import type { HttpResponseHeader } from '@yaakapp/api';
import { useMemo } from 'react';
export function useContentTypeFromHeaders(headers: HttpResponseHeader[] | null): string | null {
return useMemo(

View File

@@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query';
import type { HttpResponse } from '@yaakapp/api';
import { useCopy } from './useCopy';
import { getResponseBodyText } from '../lib/responseBody';
export function useCopyHttpResponse(response: HttpResponse) {
const copy = useCopy();
return useMutation({
mutationKey: ['copy_http_response'],
async mutationFn() {
const body = await getResponseBodyText(response);
copy(body);
},
});
}

View File

@@ -13,7 +13,7 @@ export function useCreateDropdownItems({
}: {
hideFolder?: boolean;
hideIcons?: boolean;
folderId?: string;
folderId?: string | null;
} = {}): DropdownItem[] {
const createHttpRequest = useCreateHttpRequest();
const createGrpcRequest = useCreateGrpcRequest();

View File

@@ -2,13 +2,11 @@ import { useMutation } from '@tanstack/react-query';
import type { Folder } from '@yaakapp/api';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
import { useActiveRequest } from './useActiveRequest';
import { useActiveWorkspace } from './useActiveWorkspace';
import { usePrompt } from './usePrompt';
export function useCreateFolder() {
const workspace = useActiveWorkspace();
const activeRequest = useActiveRequest();
const prompt = usePrompt();
return useMutation<Folder, unknown, Partial<Pick<Folder, 'name' | 'sortPriority' | 'folderId'>>>({
@@ -29,7 +27,6 @@ export function useCreateFolder() {
placeholder: 'Name',
}));
patch.sortPriority = patch.sortPriority || -Date.now();
patch.folderId = patch.folderId || activeRequest?.folderId;
return invokeCmd('cmd_create_folder', { workspaceId: workspace.id, ...patch });
},
onSettled: () => trackEvent('folder', 'create'),

View File

@@ -0,0 +1,65 @@
import EventEmitter from 'eventemitter3';
import type { DependencyList } from 'react';
import { useCallback, useEffect } from 'react';
type EventDataMap = {
'request_params.focus_value': string;
'request_pane.focus_tab': undefined;
};
export function useRequestEditorEvent<
Event extends keyof EventDataMap,
Data extends EventDataMap[Event],
>(event: Event, fn: (data: Data) => void, deps?: DependencyList) {
useEffect(() => {
emitter.on(event, fn);
return () => {
emitter.off(event, fn);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
export function useRequestEditor() {
const focusParamsTab = useCallback(() => {
emitter.emit('request_pane.focus_tab', undefined);
}, []);
const focusParamValue = useCallback(
(name: string) => {
focusParamsTab();
setTimeout(() => emitter.emit('request_params.focus_value', name), 50);
},
[focusParamsTab],
);
return {
focusParamValue,
focusParamsTab,
};
}
const emitter = new (class RequestEditorEventEmitter {
#emitter: EventEmitter = new EventEmitter();
emit<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
event: Event,
data: Data,
) {
this.#emitter.emit(event, data);
}
on<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
event: Event,
fn: (data: Data) => void,
) {
this.#emitter.on(event, fn);
}
off<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
event: Event,
fn: (data: Data) => void,
) {
this.#emitter.off(event, fn);
}
})();

View File

@@ -4,7 +4,7 @@ import { getResponseBodyText } from '../lib/responseBody';
export function useResponseBodyText(response: HttpResponse) {
return useQuery<string | null>({
queryKey: ['response-body-text', response?.updatedAt],
queryKey: ['response-body-text', response.id, response?.updatedAt],
queryFn: () => getResponseBodyText(response),
});
}

View File

@@ -0,0 +1,25 @@
import type { EditorProps } from '../components/core/Editor';
export function languageFromContentType(contentType: string | null): EditorProps['language'] {
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
if (justContentType.includes('json')) {
return 'json';
} else if (justContentType.includes('xml')) {
return 'xml';
} else if (justContentType.includes('html')) {
return 'html';
} else if (justContentType.includes('javascript')) {
return 'javascript';
} else {
return 'text';
}
}
export function isJSON(text: string): boolean {
try {
JSON.parse(text);
return true;
} catch (_) {
return false;
}
}

View File

@@ -1,5 +1,4 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { attachConsole } from '@tauri-apps/plugin-log';
import { type } from '@tauri-apps/plugin-os';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
@@ -18,8 +17,6 @@ if (osType !== 'macos') {
await getCurrentWebviewWindow().setDecorations(false);
}
await attachConsole();
window.addEventListener('keydown', (e) => {
// Hack to not go back in history on backspace. Check for document body
// or else it will prevent backspace in input fields.

1
src-web/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />