Compare commits

..

25 Commits

Author SHA1 Message Date
Gregory Schier
0a6228bf16 Fix Input ref timing, PairEditor initialization, and environment variable focus 2025-11-04 14:04:12 -08:00
Gregory Schier
fa3a0b57f9 Fix Editor.tsx wonkiness 2025-11-04 13:44:18 -08:00
Gregory Schier
4390c02117 Fix gRPC message editing 2025-11-04 12:35:36 -08:00
Gregory Schier
77011176af Fix tab flexbox issue 2025-11-04 09:22:28 -08:00
Gregory Schier
759fc503d3 Fix accidental typing 2025-11-04 08:51:46 -08:00
Gregory Schier
0cb633e479 A bunch of fixes 2025-11-04 08:44:08 -08:00
Gregory Schier
81ceb981e8 Oops 2025-11-03 15:05:50 -08:00
Gregory Schier
4dae1a7955 Improve selecting items during filter 2025-11-03 15:04:02 -08:00
Gregory Schier
d119f4cab2 Fix confirm with text autofocus 2025-11-03 14:42:30 -08:00
Gregory Schier
7e1eb90d29 Show error when enabling encryption fails 2025-11-03 14:34:43 -08:00
Gregory Schier
bf97ea1659 Some sidebar fixes 2025-11-03 14:17:11 -08:00
Gregory Schier
749ca968ec Fix environment sorting 2025-11-03 13:53:41 -08:00
Gregory Schier
0c54b481fb Fix unused variable 2025-11-03 13:29:47 -08:00
Jeroen Van den Berghe
4943bad8ec Import query parameters from Insomnia v4 and v5 exports (#290) 2025-11-03 13:03:24 -08:00
Gregory Schier
450dbd0053 Better syntax highlighting for filter expressions 2025-11-03 06:30:41 -08:00
Gregory Schier
236c8fa656 Fix sidebar reselection after dragging non-selelected item or renaming 2025-11-03 06:19:04 -08:00
Gregory Schier
1dfc2ee602 Support encoding values to base64 (url safe) 2025-11-03 06:07:34 -08:00
Gregory Schier
1d158082f6 Pass host environment variable to plugin runtime
https://feedback.yaak.app/p/when-i-use-clash-yaak-fails-to-launch
2025-11-03 06:02:18 -08:00
Gregory Schier
f3e44c53d7 Show full paths in command palette switcher
https://feedback.yaak.app/p/command-palette-search-should-include-parent-folder-names
2025-11-03 05:54:29 -08:00
Gregory Schier
c8d5e7c97b Add support for API key authentication in cURL conversion
https://feedback.yaak.app/p/copy-as-curl-without-api-key
2025-11-03 05:05:54 -08:00
Gregory Schier
9bde6bbd0a More efficient editor state saves 2025-11-02 06:16:45 -08:00
Gregory Schier
df5be218a5 Remove debug console logs from Input component 2025-11-02 05:52:56 -08:00
Gregory Schier
2deb870bb6 Fix pair editor 2025-11-02 05:52:36 -08:00
Gregory Schier
0f9975339c Fixes for last commit 2025-11-01 09:33:57 -07:00
Gregory Schier
6ad4e7bbb5 Click env var to edit AND improve input/editor ref handling 2025-11-01 08:39:07 -07:00
55 changed files with 942 additions and 645 deletions

View File

@@ -8,10 +8,15 @@ if (!port) {
throw new Error('Plugin runtime missing PORT')
}
const host = process.env.HOST;
if (!host) {
throw new Error('Plugin runtime missing HOST')
}
const pluginToAppEvents = new EventChannel();
const plugins: Record<string, PluginHandle> = {};
const ws = new WebSocket(`ws://localhost:${port}`);
const ws = new WebSocket(`ws://${host}:${port}`);
ws.on('message', async (e: Buffer) => {
try {

View File

@@ -43,6 +43,26 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
finalUrl = base + separator + queryString + (hash ? `#${hash}` : '');
}
// Add API key authentication
if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'query') {
const sep = request.url?.includes('?') ? '&' : '?';
finalUrl = [
finalUrl,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
} else {
request.headers = request.headers ?? [];
request.headers.push({
name: request.authentication?.key ?? 'X-Api-Key',
value: request.authentication?.value ?? '',
});
}
}
xs.push(quote(finalUrl));
xs.push(NEWLINE);

View File

@@ -27,6 +27,7 @@ describe('exporter-curl', () => {
}),
).toEqual([`curl 'https://yaak.app/path?a=aaa&b=bbb#section'`].join(` \\n `));
});
test('Exports POST with url form data', async () => {
expect(
await convertToCurl({
@@ -305,6 +306,94 @@ describe('exporter-curl', () => {
);
});
test('API key auth header', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'header',
key: 'X-Header',
value: 'my-token'
},
}),
).toEqual(
[
`curl 'https://yaak.app'`,
`--header 'X-Header: my-token'`,
].join(` \\\n `),
);
});
test('API key auth header default', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'header',
},
}),
).toEqual(
[
`curl 'https://yaak.app'`,
`--header 'X-Api-Key: '`,
].join(` \\\n `),
);
});
test('API key auth query', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'foo',
value: 'bar-baz'
},
}),
).toEqual(
[
`curl 'https://yaak.app?foo=bar-baz'`,
].join(` \\\n `),
);
});
test('API key auth query with existing', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?foo=bar&baz=qux',
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'hi',
value: 'there'
},
}),
).toEqual(
[
`curl 'https://yaak.app?foo=bar&baz=qux&hi=there'`,
].join(` \\\n `),
);
});
test('API key auth query default', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?foo=bar&baz=qux',
authenticationType: 'apikey',
authentication: {
location: 'query',
},
}),
).toEqual(
[
`curl 'https://yaak.app?foo=bar&baz=qux&token='`,
].join(` \\\n `),
);
});
test('Stale body data', async () => {
expect(
await convertToCurl({

View File

@@ -122,6 +122,12 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
name: r.name,
description: r.description || undefined,
url: convertSyntax(r.url),
urlParameters: (r.parameters ?? [])
.map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
})),
body,
bodyType,
authentication,

View File

@@ -125,6 +125,12 @@ function importHttpRequest(
name: r.name,
description: r.meta?.description || undefined,
url: convertSyntax(r.url),
urlParameters: (r.parameters ?? [])
.map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
})),
body,
bodyType,
method: r.method,

View File

@@ -113,6 +113,13 @@
"model": "http_request",
"name": "New Request",
"url": "${[BASE_URL ]}/foo/:id",
"urlParameters": [
{
"name": "query",
"value": "qqq",
"enabled": true
}
],
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
}
],

View File

@@ -76,6 +76,7 @@
"sortPriority": -1747414129276,
"updatedAt": "2025-05-16T16:48:49.313",
"url": "https://httpbin.org/post",
"urlParameters": [],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
},
{
@@ -98,6 +99,7 @@
"name": "New Request",
"sortPriority": -1747414160498,
"updatedAt": "2025-05-16T16:49:20.497",
"urlParameters": [],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
],

View File

@@ -135,6 +135,13 @@
"name": "New Request",
"sortPriority": -1736781406672,
"url": "${[BASE_URL ]}/foo/:id",
"urlParameters": [
{
"name": "query",
"value": "qqq",
"enabled": true
}
],
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],

View File

