mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-22 23:57:57 +01:00
Compare commits
25 Commits
v2025.8.0-
...
v2025.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a6228bf16 | ||
|
|
fa3a0b57f9 | ||
|
|
4390c02117 | ||
|
|
77011176af | ||
|
|
759fc503d3 | ||
|
|
0cb633e479 | ||
|
|
81ceb981e8 | ||
|
|
4dae1a7955 | ||
|
|
d119f4cab2 | ||
|
|
7e1eb90d29 | ||
|
|
bf97ea1659 | ||
|
|
749ca968ec | ||
|
|
0c54b481fb | ||
|
|
4943bad8ec | ||
|
|
450dbd0053 | ||
|
|
236c8fa656 | ||
|
|
1dfc2ee602 | ||
|
|
1d158082f6 | ||
|
|
f3e44c53d7 | ||
|
|
c8d5e7c97b | ||
|
|
9bde6bbd0a | ||
|
|
df5be218a5 | ||
|
|
2deb870bb6 | ||
|
|
0f9975339c | ||
|
|
6ad4e7bbb5 |
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"))]
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -66,7 +66,6 @@ FieldName {
|
||||
FieldValue {
|
||||
Phrase
|
||||
| Term
|
||||
| Group
|
||||
}
|
||||
|
||||
Term {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user