@@ -5,9 +5,29 @@ export const plugin: PluginDefinition = {
{
name: 'base64.encode',
description: 'Encode a value to base64',
args: [{ label: 'Plain Text', type: 'text', name: 'value', multiLine: true }],
args: [
{
label: 'Encoding',
type: 'select',
name: 'encoding',
defaultValue: 'base64',
options: [
{
label: 'Base64',
value: 'base64',
},
{
label: 'Base64 URL-safe',
value: 'base64url',
},
],
},
{ label: 'Plain Text', type: 'text', name: 'value', multiLine: true },
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
return Buffer.from(String(args.values.value ?? '')).toString('base64');
return Buffer.from(String(args.values.value ?? '')).toString(
args.values.encoding === 'base64url' ? 'base64url' : 'base64',
);
},
},
{

View File

@@ -86,7 +86,7 @@ impl YaakNotifier {
#[cfg(feature = "license")]
let license_check = {
use yaak_license::{check_license, LicenseCheckStatus};
use yaak_license::{LicenseCheckStatus, check_license};
match check_license(window).await {
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(),
Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(),
@@ -139,6 +139,7 @@ async fn get_kv<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<String>> {
}
}
#[allow(unused)]
fn get_updater_status<R: Runtime>(app_handle: &AppHandle<R>) -> &'static str {
#[cfg(not(feature = "updater"))]
{

View File

@@ -3,7 +3,7 @@ use crate::window_menu::app_menu;
use log::{info, warn};
use rand::random;
use tauri::{
AppHandle, Emitter, LogicalSize, Manager, Runtime, WebviewUrl, WebviewWindow, WindowEvent,
AppHandle, Emitter, LogicalSize, Manager, PhysicalSize, Runtime, WebviewUrl, WebviewWindow, WindowEvent
};
use tauri_plugin_opener::OpenerExt;
use tokio::sync::mpsc;
@@ -160,6 +160,11 @@ pub(crate) fn create_window<R: Runtime>(
"dev.reset_size" => webview_window
.set_size(LogicalSize::new(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT))
.unwrap(),
"dev.reset_size_record" => {
let width = webview_window.outer_size().unwrap().width;
let height = width * 9 / 16;
webview_window.set_size(PhysicalSize::new(width, height)).unwrap()
}
"dev.refresh" => webview_window.eval("location.reload()").unwrap(),
"dev.generate_theme_css" => {
w.emit("generate_theme_css", true).unwrap();

View File

@@ -143,6 +143,8 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
.build(app_handle)?,
&MenuItemBuilder::with_id("dev.reset_size".to_string(), "Reset Size")
.build(app_handle)?,
&MenuItemBuilder::with_id("dev.reset_size_record".to_string(), "Reset Size 16x9")
.build(app_handle)?,
&MenuItemBuilder::with_id(
"dev.generate_theme_css".to_string(),
"Generate Theme CSS",

View File

@@ -8,7 +8,7 @@ import { newStoreData } from './util';
export const modelStoreDataAtom = atom(newStoreData());
export const cookieJarsAtom = createOrderedModelAtom('cookie_jar', 'name', 'asc');
export const environmentsAtom = createOrderedModelAtom('environment', 'name', 'asc');
export const environmentsAtom = createOrderedModelAtom('environment', 'sortPriority', 'asc');
export const foldersAtom = createModelAtom('folder');
export const grpcConnectionsAtom = createOrderedModelAtom('grpc_connection', 'createdAt', 'desc');
export const grpcEventsAtom = createOrderedModelAtom('grpc_event', 'createdAt', 'asc');

View File

@@ -38,14 +38,12 @@ impl<'a> DbContext<'a> {
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
match stmt.query_row(&*params.as_params(), M::from_row) {
Ok(result) => Ok(result),
Err(rusqlite::Error::QueryReturnedNoRows) => {
Err(ModelNotFound(format!(
r#"table "{}" {} == {}"#,
M::table_name().into_iden().to_string(),
col.into_iden().to_string(),
value_debug
)))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Err(ModelNotFound(format!(
r#"table "{}" {} == {}"#,
M::table_name().into_iden().to_string(),
col.into_iden().to_string(),
value_debug
))),
Err(e) => Err(crate::error::Error::SqlError(e)),
}
}
@@ -69,7 +67,7 @@ impl<'a> DbContext<'a> {
.expect("Failed to run find on DB")
}
pub fn find_all<'s, M>(&self) -> crate::error::Result<Vec<M>>
pub fn find_all<'s, M>(&self) -> Result<Vec<M>>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{
@@ -117,7 +115,7 @@ impl<'a> DbContext<'a> {
Ok(items.map(|v| v.unwrap()).collect())
}
pub fn upsert<M>(&self, model: &M, source: &UpdateSource) -> crate::error::Result<M>
pub fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
where
M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,
{
@@ -139,7 +137,7 @@ impl<'a> DbContext<'a> {
other_values: Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>,
update_columns: Vec<impl IntoIden>,
source: &UpdateSource,
) -> crate::error::Result<M>
) -> Result<M>
where
M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,
{
@@ -178,7 +176,7 @@ impl<'a> DbContext<'a> {
Ok(m)
}
pub(crate) fn delete<'s, M>(&self, m: &M, source: &UpdateSource) -> crate::error::Result<M>
pub(crate) fn delete<'s, M>(&self, m: &M, source: &UpdateSource) -> Result<M>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{

View File

@@ -24,6 +24,7 @@ pub async fn start_nodejs_plugin_runtime<R: Runtime>(
let cmd = app
.shell()
.sidecar("yaaknode")?
.env("HOST", addr.ip().to_string())
.env("PORT", addr.port().to_string())
.args(&[&plugin_runtime_main]);

View File

@@ -30,7 +30,10 @@ import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { showDialog } from '../lib/dialog';
import { editEnvironment } from '../lib/editEnvironment';
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
import { resolvedModelNameWithFolders } from '../lib/resolvedModelName';
import {
resolvedModelNameWithFolders,
resolvedModelNameWithFoldersArray,
} from '../lib/resolvedModelName';
import { router } from '../lib/router';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { CookieDialog } from './CookieDialog';
@@ -40,7 +43,6 @@ import { HotKey } from './core/HotKey';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
import { HStack } from './core/Stacks';
interface CommandPaletteGroup {
key: string;
@@ -275,10 +277,17 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
key: `switch-request-${r.id}`,
searchText: resolvedModelNameWithFolders(r),
label: (
<HStack space={2}>
<HttpMethodTag short className="text-xs" request={r} />
<div className="truncate">{resolvedModelNameWithFolders(r)}</div>
</HStack>
<div className="flex items-center gap-x-0.5">
<HttpMethodTag short className="text-xs mr-2" request={r} />
{resolvedModelNameWithFoldersArray(r).map((name, i, all) => (
<>
{i !== 0 && (
<Icon icon="chevron_right" className="opacity-80"/>
)}
<div className={classNames(i < all.length - 1 && 'truncate')}>{name}</div>
</>
))}
</div>
),
onSelect: async () => {
await router.navigate({
@@ -400,9 +409,10 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
);
return (
<div className="h-full w-[400px] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden py-2">
<div className="h-full w-[min(700px,80vw)] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden py-2">
<div className="px-2 w-full">
<PlainInput
autoFocus
hideLabel
leftSlot={
<div className="h-md w-10 flex justify-center items-center">

View File

@@ -1,12 +1,13 @@
import { useAtomValue } from 'jotai';
import React from 'react';
import type { ComponentType } from 'react';
import React, { useCallback } from 'react';
import { dialogsAtom, hideDialog } from '../lib/dialog';
import { Dialog, type DialogProps } from './core/Dialog';
import { ErrorBoundary } from './ErrorBoundary';
export type DialogInstance = {
id: string;
render: ({ hide }: { hide: () => void }) => React.ReactNode;
render: ComponentType<{ hide: () => void }>;
} & Omit<DialogProps, 'open' | 'children'>;
export function Dialogs() {
@@ -20,19 +21,20 @@ export function Dialogs() {
);
}
function DialogInstance({ render, onClose, id, ...props }: DialogInstance) {
const children = render({ hide: () => hideDialog(id) });
function DialogInstance({ render: Component, onClose, id, ...props }: DialogInstance) {
const hide = useCallback(() => {
hideDialog(id);
}, [id]);
const handleClose = useCallback(() => {
onClose?.();
hideDialog(id);
}, [id, onClose]);
return (
<ErrorBoundary name={`Dialog ${id}`}>
<Dialog
open
onClose={() => {
onClose?.();
hideDialog(id);
}}
{...props}
>
{children}
<Dialog open onClose={handleClose} {...props}>
<Component hide={hide} {...props} />
</Dialog>
</ErrorBoundary>
);

View File

@@ -10,7 +10,7 @@ import {
} from '../hooks/useEnvironmentsBreakdown';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai';
import { isBaseEnvironment } from '../lib/model_util';
import { isBaseEnvironment, isSubEnvironment } from '../lib/model_util';
import { resolvedModelName } from '../lib/resolvedModelName';
import { showColorPicker } from '../lib/showColorPicker';
import { Banner } from './core/Banner';
@@ -19,6 +19,7 @@ import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode';
import type { PairEditorHandle } from './core/PairEditor';
import { SplitLayout } from './core/SplitLayout';
import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree';
@@ -28,15 +29,16 @@ import { EnvironmentEditor } from './EnvironmentEditor';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
interface Props {
initialEnvironment: Environment | null;
initialEnvironmentId: string | null;
setRef?: (ref: PairEditorHandle | null) => void;
}
type TreeModel = Environment | Workspace;
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
initialEnvironmentId ?? null,
);
const selectedEnvironment =
@@ -76,16 +78,21 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
</Banner>
</div>
) : (
<EnvironmentEditor className="pl-4 pt-3" environment={selectedEnvironment} />
<EnvironmentEditor
setRef={setRef}
className="pl-4 pt-3"
environment={selectedEnvironment}
/>
)}
</div>
)}
/>
);
};
}
const sharableTooltip = (
<IconTooltip
tabIndex={-1}
icon="eye"
iconSize="sm"
content="This environment will be included in Directory Sync and data exports"
@@ -163,7 +170,18 @@ function EnvironmentEditDialogSidebar({
const getContextMenu = useCallback(
(items: TreeModel[]): ContextMenuProps['items'] => {
const environment = items[0];
if (environment == null || environment.model !== 'environment') return [];
const addEnvironmentItem: DropdownItem = {
label: 'Create Sub Environment',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createSubEnvironment();
},
};
if (environment == null || environment.model !== 'environment') {
return [addEnvironmentItem];
}
const singleEnvironment = items.length === 1;
const menuItems: DropdownItem[] = [
@@ -199,6 +217,7 @@ function EnvironmentEditDialogSidebar({
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: <EnvironmentSharableTooltip />,
hidden: items.length > 1,
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
@@ -208,22 +227,18 @@ function EnvironmentEditDialogSidebar({
label: 'Delete',
hotKeyAction: 'sidebar.selected.delete',
hotKeyLabelOnly: true,
hidden: !(isBaseEnvironment(environment) && baseEnvironments.length > 1),
hidden:
(isBaseEnvironment(environment) && baseEnvironments.length <= 1) ||
!isSubEnvironment(environment),
leftSlot: <Icon icon="trash" />,
onSelect: () => handleDeleteEnvironment(environment),
},
];
// Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment) {
menuItems.push({ type: 'separator' });
menuItems.push({
label: 'Create Sub Environment',
leftSlot: <Icon icon="plus" />,
hidden: !isBaseEnvironment(environment),
onSelect: async () => {
await createSubEnvironment();
},
});
menuItems.push(addEnvironmentItem);
}
return menuItems;
@@ -324,12 +339,7 @@ const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
const parent = root.children?.[0];
if (baseEnvironments.length <= 1 && parent != null) {
const sortedEnvironments = [...subEnvironments].sort((a, b) => {
if (a.sortPriority === b.sortPriority) return a.updatedAt > b.updatedAt ? 1 : -1;
else return a.sortPriority - b.sortPriority;
});
parent.children = sortedEnvironments.map((item) => ({
parent.children = subEnvironments.map((item) => ({
item,
parent,
depth: 1,

View File

@@ -2,7 +2,7 @@ import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import React, { useCallback, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
import { useKeyValue } from '../hooks/useKeyValue';
@@ -17,21 +17,20 @@ import { BadgeButton } from './core/BadgeButton';
import { DismissibleBanner } from './core/DismissibleBanner';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import type { PairWithId } from './core/PairEditor';
import type { PairEditorHandle, PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor.util';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
export function EnvironmentEditor({
environment,
hideName,
className,
}: {
interface Props {
environment: Environment;
hideName?: boolean;
className?: string;
}) {
setRef?: (n: PairEditorHandle | null) => void;
}
export function EnvironmentEditor({ environment, hideName, className, setRef }: Props) {
const workspaceId = environment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
@@ -98,10 +97,19 @@ export function EnvironmentEditor({
};
return (
<div className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3')}>
<div
className={classNames(
className,
'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3',
)}
>
<div className="flex flex-col gap-4">
<Heading className="w-full flex items-center gap-0.5">
<EnvironmentColorIndicator className="mr-2" clickToEdit environment={environment ?? null} />
<EnvironmentColorIndicator
className="mr-2"
clickToEdit
environment={environment ?? null}
/>
{!hideName && <div className="mr-2">{environment?.name}</div>}
{isEncryptionEnabled ? (
!allVariableAreEncrypted ? (
@@ -146,6 +154,7 @@ export function EnvironmentEditor({
)}
</div>
<PairOrBulkEditor
setRef={setRef}
className="h-full"
allowMultilineValues
preferenceName="environment"

View File

@@ -10,7 +10,7 @@ import {
stateExtensions,
updateSchema,
} from 'codemirror-json-schema';
import { useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { showAlert } from '../lib/alert';
import { showDialog } from '../lib/dialog';
@@ -40,6 +40,9 @@ export function GrpcEditor({
...extraEditorProps
}: Props) {
const editorViewRef = useRef<EditorView>(null);
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
editorViewRef.current = h;
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
@@ -167,6 +170,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
setRef={handleInitEditorViewRef}
language="json"
autocompleteFunctions
autocompleteVariables
@@ -174,7 +178,6 @@ export function GrpcEditor({
defaultValue={request.message}
heightMode="auto"
placeholder="..."
ref={editorViewRef}
extraExtensions={extraExtensions}
actions={actions}
stateKey={`grpc_message.${request.id}`}

View File

@@ -1,18 +1,23 @@
import { type } from '@tauri-apps/plugin-os';
import { settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import React, { useMemo } from 'react';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from '../lib/constants';
import { WindowControls } from './WindowControls';
import { type } from "@tauri-apps/plugin-os";
import { settingsAtom } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
import React, { useMemo } from "react";
import { useIsFullscreen } from "../hooks/useIsFullscreen";
import {
HEADER_SIZE_LG,
HEADER_SIZE_MD,
WINDOW_CONTROLS_WIDTH,
} from "../lib/constants";
import { WindowControls } from "./WindowControls";
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
size: 'md' | 'lg';
size: "md" | "lg";
ignoreControlsSpacing?: boolean;
onlyXWindowControl?: boolean;
hideControls?: boolean;
}
export function HeaderSize({
@@ -22,6 +27,7 @@ export function HeaderSize({
ignoreControlsSpacing,
onlyXWindowControl,
children,
hideControls,
}: HeaderSizeProps) {
const settings = useAtomValue(settingsAtom);
const isFullscreen = useIsFullscreen();
@@ -29,10 +35,10 @@ export function HeaderSize({
const s = { ...style };
// Set the height (use min-height because scaling font size may make it larger
if (size === 'md') s.minHeight = HEADER_SIZE_MD;
if (size === 'lg') s.minHeight = HEADER_SIZE_LG;
if (size === "md") s.minHeight = HEADER_SIZE_MD;
if (size === "lg") s.minHeight = HEADER_SIZE_LG;
if (type() === 'macos') {
if (type() === "macos") {
if (!isFullscreen) {
// Add large padding for window controls
s.paddingLeft = 72 / settings.interfaceScale;
@@ -57,21 +63,21 @@ export function HeaderSize({
style={finalStyle}
className={classNames(
className,
'pt-[1px]', // Make up for bottom border
'select-none relative',
'w-full border-b border-border-subtle min-w-0',
"pt-[1px]", // Make up for bottom border
"select-none relative",
"w-full border-b border-border-subtle min-w-0",
)}
>
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
<div
className={classNames(
'pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid',
'px-1', // Give it some space on either end for focus outlines
"pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid",
"px-1", // Give it some space on either end for focus outlines
)}
>
{children}
</div>
<WindowControls onlyX={onlyXWindowControl} />
{!hideControls && <WindowControls onlyX={onlyXWindowControl} />}
</div>
);
}

View File

@@ -354,7 +354,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
isLoading={activeResponse != null && activeResponse.state !== 'closed'}
/>
<Tabs
key={activeRequest.id} // Freshen tabs on request change
value={activeTab}
label="Request"
onChangeValue={setActiveTab}

View File

@@ -52,21 +52,13 @@ export function Overlay({
{open && (
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true, // So we can still click toasts and things
// Allow outside click so we can click things like toasts
allowOutsideClick: true,
delayInitialFocus: true,
initialFocus: () =>
// Doing this explicitly seems to work better than the default behavior for some reason
containerRef.current?.querySelector<HTMLElement>(
[
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable]:not([contenteditable="false"])',
].join(', '),
) ?? false,
checkCanFocusTrap: async () => {
// Not sure why delayInitialFocus: true doesn't help, but having this no-op promise
// seems to be required to make things work.
},
}}
>
<m.div

View File

@@ -1,7 +1,6 @@
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import React from 'react';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { appInfo } from '../../lib/appInfo';
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';

View File

@@ -21,8 +21,7 @@ import {
import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useKey } from 'react-use';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { moveToWorkspace } from '../commands/moveToWorkspace';
import { openFolderSettings } from '../commands/openFolderSettings';
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
@@ -57,7 +56,7 @@ import { InlineCode } from './core/InlineCode';
import type { InputHandle } from './core/Input';
import { Input } from './core/Input';
import { LoadingIcon } from './core/LoadingIcon';
import { collapsedFamily, isSelectedFamily } from './core/tree/atoms';
import { collapsedFamily, isSelectedFamily, selectedIdsFamily } from './core/tree/atoms';
import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree';
import { Tree } from './core/tree/Tree';
@@ -77,6 +76,9 @@ function Sidebar({ className }: { className?: string }) {
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
const setFilterRef = useCallback((h: InputHandle | null) => {
filterRef.current = h;
}, []);
const allHidden = useMemo(() => {
if (tree?.children?.length === 0) return false;
else if (filterText) return tree?.children?.every((c) => c.hidden);
@@ -147,7 +149,10 @@ function Sidebar({ className }: { className?: string }) {
await Promise.all(
items.map((m, i) =>
// Spread item sortPriority out over before/after range
patchModel(m, { sortPriority: beforePriority + (i + 1) * increment, folderId }),
patchModel(m, {
sortPriority: beforePriority + (i + 1) * increment,
folderId,
}),
),
);
}
@@ -156,22 +161,18 @@ function Sidebar({ className }: { className?: string }) {
}
}, []);
const handleTreeRefInit = useCallback((n: TreeHandle) => {
treeRef.current = n;
if (n == null) return;
const activeId = jotaiStore.get(activeIdAtom);
if (activeId == null) return;
n.selectItem(activeId);
}, []);
// Ensure active id is always selected when it changes
useEffect(() => {
return jotaiStore.sub(activeIdAtom, () => {
const handleTreeRefInit = useCallback(
(n: TreeHandle) => {
treeRef.current = n;
if (n == null) return;
const activeId = jotaiStore.get(activeIdAtom);
if (activeId == null) return;
treeRef.current?.selectItem(activeId);
});
}, []);
const selectedIds = jotaiStore.get(selectedIdsFamily(treeId));
if (selectedIds.length > 0) return;
n.selectItem(activeId);
},
[treeId],
);
const clearFilterText = useCallback(() => {
jotaiStore.set(sidebarFilterAtom, { text: '', key: `${Math.random()}` });
@@ -199,14 +200,6 @@ function Sidebar({ className }: { className?: string }) {
[],
);
// Focus the first sidebar item on arrow down from filter
useKey('ArrowDown', (e) => {
if (e.key === 'ArrowDown' && filterRef.current?.isFocused()) {
e.preventDefault();
treeRef.current?.focus();
}
});
const actions = useMemo(() => {
const enable = () => treeRef.current?.hasFocus() ?? false;
@@ -286,7 +279,11 @@ function Sidebar({ className }: { className?: string }) {
// No children means we're in the root
if (child == null) {
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
return getCreateDropdownItems({
workspaceId,
activeRequest: null,
folderId: null,
});
}
const workspaces = jotaiStore.get(workspacesAtom);
@@ -350,12 +347,19 @@ function Sidebar({ className }: { className?: string }) {
items.length === 1 && child.model === 'folder'
? [
{ type: 'separator' },
...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: child.id }),
...getCreateDropdownItems({
workspaceId,
activeRequest: null,
folderId: child.id,
}),
]
: [];
const menuItems: ContextMenuProps['items'] = [
...initialItems,
{ type: 'separator', hidden: initialItems.filter((v) => !v.hidden).length === 0 },
{
type: 'separator',
hidden: initialItems.filter((v) => !v.hidden).length === 0,
},
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
@@ -416,7 +420,9 @@ function Sidebar({ className }: { className?: string }) {
const view = filterRef.current;
if (!view) return;
const ext = filter({ fields: allFields ?? [] });
view.dispatch({ effects: filterLanguageCompartmentRef.current.reconfigure(ext) });
view.dispatch({
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
});
}, [allFields]);
if (tree == null || hidden) {
@@ -429,12 +435,12 @@ function Sidebar({ className }: { className?: string }) {
aria-hidden={hidden ?? undefined}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)_auto]')}
>
<div className="px-3 pt-3 grid grid-cols-[1fr_auto] items-center -mr-2.5">
<div className="w-full px-3 pt-3 grid grid-cols-[minmax(0,1fr)_auto] items-center -mr-2.5">
{(tree.children?.length ?? 0) > 0 && (
<>
<Input
hideLabel
ref={filterRef}
setRef={setFilterRef}
size="sm"
label="filter"
language={null} // Explicitly disable
@@ -449,7 +455,7 @@ function Sidebar({ className }: { className?: string }) {
rightSlot={
filterText.text && (
<IconButton
className="!h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x"
title="Clear filter"
onClick={clearFilterText}
@@ -546,7 +552,10 @@ const allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
const sidebarFilterAtom = atom<{ text: string; key: string }>({ text: '', key: '' });
const sidebarFilterAtom = atom<{ text: string; key: string }>({
text: '',
key: '',
});
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
@@ -575,21 +584,24 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true;
const fields = getItemFields(node.item);
const fields = getItemFields(node);
const model = node.item.model;
const isLeafNode = !(model === 'folder' || model === 'workspace');
for (const [field, value] of Object.entries(fields)) {
if (!value) continue;
allFields[field] = allFields[field] ?? new Set();
allFields[field].add(value);
}
if (queryAst != null) {
matchesSelf = evaluate(queryAst, { text: getItemText(node.item), fields });
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
}
let matchesChild = false;
// Recurse to children
const m = node.item.model;
node.children = m === 'folder' || m === 'workspace' ? [] : undefined;
node.children = !isLeafNode ? [] : undefined;
if (node.children != null) {
childItems.sort((a, b) => {
@@ -618,7 +630,7 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
const root: TreeNode<SidebarModel> = {
item: activeWorkspace,
parent: null,
parent: null,
children: [],
depth: 0,
};
@@ -628,7 +640,10 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
const fields: FieldDef[] = [];
for (const [name, values] of Object.entries(allFields)) {
fields.push({ name, values: Array.from(values).filter((v) => v.length < 20) });
fields.push({
name,
values: Array.from(values).filter((v) => v.length < 20),
});
}
return [root, fields] as const;
});
@@ -711,7 +726,9 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
);
});
function getItemFields(item: SidebarModel): Record<string, string> {
function getItemFields(node: TreeNode<SidebarModel>): Record<string, string> {
const item = node.item;
if (item.model === 'workspace') return {};
const fields: Record<string, string> = {};
@@ -731,9 +748,20 @@ function getItemFields(item: SidebarModel): Record<string, string> {
if (item.model === 'grpc_request') fields.type = 'grpc';
else if (item.model === 'websocket_request') fields.type = 'ws';
if (node.parent?.item.model === 'folder') {
fields.folder = node.parent.item.name;
}
return fields;
}
function getItemText(item: SidebarModel): string {
return resolvedModelName(item);
const segments = [];
if (item.model === 'http_request') {
segments.push(item.method);
}
segments.push(resolvedModelName(item));
return segments.join(' ');
}

View File

@@ -1,7 +1,7 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { FormEvent, ReactNode } from 'react';
import { memo, useRef, useState } from 'react';
import { useCallback, memo, useRef, useState } from 'react';
import { useHotKey } from '../hooks/useHotKey';
import type { IconProps } from './core/Icon';
import { IconButton } from './core/IconButton';
@@ -46,6 +46,10 @@ export const UrlBar = memo(function UrlBar({
const inputRef = useRef<InputHandle>(null);
const [isFocused, setIsFocused] = useState<boolean>(false);
const handleInitInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
useHotKey('url_bar.focus', () => {
inputRef.current?.selectAll();
});
@@ -59,7 +63,7 @@ export const UrlBar = memo(function UrlBar({
return (
<form onSubmit={handleSubmit} className={classNames('x-theme-urlBar', className)}>
<Input
ref={inputRef}
setRef={handleInitInputRef}
autocompleteFunctions
autocompleteVariables
stateKey={stateKey}

View File

@@ -1,7 +1,7 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import { useRef } from 'react';
import { useCallback, useRef } from 'react';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import type { PairEditorProps, PairEditorRef } from './core/PairEditor';
import type { PairEditorHandle, PairEditorProps } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { VStack } from './core/Stacks';
@@ -13,15 +13,19 @@ type Props = {
};
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
const pairEditor = useRef<PairEditorRef>(null);
const pairEditorRef = useRef<PairEditorHandle>(null);
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
return (pairEditorRef.current = ref);
}, []);
const [{ urlParametersKey }] = useRequestEditor();
useRequestEditorEvent(
'request_params.focus_value',
(name) => {
const pairIndex = pairs.findIndex((p) => p.name === name);
if (pairIndex >= 0) {
pairEditor.current?.focusValue(pairIndex);
const pair = pairs.find((p) => p.name === name);
if (pair?.id != null) {
pairEditorRef.current?.focusValue(pair.id);
} else {
console.log(`Couldn't find pair to focus`, { name, pairs });
}
@@ -32,7 +36,7 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
return (
<VStack className="h-full">
<PairOrBulkEditor
ref={pairEditor}
setRef={handleInitPairEditorRef}
allowMultilineValues
forceUpdateKey={forceUpdateKey + urlParametersKey}
nameAutocompleteFunctions

View File

@@ -229,7 +229,6 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
/>
</div>
<Tabs
key={activeRequest.id} // Freshen tabs on request change
value={activeTab}
label="Request"
onChangeValue={setActiveTab}

View File

@@ -141,11 +141,11 @@ export function Workspace() {
animate={{ opacity: 1, x: 0 }}
className={classNames(
'x-theme-sidebar',
'absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[14rem]',
'absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[20rem]',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize size="lg" className="border-transparent">
<HeaderSize hideControls size="lg" className="border-transparent flex items-center">
<SidebarActions />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">

View File

@@ -26,6 +26,7 @@ interface Props {
export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
@@ -111,12 +112,22 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
color={expanded ? 'info' : 'secondary'}
size={size}
onClick={async () => {
setJustEnabledEncryption(true);
await enableEncryption(workspaceMeta.workspaceId);
setError(null);
try {
await enableEncryption(workspaceMeta.workspaceId);
setJustEnabledEncryption(true);
} catch (err) {
setError('Failed to enable encryption: ' + err);
}
}}
>
Enable Encryption
</Button>
{error && (
<Banner color="danger" className="mb-2">
{error}
</Banner>
)}
{expanded ? (
<Banner color="info" className="mb-6">
<EncryptionHelp />

View File

@@ -1,10 +1,10 @@
import type { Color } from '@yaakapp-internal/plugins';
import type { FormEvent } from 'react';
import { useState } from 'react';
import { CopyIconButton } from '../CopyIconButton';
import { Button } from './Button';
import { PlainInput } from './PlainInput';
import { HStack } from './Stacks';
import type { Color } from "@yaakapp-internal/plugins";
import type { FormEvent } from "react";
import { useState } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Button } from "./Button";
import { PlainInput } from "./PlainInput";
import { HStack } from "./Stacks";
export interface ConfirmProps {
onHide: () => void;
@@ -19,9 +19,9 @@ export function Confirm({
onResult,
confirmText,
requireTyping,
color = 'primary',
color = "primary",
}: ConfirmProps) {
const [confirm, setConfirm] = useState<string>('');
const [confirm, setConfirm] = useState<string>("");
const handleHide = () => {
onResult(false);
onHide();
@@ -46,6 +46,7 @@ export function Confirm({
placeholder={requireTyping}
labelRightSlot={
<CopyIconButton
tabIndex={-1}
text={requireTyping}
title="Copy name"
className="text-text-subtlest"
@@ -60,9 +61,13 @@ export function Confirm({
}
/>
)}
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<HStack
space={2}
justifyContent="start"
className="mt-2 mb-4 flex-row-reverse"
>
<Button type="submit" color={color} disabled={!didConfirm}>
{confirmText ?? 'Confirm'}
{confirmText ?? "Confirm"}
</Button>
<Button onClick={handleHide} variant="border">
Cancel

View File

@@ -17,17 +17,16 @@ import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { ReactNode, RefObject } from 'react';
import {
useEffect,
Children,
cloneElement,
forwardRef,
isValidElement,
useCallback,
useImperativeHandle,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
import { activeWorkspaceAtom } from '../../../hooks/useActiveWorkspace';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
@@ -40,7 +39,6 @@ import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { jotaiStore } from '../../../lib/jotai';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
import { InlineCode } from '../InlineCode';
import { HStack } from '../Stacks';
@@ -97,6 +95,7 @@ export interface EditorProps {
tooltipContainer?: HTMLElement;
type?: 'text' | 'password';
wrapLines?: boolean;
setRef?: (view: EditorView | null) => void;
}
const stateFields = { history: historyField, folds: foldState };
@@ -104,41 +103,39 @@ const stateFields = { history: historyField, folds: foldState };
const emptyVariables: WrappedEnvironmentVariable[] = [];
const emptyExtension: Extension = [];
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
actions,
autoFocus,
autoSelect,
autocomplete,
autocompleteFunctions,
autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey: forceUpdateKeyFromAbove,
format,
heightMode,
hideGutter,
graphQLSchema,
language,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
singleLine,
stateKey,
type,
wrapLines,
}: EditorProps,
ref,
) {
export function Editor({
actions,
autoFocus,
autoSelect,
autocomplete,
autocompleteFunctions,
autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey: forceUpdateKeyFromAbove,
format,
heightMode,
hideGutter,
graphQLSchema,
language,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
singleLine,
stateKey,
type,
wrapLines,
setRef,
}: EditorProps) {
const settings = useAtomValue(settingsAtom);
const allEnvironmentVariables = useEnvironmentVariables(forcedEnvironmentId ?? null);
@@ -182,7 +179,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
}
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view, []);
// Use ref so we can update the handler without re-initializing the editor
const handleChange = useRef<EditorProps['onChange']>(onChange);
@@ -324,33 +320,17 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const onClickVariable = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async (v: WrappedEnvironmentVariable, _tagValue: string, _startPos: number) => {
editEnvironment(v.environment);
await editEnvironment(v.environment, { addOrFocusVariable: v.variable });
},
[],
);
const onClickMissingVariable = useCallback(
async (_name: string, tagValue: string, startPos: number) => {
const initialTokens = parseTemplate(tagValue);
showDialog({
size: 'dynamic',
id: 'template-variable',
title: 'Configure Variable',
render: ({ hide }) => (
<TemplateVariableDialog
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
},
[],
);
const onClickMissingVariable = useCallback(async (name: string) => {
const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
await editEnvironment(activeEnvironment, {
addOrFocusVariable: { name, value: '', enabled: true },
});
}, []);
const [, { focusParamValue }] = useRequestEditor();
const onClickPathParameter = useCallback(
@@ -469,22 +449,15 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
setRef?.(view);
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
[forceUpdateKey],
);
// Update editor doc when force update key changes
useEffect(() => {
if (cm.current?.view != null) {
updateContents(cm.current.view, defaultValue || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceUpdateKey]);
// For read-only mode, update content when `defaultValue` changes
useEffect(
function updateReadOnlyEditor() {
@@ -588,7 +561,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
)}
</div>
);
});
}
function getExtensions({
stateKey,
@@ -648,15 +621,13 @@ function getExtensions({
// Things that must be last //
// ------------------------ //
// Fire onChange event
EditorView.updateListener.of((update) => {
if (update.startState === update.state) return;
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
// Cache editor state
EditorView.updateListener.of((update) => {
saveCachedEditorState(stateKey, update.state);
}),
];

View File

@@ -1,13 +1,12 @@
import type { EditorView } from '@codemirror/view';
import { forwardRef, lazy, Suspense } from 'react';
import { lazy, Suspense } from 'react';
import type { EditorProps } from './Editor';
const Editor_ = lazy(() => import('./Editor').then((m) => ({ default: m.Editor })));
export const Editor = forwardRef<EditorView, EditorProps>(function LazyEditor(props, ref) {
export function Editor(props: EditorProps) {
return (
<Suspense>
<Editor_ ref={ref} {...props} />
<Editor_ {...props} />
</Suspense>
);
});
}

View File

@@ -62,7 +62,7 @@ export const syntaxHighlightStyle = HighlightStyle.define([
textDecoration: 'underline',
},
{
tag: [t.paren, t.bracket, t.squareBracket, t.brace, t.separator],
tag: [t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
color: 'var(--textSubtle)',
},
{

View File

@@ -112,7 +112,6 @@ function fieldValueCompletions(
): Completion[] | null {
if (!def || !def.values) return null;
const vals = Array.isArray(def.values) ? def.values : def.values();
// console.log("HELLO", v, v.match(IDENT));
return vals.map((v) => ({
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v,

View File

@@ -66,7 +66,6 @@ FieldName {
FieldValue {
Phrase
| Term
| Group
}
Term {

View File

@@ -3,9 +3,9 @@ import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#pQPO,58zOVQPO'#CrO#}QPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
stateData: "$`~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~OXQO]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
goto: "#hgPPhnryP!YPP!c!o!xPP#RP!cPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd^TOQUWacdRi__TOQUWacd_SOQUWacdRj_Q]PRf]QcWRlcQeXRne",
states: "%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
stateData: "$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne",
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
maxTerm: 25,
nodeProps: [
@@ -18,6 +18,6 @@ export const parser = LRParser.deserialize({
tokenData: ")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
tokenizers: [0],
topRules: {"Query":[0,1]},
tokenPrec: 148
tokenPrec: 145
})

View File

@@ -14,11 +14,8 @@ export const highlight = styleTags({
// Literals
Phrase: t.string, // "quoted string"
Term: t.variableName, // bare terms like foo, bar
// Fields
'FieldName/Word': t.tagName,
// Grouping
Group: t.paren,
'FieldName/Word': t.attributeName,
'FieldValue/Term/Word': t.attributeValue,
});

View File

@@ -2,15 +2,7 @@ import type { EditorView } from '@codemirror/view';
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createFastMutation } from '../../hooks/useFastMutation';
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
@@ -80,6 +72,7 @@ export type InputProps = Pick<
type?: 'text' | 'password';
validate?: boolean | ((v: string) => boolean);
wrapLines?: boolean;
setRef?: (h: InputHandle | null) => void;
};
export interface InputHandle {
@@ -90,61 +83,64 @@ export interface InputHandle {
dispatch: EditorView['dispatch'];
}
export const Input = forwardRef<InputHandle, InputProps>(function Input({ type, ...props }, ref) {
export function Input({ type, ...props }: InputProps) {
// If it's a password and template functions are supported (ie. secure(...)) then
// use the encrypted input component.
if (type === 'password' && props.autocompleteFunctions) {
return <EncryptionInput {...props} />;
} else {
return <BaseInput ref={ref} type={type} {...props} />;
return <BaseInput type={type} {...props} />;
}
});
}
const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
{
className,
containerClassName,
defaultValue,
disableObscureToggle,
disabled,
forceUpdateKey,
fullHeight,
help,
hideLabel,
inputWrapperClassName,
label,
labelClassName,
labelPosition = 'top',
leftSlot,
multiLine,
onBlur,
onChange,
onFocus,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
required,
rightSlot,
size = 'md',
stateKey,
tint,
type = 'text',
validate,
wrapLines,
...props
}: InputProps,
ref,
) {
function BaseInput({
className,
containerClassName,
defaultValue,
disableObscureToggle,
disabled,
forceUpdateKey,
fullHeight,
help,
hideLabel,
inputWrapperClassName,
label,
labelClassName,
labelPosition = 'top',
leftSlot,
multiLine,
onBlur,
onChange,
onFocus,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
required,
rightSlot,
size = 'md',
stateKey,
tint,
type = 'text',
validate,
wrapLines,
setRef,
...props
}: InputProps) {
const [focused, setFocused] = useState(false);
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const editorRef = useRef<EditorView | null>(null);
const skipNextFocus = useRef<boolean>(false);
const inputHandle = useMemo<InputHandle>(
const handle = useMemo<InputHandle>(
() => ({
focus: () => {
editorRef.current?.focus();
if (editorRef.current == null) return;
const anchor = editorRef.current.state.doc.length;
skipNextFocus.current = true;
editorRef.current.focus();
editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true });
},
isFocused: () => editorRef.current?.hasFocus ?? false,
value: () => editorRef.current?.state.doc.toString() ?? '',
@@ -153,21 +149,26 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
editorRef.current?.dispatch(...(args as any));
},
selectAll() {
const head = editorRef.current?.state.doc.length ?? 0;
editorRef.current?.dispatch({
selection: { anchor: 0, head },
if (editorRef.current == null) return;
editorRef.current.focus();
editorRef.current.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
editorRef.current?.focus();
},
}),
[],
);
useImperativeHandle(ref, (): InputHandle => inputHandle, [inputHandle]);
const setEditorRef = useCallback(
(h: EditorView | null) => {
editorRef.current = h;
setRef?.(handle);
},
[handle, setRef],
);
const lastWindowFocus = useRef<number>(0);
useEffect(() => {
const fn = () => (lastWindowFocus.current = Date.now());
const fn = () => (skipNextFocus.current = true);
window.addEventListener('focus', fn);
return () => {
window.removeEventListener('focus', fn);
@@ -177,11 +178,7 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
const handleFocus = useCallback(() => {
if (readOnly) return;
// Select all text of input when it's focused to match standard browser behavior.
// This should not, however, select when the input is focused due to a window focus event, so
// we handle that case as well.
const windowJustFocused = Date.now() - lastWindowFocus.current < 200;
if (!windowJustFocused) {
if (!skipNextFocus.current) {
editorRef.current?.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
@@ -189,6 +186,7 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
setFocused(true);
onFocus?.();
skipNextFocus.current = false;
}, [onFocus, readOnly]);
const handleBlur = useCallback(async () => {
@@ -300,7 +298,7 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
)}
>
<Editor
ref={editorRef}
setRef={setEditorRef}
id={id.current}
hideGutter
singleLine={!multiLine}
@@ -351,7 +349,7 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
</HStack>
</div>
);
});
}
function validateRequire(v: string) {
return v.length > 0;
@@ -365,8 +363,9 @@ function EncryptionInput({
autocompleteFunctions,
autocompleteVariables,
forceUpdateKey: ogForceUpdateKey,
setRef,
...props
}: Omit<InputProps, 'type'>) {
}: InputProps) {
const isEncryptionEnabled = useIsEncryptionEnabled();
const [state, setState] = useStateWithDeps<{
fieldType: PasswordFieldType;
@@ -374,11 +373,19 @@ function EncryptionInput({
security: ReturnType<typeof analyzeTemplate> | null;
obscured: boolean;
error: string | null;
}>({ fieldType: 'text', value: null, security: null, obscured: true, error: null }, [
ogForceUpdateKey,
]);
}>(
{
fieldType: isEncryptionEnabled ? 'encrypted' : 'text',
value: null,
security: null,
obscured: true,
error: null,
},
[ogForceUpdateKey],
);
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
const inputRef = useRef<InputHandle>(null);
useEffect(() => {
if (state.value != null) {
@@ -392,6 +399,9 @@ function EncryptionInput({
templateToInsecure.mutate(defaultValue ?? '', {
onSuccess: (value) => {
setState({ fieldType: 'encrypted', security, value, obscured: true, error: null });
// We're calling this here because we want the input to be fully initialized so the caller
// can do stuff like change the selection.
requestAnimationFrame(() => setRef?.(inputRef.current));
},
onError: (value) => {
setState({
@@ -406,6 +416,7 @@ function EncryptionInput({
} else if (isEncryptionEnabled && !defaultValue) {
// Default to encrypted field for new encrypted inputs
setState({ fieldType: 'encrypted', security, value: '', obscured: true, error: null });
requestAnimationFrame(() => setRef?.(inputRef.current));
} else if (isEncryptionEnabled) {
// Don't obscure plain text when encryption is enabled
setState({
@@ -415,6 +426,7 @@ function EncryptionInput({
obscured: false,
error: null,
});
requestAnimationFrame(() => setRef?.(inputRef.current));
} else {
// Don't obscure plain text when encryption is disabled
setState({
@@ -424,8 +436,9 @@ function EncryptionInput({
obscured: true,
error: null,
});
requestAnimationFrame(() => setRef?.(inputRef.current));
}
}, [defaultValue, isEncryptionEnabled, setState, state.value]);
}, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]);
const handleChange = useCallback(
(value: string, fieldType: PasswordFieldType) => {
@@ -454,6 +467,10 @@ function EncryptionInput({
[handleChange, state],
);
const setInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
const handleFieldTypeChange = useCallback(
(newFieldType: PasswordFieldType) => {
const { value, fieldType } = state;
@@ -563,6 +580,7 @@ function EncryptionInput({
return (
<BaseInput
setRef={setInputRef}
disableObscureToggle
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}

View File

@@ -10,16 +10,7 @@ import {
useSensors,
} from '@dnd-kit/core';
import classNames from 'classnames';
import {
forwardRef,
Fragment,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { WrappedEnvironmentVariable } from '../../hooks/useEnvironmentVariables';
import { useRandomKey } from '../../hooks/useRandomKey';
import { useToggle } from '../../hooks/useToggle';
@@ -41,12 +32,12 @@ import { IconButton } from './IconButton';
import type { InputHandle, InputProps } from './Input';
import { Input } from './Input';
import { ensurePairId } from './PairEditor.util';
import { PlainInput } from './PlainInput';
import type { RadioDropdownItem } from './RadioDropdown';
import { RadioDropdown } from './RadioDropdown';
export interface PairEditorRef {
focusValue(index: number): void;
export interface PairEditorHandle {
focusName(id: string): void;
focusValue(id: string): void;
}
export type PairEditorProps = {
@@ -64,6 +55,7 @@ export type PairEditorProps = {
onChange: (pairs: PairWithId[]) => void;
pairs: Pair[];
stateKey: InputProps['stateKey'];
setRef?: (n: PairEditorHandle) => void;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean | 'environment';
@@ -87,35 +79,31 @@ export type PairWithId = Pair & {
};
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 50;
const MAX_INITIAL_PAIRS = 30;
export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function PairEditor(
{
allowFileValues,
allowMultilineValues,
className,
forcedEnvironmentId,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteFunctions,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
noScroll,
onChange,
pairs: originalPairs,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
valueValidate,
}: PairEditorProps,
ref,
) {
const [forceFocusNamePairId, setForceFocusNamePairId] = useState<string | null>(null);
const [forceFocusValuePairId, setForceFocusValuePairId] = useState<string | null>(null);
export function PairEditor({
allowFileValues,
allowMultilineValues,
className,
forcedEnvironmentId,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteFunctions,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
noScroll,
onChange,
pairs: originalPairs,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
valueValidate,
setRef,
}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
const [pairs, setPairs] = useState<PairWithId[]>([]);
@@ -124,15 +112,35 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey();
useImperativeHandle(
ref,
const rowsRef = useRef<Record<string, RowHandle | null>>({});
const handle = useMemo<PairEditorHandle>(
() => ({
focusValue(index: number) {
const id = pairs[index]?.id ?? 'n/a';
setForceFocusValuePairId(id);
focusName(id: string) {
rowsRef.current[id]?.focusName();
},
focusValue(id: string) {
rowsRef.current[id]?.focusValue();
},
}),
[pairs],
[],
);
const initPairEditorRow = useCallback(
(id: string, n: RowHandle | null) => {
const isLast = id === pairs[pairs.length - 1]?.id;
if (isLast) return; // Never add the last pair
rowsRef.current[id] = n;
const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
// NOTE: Ignore the last placeholder pair
const ready = validHandles.length === pairs.length - 1;
if (ready) {
setRef?.(handle);
}
},
[handle, pairs, setRef],
);
useEffect(() => {
@@ -179,42 +187,28 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
if (focusPrevious) {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index - 1]?.id ?? null;
setForceFocusNamePairId(id);
rowsRef.current[id ?? 'n/a']?.focusName();
}
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
},
[setPairsAndSave, setForceFocusNamePairId, pairs],
[setPairsAndSave, pairs],
);
const handleFocusName = useCallback((pair: Pair) => {
setForceFocusNamePairId(null); // Remove focus override when something focused
setForceFocusValuePairId(null); // Remove focus override when something focused
setPairs((pairs) => {
const handleFocusName = useCallback(
(pair: Pair) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) {
const prevPair = pairs[pairs.length - 1];
setTimeout(() => setForceFocusNamePairId(prevPair?.id ?? null));
return [...pairs, emptyPair()];
} else {
return pairs;
}
});
}, []);
if (isLast) setPairs([...pairs, emptyPair()]);
},
[pairs],
);
const handleFocusValue = useCallback((pair: Pair) => {
setForceFocusNamePairId(null); // Remove focus override when something focused
setForceFocusValuePairId(null); // Remove focus override when something focused
setPairs((pairs) => {
const handleFocusValue = useCallback(
(pair: Pair) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) {
const prevPair = pairs[pairs.length - 1];
setTimeout(() => setForceFocusValuePairId(prevPair?.id ?? null));
return [...pairs, emptyPair()];
} else {
return pairs;
}
});
}, []);
if (isLast) setPairs([...pairs, emptyPair()]);
},
[pairs],
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
@@ -282,77 +276,89 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
'-mr-2 pr-2',
// Pad to make room for the drag divider
'pt-0.5',
'grid grid-rows-[auto_1fr]',
)}
>
<DndContext
autoScroll
sensors={sensors}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={pointerWithin}
>
{pairs.map((p, i) => {
if (!showAll && i > MAX_INITIAL_PAIRS) return null;
<div>
<DndContext
autoScroll
sensors={sensors}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={pointerWithin}
>
{pairs.map((p, i) => {
if (!showAll && i > MAX_INITIAL_PAIRS) return null;
const isLast = i === pairs.length - 1;
return (
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
<PairEditorRow
allowFileValues={allowFileValues}
allowMultilineValues={allowMultilineValues}
className="py-1"
forcedEnvironmentId={forcedEnvironmentId}
forceFocusNamePairId={forceFocusNamePairId}
forceFocusValuePairId={forceFocusValuePairId}
forceUpdateKey={localForceUpdateKey}
index={i}
isLast={isLast}
isDraggingGlobal={!!isDragging}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions={nameAutocompleteFunctions}
nameAutocompleteVariables={nameAutocompleteVariables}
namePlaceholder={namePlaceholder}
nameValidate={nameValidate}
onChange={handleChange}
onDelete={handleDelete}
onFocusName={handleFocusName}
onFocusValue={handleFocusValue}
pair={p}
stateKey={stateKey}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions={valueAutocompleteFunctions}
valueAutocompleteVariables={valueAutocompleteVariables}
valuePlaceholder={valuePlaceholder}
valueType={valueType}
valueValidate={valueValidate}
/>
</Fragment>
);
})}
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
Show {pairs.length - MAX_INITIAL_PAIRS} More
</Button>
)}
<DragOverlay dropAnimation={null}>
{isDragging && (
<PairEditorRow
namePlaceholder={namePlaceholder}
valuePlaceholder={valuePlaceholder}
className="opacity-80"
pair={isDragging}
index={0}
stateKey={null}
/>
const isLast = i === pairs.length - 1;
return (
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
<PairEditorRow
setRef={initPairEditorRow}
allowFileValues={allowFileValues}
allowMultilineValues={allowMultilineValues}
className="py-1"
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={localForceUpdateKey}
index={i}
isLast={isLast}
isDraggingGlobal={!!isDragging}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions={nameAutocompleteFunctions}
nameAutocompleteVariables={nameAutocompleteVariables}
namePlaceholder={namePlaceholder}
nameValidate={nameValidate}
onChange={handleChange}
onDelete={handleDelete}
onFocusName={handleFocusName}
onFocusValue={handleFocusValue}
pair={p}
stateKey={stateKey}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions={valueAutocompleteFunctions}
valueAutocompleteVariables={valueAutocompleteVariables}
valuePlaceholder={valuePlaceholder}
valueType={valueType}
valueValidate={valueValidate}
/>
</Fragment>
);
})}
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
Show {pairs.length - MAX_INITIAL_PAIRS} More
</Button>
)}
</DragOverlay>
</DndContext>
<DragOverlay dropAnimation={null}>
{isDragging && (
<PairEditorRow
namePlaceholder={namePlaceholder}
valuePlaceholder={valuePlaceholder}
className="opacity-80"
pair={isDragging}
index={0}
stateKey={null}
/>
)}
</DragOverlay>
</DndContext>
</div>
<div
// There's a weird bug where clicking below one of the above Codemirror inputs will cause
// it to focus. Putting this element here prevents that
aria-hidden
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</div>
);
});
}
type PairEditorRowProps = {
className?: string;
@@ -369,6 +375,7 @@ type PairEditorRowProps = {
disableDrag?: boolean;
index: number;
isDraggingGlobal?: boolean;
setRef?: (id: string, n: RowHandle | null) => void;
} & Pick<
PairEditorProps,
| 'allowFileValues'
@@ -389,14 +396,17 @@ type PairEditorRowProps = {
| 'valueValidate'
>;
interface RowHandle {
focusName(): void;
focusValue(): void;
}
export function PairEditorRow({
allowFileValues,
allowMultilineValues,
className,
disableDrag,
disabled,
forceFocusNamePairId,
forceFocusValuePairId,
forceUpdateKey,
forcedEnvironmentId,
index,
@@ -419,21 +429,38 @@ export function PairEditorRow({
valuePlaceholder,
valueType,
valueValidate,
setRef,
}: PairEditorRowProps) {
const nameInputRef = useRef<InputHandle>(null);
const valueInputRef = useRef<InputHandle>(null);
useEffect(() => {
if (forceFocusNamePairId === pair.id) {
const handle = useRef<RowHandle>({
focusName() {
nameInputRef.current?.focus();
}
}, [forceFocusNamePairId, pair.id]);
useEffect(() => {
if (forceFocusValuePairId === pair.id) {
},
focusValue() {
valueInputRef.current?.focus();
}
}, [forceFocusValuePairId, pair.id]);
},
});
const initNameInputRef = useCallback(
(n: InputHandle | null) => {
nameInputRef.current = n;
if (nameInputRef.current && valueInputRef.current) {
setRef?.(pair.id, handle.current);
}
},
[pair.id, setRef],
);
const initValueInputRef = useCallback(
(n: InputHandle | null) => {
valueInputRef.current = n;
if (nameInputRef.current && valueInputRef.current) {
setRef?.(pair.id, handle.current);
}
},
[pair.id, setRef],
);
const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]);
const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]);
@@ -559,44 +586,29 @@ export function PairEditorRow({
'gap-0.5 grid-cols-1 grid-rows-2',
)}
>
{isLast ? (
// Use PlainInput for last ones because there's a unique bug where clicking below
// the Codemirror input focuses it.
<PlainInput
hideLabel
size="sm"
containerClassName={classNames(isLast && 'border-dashed')}
className={classNames(isDraggingGlobal && 'pointer-events-none')}
label="Name"
name={`name[${index}]`}
onFocus={handleFocusName}
placeholder={namePlaceholder ?? 'name'}
/>
) : (
<Input
ref={nameInputRef}
hideLabel
stateKey={`name.${pair.id}.${stateKey}`}
disabled={disabled}
wrapLines={false}
readOnly={pair.readOnlyName || isDraggingGlobal}
size="sm"
required={!isLast && !!pair.enabled && !!pair.value}
validate={nameValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
containerClassName={classNames('bg-surface', isLast && 'border-dashed')}
defaultValue={pair.name}
label="Name"
name={`name[${index}]`}
onChange={handleChangeName}
onFocus={handleFocusName}
placeholder={namePlaceholder ?? 'name'}
autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
autocompleteFunctions={nameAutocompleteFunctions}
/>
)}
<Input
setRef={initNameInputRef}
hideLabel
stateKey={`name.${pair.id}.${stateKey}`}
disabled={disabled}
wrapLines={false}
readOnly={pair.readOnlyName || isDraggingGlobal}
size="sm"
required={!isLast && !!pair.enabled && !!pair.value}
validate={nameValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
containerClassName={classNames('bg-surface', isLast && 'border-dashed')}
defaultValue={pair.name}
label="Name"
name={`name[${index}]`}
onChange={handleChangeName}
onFocus={handleFocusName}
placeholder={namePlaceholder ?? 'name'}
autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
autocompleteFunctions={nameAutocompleteFunctions}
/>
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
{pair.isFile ? (
<SelectFile
@@ -606,20 +618,6 @@ export function PairEditorRow({
filePath={pair.value}
onChange={handleChangeValueFile}
/>
) : isLast ? (
// Use PlainInput for last ones because there's a unique bug where clicking below
// the Codemirror input focuses it.
<PlainInput
hideLabel
disabled={disabled}
size="sm"
containerClassName={classNames(isLast && 'border-dashed')}
label="Value"
name={`value[${index}]`}
className={classNames(isDraggingGlobal && 'pointer-events-none')}
onFocus={handleFocusValue}
placeholder={valuePlaceholder ?? 'value'}
/>
) : pair.value.includes('\n') ? (
<Button
color="secondary"
@@ -632,7 +630,7 @@ export function PairEditorRow({
</Button>
) : (
<Input
ref={valueInputRef}
setRef={initValueInputRef}
hideLabel
stateKey={`value.${pair.id}.${stateKey}`}
wrapLines={false}

View File

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

View File

@@ -10,6 +10,7 @@ import {
} from 'react';
import { useRandomKey } from '../../hooks/useRandomKey';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { generateId } from '../../lib/generateId';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
import { Label } from './Label';
@@ -99,7 +100,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
}
}, [regenerateFocusedUpdateKey, defaultValue]);
const id = `input-${name}`;
const id = useRef(`input-${generateId()}`);
const commonClassName = classNames(
className,
'!bg-transparent min-w-0 w-full focus:outline-none placeholder:text-placeholder',
@@ -134,7 +135,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
)}
>
<Label
htmlFor={id}
htmlFor={id.current}
className={labelClassName}
visuallyHidden={hideLabel}
required={required}
@@ -177,10 +178,11 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
)}
>
<input
id={id}
id={id.current}
ref={inputRef}
key={forceUpdateKey}
type={type === 'password' && !obscured ? 'text' : type}
name={name}
defaultValue={defaultValue ?? undefined}
autoComplete="off"
autoCapitalize="off"

View File

@@ -142,7 +142,7 @@ export function Tabs({
>
<Button
rightSlot={
<>
<div className="flex items-center">
{t.rightSlot}
<Icon
size="sm"
@@ -152,7 +152,7 @@ export function Tabs({
isActive ? 'text-text-subtle' : 'text-text-subtlest',
)}
/>
</>
</div>
}
{...btnProps}
>

View File

@@ -11,7 +11,7 @@ import {
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from 'react';
import React, {
import {
forwardRef,
memo,
useCallback,
@@ -146,13 +146,24 @@ function TreeInner<T extends { id: string }>(
const ensureTabbableItem = useCallback(() => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
const lastSelectedItem = selectableItems.find(
(i) => i.node.item.id === lastSelectedId && !i.node.hidden,
);
// If no item found, default to selecting the first item (prefer leaf node);
if (lastSelectedItem == null) {
return false;
const firstLeafItem = selectableItems.find((i) => !i.node.hidden && i.node.children == null);
const firstItem = firstLeafItem ?? selectableItems.find((i) => !i.node.hidden);
if (firstItem != null) {
const id = firstItem.node.item.id;
jotaiStore.set(selectedIdsFamily(treeId), [id]);
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
}
return;
}
const closest = closestVisibleNode(treeId, lastSelectedItem.node);
if (closest != null && closest !== lastSelectedItem.node) {
if (closest != null) {
const id = closest.item.id;
jotaiStore.set(selectedIdsFamily(treeId), [id]);
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
@@ -168,7 +179,6 @@ function TreeInner<T extends { id: string }>(
// Ensure there's always a tabbable item after render
useEffect(() => {
requestAnimationFrame(ensureTabbableItem);
ensureTabbableItem();
});
const setSelected = useCallback(
@@ -187,6 +197,10 @@ function TreeInner<T extends { id: string }>(
hasFocus: () => treeRef.current?.contains(document.activeElement) ?? false,
renameItem: (id) => treeItemRefs.current[id]?.rename(),
selectItem: (id) => {
if (jotaiStore.get(selectedIdsFamily(treeId)).includes(id)) {
// Already selected
return;
}
setSelected([id], false);
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
},
@@ -233,8 +247,10 @@ function TreeInner<T extends { id: string }>(
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
if (shiftKey) {
const anchorIndex = selectableItems.findIndex((i) => i.node.item.id === anchorSelectedId);
const currIndex = selectableItems.findIndex((v) => v.node.item.id === item.id);
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
const anchorIndex = validSelectableItems.findIndex((i) => i.node.item.id === anchorSelectedId);
const currIndex = validSelectableItems.findIndex((v) => v.node.item.id === item.id);
// Nothing was selected yet, so just select this item
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
setSelected([item.id], true);
@@ -242,7 +258,6 @@ function TreeInner<T extends { id: string }>(
return;
}
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
if (currIndex > anchorIndex) {
// Selecting down
const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1);
@@ -428,6 +443,18 @@ function TreeInner<T extends { id: string }>(
return;
}
// Root is anything past the end of the list, so set it to the end
const hoveringRoot = over.id === root.item.id;
if (hoveringRoot) {
jotaiStore.set(hoveredParentFamily(treeId), {
parentId: root.item.id,
parentDepth: root.depth,
index: selectableItems.length,
childIndex: selectableItems.length,
});
return;
}
const overSelectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
if (overSelectableItem == null) {
return;
@@ -446,18 +473,6 @@ function TreeInner<T extends { id: string }>(
}
}
// Root is anything past the end of the list, so set it to the end
const hoveringRoot = over.id === root.item.id;
if (hoveringRoot) {
jotaiStore.set(hoveredParentFamily(treeId), {
parentId: root.item.id,
parentDepth: root.depth,
index: selectableItems.length,
childIndex: selectableItems.length,
});
return;
}
const node = overSelectableItem.node;
const side = computeSideForDragMove(node.item.id, e);
@@ -487,7 +502,12 @@ function TreeInner<T extends { id: string }>(
childIndex === existing.childIndex
)
) {
jotaiStore.set(hoveredParentFamily(treeId), { parentId, parentDepth, index, childIndex });
jotaiStore.set(hoveredParentFamily(treeId), {
parentId,
parentDepth,
index,
childIndex,
});
}
},
[root.depth, root.item.id, selectableItems, treeId],
@@ -510,7 +530,11 @@ function TreeInner<T extends { id: string }>(
if (activeItem != null) {
jotaiStore.set(draggingIdsFamily(treeId), [activeItem.id]);
// Also update selection to just be this one
handleSelect(activeItem, { shiftKey: false, metaKey: false, ctrlKey: false });
handleSelect(activeItem, {
shiftKey: false,
metaKey: false,
ctrlKey: false,
});
}
}
},
@@ -632,8 +656,8 @@ function TreeInner<T extends { id: string }>(
onDragEnd={handleDragEnd}
onDragCancel={clearDragState}
onDragAbort={clearDragState}
measuring={measuring}
onDragMove={handleDragMove}
measuring={measuring}
autoScroll
>
<div
@@ -650,7 +674,6 @@ function TreeInner<T extends { id: string }>(
'[&_.tree-item.selected_.tree-item-inner]:text-text',
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
'[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight',
// Round the items, but only if the ends of the selection.
// Also account for the drop marker being in between items
'[&_.tree-item]:rounded-md',

View File

@@ -31,7 +31,7 @@ export type TreeItemProps<T extends { id: string }> = Pick<
onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
depth: number;
addRef?: (item: T, n: TreeItemHandle | null) => void;
setRef?: (item: T, n: TreeItemHandle | null) => void;
};
export interface TreeItemHandle {
@@ -54,7 +54,7 @@ function TreeItem_<T extends { id: string }>({
getEditOptions,
className,
depth,
addRef,
setRef,
}: TreeItemProps<T>) {
const listItemRef = useRef<HTMLLIElement>(null);
const draggableRef = useRef<HTMLButtonElement>(null);
@@ -86,8 +86,8 @@ function TreeItem_<T extends { id: string }>({
);
useEffect(() => {
addRef?.(node.item, handle);
}, [addRef, handle, node.item]);
setRef?.(node.item, handle);
}, [setRef, handle, node.item]);
const ancestorIds = useMemo(() => {
const ids: string[] = [];

View File

@@ -35,7 +35,7 @@ export function TreeItemList<T extends { id: string }>({
<Fragment key={getItemKey(child.node.item)}>
<TreeItem
treeId={treeId}
addRef={addTreeItemRef}
setRef={addTreeItemRef}
node={child.node}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}

View File

@@ -1,9 +1,8 @@
import type { EditorView } from '@codemirror/view';
import type { HttpRequest } from '@yaakapp-internal/models';
import { formatSdl } from 'format-graphql';
import { useAtom } from 'jotai';
import { useMemo, useRef } from 'react';
import { useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useIntrospectGraphQL } from '../../hooks/useIntrospectGraphQL';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
@@ -26,7 +25,6 @@ type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> &
};
export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorProps }: Props) {
const editorViewRef = useRef<EditorView>(null);
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
Record<string, boolean>
>('graphQLAutoIntrospectDisabled', {});
@@ -199,7 +197,6 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
defaultValue={currentBody.query}
onChange={handleChangeQuery}
placeholder="..."
ref={editorViewRef}
actions={actions}
stateKey={'graphql_body.' + request.id}
{...extraEditorProps}

View File

@@ -132,6 +132,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
language={language}
actions={actions}
extraExtensions={extraExtensions}
// State key for storing fold state
stateKey={'response.body.' + response.id}
/>
);

View File

@@ -4,7 +4,18 @@ import { atom, useAtomValue } from 'jotai';
export const environmentsBreakdownAtom = atom((get) => {
const allEnvironments = get(environmentsAtom);
const baseEnvironments = allEnvironments.filter((e) => e.parentModel === 'workspace') ?? [];
const subEnvironments = allEnvironments.filter((e) => e.parentModel === 'environment') ?? [];
const subEnvironments =
allEnvironments
.filter((e) => e.parentModel === 'environment')
?.sort((a, b) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
} else {
return a.sortPriority - b.sortPriority;
}
}) ?? [];
const folderEnvironments =
allEnvironments.filter((e) => e.parentModel === 'folder' && e.parentId != null) ?? [];

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo } from 'react';
import { jotaiStore } from '../lib/jotai';
import { getKeyValue, setKeyValue } from '../lib/keyValueStore';
import { activeCookieJarAtom } from './useActiveCookieJar';
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
import { useKeyValue } from './useKeyValue';
const kvKey = (workspaceId: string) => 'recent_cookie_jars::' + workspaceId;
@@ -13,9 +12,8 @@ const fallback: string[] = [];
export function useRecentCookieJars() {
const cookieJars = useAtomValue(cookieJarsAtom);
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
const kv = useKeyValue<string[]>({
key: kvKey(activeWorkspaceId ?? 'n/a'),
key: kvKey(cookieJars[0]?.workspaceId ?? 'n/a'),
namespace,
fallback,
});
@@ -31,18 +29,16 @@ export function useRecentCookieJars() {
export function useSubscribeRecentCookieJars() {
useEffect(() => {
return jotaiStore.sub(activeCookieJarAtom, async () => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const activeCookieJarId = jotaiStore.get(activeCookieJarAtom)?.id ?? null;
if (activeWorkspaceId == null) return;
if (activeCookieJarId == null) return;
const activeCookieJar = jotaiStore.get(activeCookieJarAtom);
if (activeCookieJar == null) return;
const key = kvKey(activeWorkspaceId);
const key = kvKey(activeCookieJar.workspaceId);
const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeCookieJarId) return; // Short-circuit
if (recentIds[0] === activeCookieJar.id) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeCookieJarId);
const value = [activeCookieJarId, ...withoutActiveId];
const withoutActiveId = recentIds.filter((id) => id !== activeCookieJar.id);
const value = [activeCookieJar.id, ...withoutActiveId];
await setKeyValue({ namespace, key, value });
});
}, []);

View File

@@ -1,9 +1,7 @@
import { useAtomValue } from 'jotai';
import { useEffect, useMemo } from 'react';
import { jotaiStore } from '../lib/jotai';
import { getKeyValue, setKeyValue } from '../lib/keyValueStore';
import { activeEnvironmentIdAtom } from './useActiveEnvironment';
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from './useActiveWorkspace';
import { activeEnvironmentAtom } from './useActiveEnvironment';
import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown';
import { useKeyValue } from './useKeyValue';
@@ -12,10 +10,9 @@ const namespace = 'global';
const fallback: string[] = [];
export function useRecentEnvironments() {
const { subEnvironments } = useEnvironmentsBreakdown();
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const { subEnvironments, allEnvironments } = useEnvironmentsBreakdown();
const kv = useKeyValue<string[]>({
key: kvKey(activeWorkspace?.id ?? 'n/a'),
key: kvKey(allEnvironments[0]?.workspaceId ?? 'n/a'),
namespace,
fallback,
});
@@ -30,19 +27,16 @@ export function useRecentEnvironments() {
export function useSubscribeRecentEnvironments() {
useEffect(() => {
return jotaiStore.sub(activeEnvironmentIdAtom, async () => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const activeEnvironmentId = jotaiStore.get(activeEnvironmentIdAtom);
if (activeWorkspaceId == null) return;
if (activeEnvironmentId == null) return;
const key = kvKey(activeWorkspaceId);
return jotaiStore.sub(activeEnvironmentAtom, async () => {
const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
if (activeEnvironment == null) return;
const key = kvKey(activeEnvironment.workspaceId);
const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeEnvironmentId) return; // Short-circuit
if (recentIds[0] === activeEnvironment.id) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeEnvironmentId);
const value = [activeEnvironmentId, ...withoutActiveId];
const withoutActiveId = recentIds.filter((id) => id !== activeEnvironment.id);
const value = [activeEnvironment.id, ...withoutActiveId];
await setKeyValue({ namespace, key, value });
});
}, []);

View File

@@ -1,11 +1,9 @@
import { useAtomValue } from 'jotai';
import { useEffect, useMemo } from 'react';
import { jotaiStore } from '../lib/jotai';
import { getKeyValue, setKeyValue } from '../lib/keyValueStore';
import { activeRequestIdAtom } from './useActiveRequestId';
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
import { useKeyValue } from './useKeyValue';
import { useAllRequests } from './useAllRequests';
import { activeRequestAtom } from './useActiveRequest';
const kvKey = (workspaceId: string) => 'recent_requests::' + workspaceId;
const namespace = 'global';
@@ -13,10 +11,9 @@ const fallback: string[] = [];
export function useRecentRequests() {
const requests = useAllRequests();
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
const { set: setRecentRequests, value: recentRequests } = useKeyValue<string[]>({
key: kvKey(activeWorkspaceId ?? 'n/a'),
key: kvKey(requests[0]?.workspaceId ?? 'n/a'),
namespace,
fallback,
});
@@ -31,19 +28,17 @@ export function useRecentRequests() {
export function useSubscribeRecentRequests() {
useEffect(() => {
return jotaiStore.sub(activeRequestIdAtom, async () => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const activeRequestId = jotaiStore.get(activeRequestIdAtom);
if (activeWorkspaceId == null) return;
if (activeRequestId == null) return;
return jotaiStore.sub(activeRequestAtom, async () => {
const activeRequest = jotaiStore.get(activeRequestAtom);
if (activeRequest == null) return;
const key = kvKey(activeWorkspaceId);
const key = kvKey(activeRequest.workspaceId);
const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeRequestId) return; // Short-circuit
if (recentIds[0] === activeRequest.id) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeRequestId);
const value = [activeRequestId, ...withoutActiveId];
const withoutActiveId = recentIds.filter((id) => id !== activeRequest.id);
const value = [activeRequest.id, ...withoutActiveId];
await setKeyValue({ namespace, key, value });
});
}, []);

View File

@@ -4,14 +4,17 @@ import { jotaiStore } from './jotai';
export const dialogsAtom = atom<DialogInstance[]>([]);
export function showDialog({ id, ...props }: DialogInstance) {
jotaiStore.set(dialogsAtom, (a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
}
export function toggleDialog({ id, ...props }: DialogInstance) {
const dialogs = jotaiStore.get(dialogsAtom);
if (dialogs.some((d) => d.id === id)) hideDialog(id);
else showDialog({ id, ...props });
if (dialogs.some((d) => d.id === id)) {
hideDialog(id);
} else {
showDialog({ id, ...props });
}
}
export function showDialog({ id, ...props }: DialogInstance) {
jotaiStore.set(dialogsAtom, (a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
}
export function hideDialog(id: string) {

View File

@@ -1,18 +1,62 @@
import type { Environment } from '@yaakapp-internal/models';
import type { Environment, EnvironmentVariable } from '@yaakapp-internal/models';
import { updateModel } from '@yaakapp-internal/models';
import { openFolderSettings } from '../commands/openFolderSettings';
import type { PairEditorHandle } from '../components/core/PairEditor';
import { ensurePairId } from '../components/core/PairEditor.util';
import { EnvironmentEditDialog } from '../components/EnvironmentEditDialog';
import { environmentsBreakdownAtom } from '../hooks/useEnvironmentsBreakdown';
import { toggleDialog } from './dialog';
import { jotaiStore } from './jotai';
export function editEnvironment(environment: Environment | null) {
if (environment?.parentModel === 'folder' && environment.parentId != null) {
openFolderSettings(environment.parentId, 'variables');
interface Options {
addOrFocusVariable?: EnvironmentVariable;
}
export async function editEnvironment(
initialEnvironment: Environment | null,
options: Options = {},
) {
if (initialEnvironment?.parentModel === 'folder' && initialEnvironment.parentId != null) {
openFolderSettings(initialEnvironment.parentId, 'variables');
} else {
const { addOrFocusVariable } = options;
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
let environment = initialEnvironment ?? baseEnvironment;
let focusId: string | null = null;
if (addOrFocusVariable && environment != null) {
const existing = environment.variables.find(
(v) => v.id === addOrFocusVariable.id || v.name === addOrFocusVariable.name,
);
if (existing) {
focusId = existing.id ?? null;
} else {
const newVar = ensurePairId(addOrFocusVariable);
environment = { ...environment, variables: [...environment.variables, newVar] };
await updateModel(environment);
environment.variables.push(newVar);
focusId = newVar.id;
}
}
let didFocusVariable = false;
toggleDialog({
id: 'environment-editor',
noPadding: true,
size: 'lg',
className: 'h-[80vh]',
render: () => <EnvironmentEditDialog initialEnvironment={environment} />,
render: () => (
<EnvironmentEditDialog
initialEnvironmentId={environment?.id ?? null}
setRef={(pairEditor: PairEditorHandle | null) => {
if (focusId && !didFocusVariable) {
pairEditor?.focusValue(focusId);
didFocusVariable = true;
}
}}
/>
),
});
}
}

View File

@@ -1,4 +1,4 @@
import type { AnyModel} from '@yaakapp-internal/models';
import type { AnyModel } from '@yaakapp-internal/models';
import { foldersAtom } from '@yaakapp-internal/models';
import { jotaiStore } from './jotai';
@@ -39,7 +39,11 @@ export function resolvedModelName(r: AnyModel | null): string {
}
export function resolvedModelNameWithFolders(model: AnyModel | null): string {
if (model == null) return '';
return resolvedModelNameWithFoldersArray(model).join(' / ');
}
export function resolvedModelNameWithFoldersArray(model: AnyModel | null): string[] {
if (model == null) return [];
const folders = jotaiStore.get(foldersAtom) ?? [];
const getParents = (m: AnyModel, names: string[]) => {
@@ -47,11 +51,11 @@ export function resolvedModelNameWithFolders(model: AnyModel | null): string {
if ('folderId' in m) {
const parent = folders.find((f) => f.id === m.folderId);
if (parent) {
names = [resolvedModelName(parent), ...names];
names = [...resolvedModelNameWithFoldersArray(parent), ...names];
}
}
return names;
};
return getParents(model, []).join(' / ');
return getParents(model, []);
}