diff --git a/package-lock.json b/package-lock.json index 373d8357..d7bbdfa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6529,16 +6529,6 @@ "eslint": ">=8.40" } }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.16.tgz", - "integrity": "sha512-slterMlxAhov/DZO8NScf6mEeMBBXodFUolijDvrtTxyezyLoTQaa73FyYus/VbTdftd8wBgBxPMRk3poleXNQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -15876,6 +15866,7 @@ "jotai": "^2.9.3", "lucide-react": "^0.439.0", "mime": "^4.0.4", + "nanoid": "^5.0.9", "papaparse": "^5.4.1", "parse-color": "^1.0.0", "react": "^18.3.1", @@ -15920,6 +15911,24 @@ "vite-plugin-svgr": "^4.2.0", "vite-plugin-top-level-await": "^1.4.4" } + }, + "src-web/node_modules/nanoid": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 674452c6..df9c66ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,7 +44,11 @@ use crate::render::{render_grpc_request, render_http_request, render_json_value, use crate::template_callback::PluginTemplateCallback; use crate::updates::{UpdateMode, YaakUpdater}; use crate::window_menu::app_menu; -use yaak_models::models::{CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, KeyValue, KeyValueIden, ModelType, Plugin, Settings, Workspace}; +use yaak_models::models::{ + CookieJar, Environment, EnvironmentVariable, Folder, GrpcConnection, GrpcConnectionState, + GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpResponse, HttpResponseState, KeyValue, + ModelType, Plugin, Settings, Workspace, +}; use yaak_models::queries::{ cancel_pending_grpc_connections, cancel_pending_responses, create_default_http_response, delete_all_grpc_connections, delete_all_grpc_connections_for_workspace, diff --git a/src-tauri/src/render.rs b/src-tauri/src/render.rs index c815cdf4..cb24c1bb 100644 --- a/src-tauri/src/render.rs +++ b/src-tauri/src/render.rs @@ -41,6 +41,7 @@ pub async fn render_grpc_request( enabled: p.enabled, name: render(p.name.as_str(), vars, cb).await, value: render(p.value.as_str(), vars, cb).await, + id: p.id, }) } @@ -73,6 +74,7 @@ pub async fn render_http_request( enabled: p.enabled, name: render(p.name.as_str(), vars, cb).await, value: render(p.value.as_str(), vars, cb).await, + id: p.id, }) } @@ -82,6 +84,7 @@ pub async fn render_http_request( enabled: p.enabled, name: render(p.name.as_str(), vars, cb).await, value: render(p.value.as_str(), vars, cb).await, + id: p.id, }) } @@ -309,6 +312,7 @@ mod placeholder_tests { name: ":foo".into(), value: "xxx".into(), enabled: true, + id: "p1".into(), }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo/bar"), @@ -322,6 +326,7 @@ mod placeholder_tests { name: ":foo".into(), value: "xxx".into(), enabled: true, + id: "p1".into(), }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo"), @@ -335,6 +340,7 @@ mod placeholder_tests { name: ":foo".into(), value: "xxx".into(), enabled: true, + id: "p1".into(), }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo?:foo"), @@ -348,6 +354,7 @@ mod placeholder_tests { enabled: true, name: "".to_string(), value: "".to_string(), + id: "p1".into(), }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:missing"), @@ -361,6 +368,7 @@ mod placeholder_tests { enabled: false, name: ":foo".to_string(), value: "xxx".to_string(), + id: "p1".into(), }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo"), @@ -374,6 +382,7 @@ mod placeholder_tests { name: ":foo".into(), value: "xxx".into(), enabled: true, + id: "p1".into(), }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foooo"), @@ -387,6 +396,7 @@ mod placeholder_tests { name: ":foo".into(), value: "Hello World".into(), enabled: true, + id: "p1".into(), }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo"), @@ -403,11 +413,13 @@ mod placeholder_tests { name: "b".to_string(), value: "bbb".to_string(), enabled: true, + id: "p1".into(), }, HttpUrlParameter { name: ":a".to_string(), value: "aaa".to_string(), enabled: true, + id: "p2".into(), }, ], ..Default::default() diff --git a/src-tauri/yaak_models/src/models.rs b/src-tauri/yaak_models/src/models.rs index 9a759f63..00db714b 100644 --- a/src-tauri/yaak_models/src/models.rs +++ b/src-tauri/yaak_models/src/models.rs @@ -294,6 +294,7 @@ pub struct EnvironmentVariable { pub enabled: bool, pub name: String, pub value: String, + pub id: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] @@ -356,6 +357,7 @@ pub struct HttpRequestHeader { pub enabled: bool, pub name: String, pub value: String, + pub id: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] @@ -367,6 +369,7 @@ pub struct HttpUrlParameter { pub enabled: bool, pub name: String, pub value: String, + pub id: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] @@ -572,6 +575,7 @@ pub struct GrpcMetadataEntry { pub enabled: bool, pub name: String, pub value: String, + pub id: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] diff --git a/src-web/components/BasicAuth.tsx b/src-web/components/BasicAuth.tsx index f082254d..ea6d2b41 100644 --- a/src-web/components/BasicAuth.tsx +++ b/src-web/components/BasicAuth.tsx @@ -17,6 +17,7 @@ export function BasicAuth({ request }: Prop ({ request }: Prop useTemplating autocompleteVariables forceUpdateKey={request?.id} + stateKey={`basic.password.${request.id}`} placeholder="password" label="Password" name="password" diff --git a/src-web/components/BearerAuth.tsx b/src-web/components/BearerAuth.tsx index 24a42537..1dc31499 100644 --- a/src-web/components/BearerAuth.tsx +++ b/src-web/components/BearerAuth.tsx @@ -18,6 +18,7 @@ export function BearerAuth({ request }: Pro useTemplating autocompleteVariables placeholder="token" + stateKey={`bearer.${request.id}`} type="password" label="Token" name="token" diff --git a/src-web/components/EnvironmentEditDialog.tsx b/src-web/components/EnvironmentEditDialog.tsx index 2c3f2b0a..1ebd8691 100644 --- a/src-web/components/EnvironmentEditDialog.tsx +++ b/src-web/components/EnvironmentEditDialog.tsx @@ -190,6 +190,7 @@ const EnvironmentEditor = function ({ forceUpdateKey={environment.id} pairs={environment.variables} onChange={handleChange} + stateKey={`environment.${environment.id}`} /> diff --git a/src-web/components/FolderSettingsDialog.tsx b/src-web/components/FolderSettingsDialog.tsx index d30ca934..5805616c 100644 --- a/src-web/components/FolderSettingsDialog.tsx +++ b/src-web/components/FolderSettingsDialog.tsx @@ -31,6 +31,7 @@ export function FolderSettingsDialog({ folderId }: Props) { placeholder="Folder description" className="min-h-[10rem] border border-border px-2" defaultValue={folder.description} + stateKey={`description.${folder.id}`} onChange={(description) => { if (folderId == null) return; updateFolder({ diff --git a/src-web/components/FormMultipartEditor.tsx b/src-web/components/FormMultipartEditor.tsx index 9dcc2499..8a5153cb 100644 --- a/src-web/components/FormMultipartEditor.tsx +++ b/src-web/components/FormMultipartEditor.tsx @@ -1,25 +1,25 @@ -import { useCallback, useMemo } from 'react'; import type { HttpRequest } from '@yaakapp-internal/models'; +import { useCallback, useMemo } from 'react'; import type { Pair, PairEditorProps } from './core/PairEditor'; import { PairEditor } from './core/PairEditor'; type Props = { forceUpdateKey: string; - body: HttpRequest['body']; + request: HttpRequest; onChange: (body: HttpRequest['body']) => void; }; -export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) { +export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) { const pairs = useMemo( () => - (Array.isArray(body.form) ? body.form : []).map((p) => ({ + (Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({ enabled: p.enabled, name: p.name, value: p.file ?? p.value, contentType: p.contentType, isFile: !!p.file, })), - [body.form], + [request.body.form], ); const handleChange = useCallback( @@ -44,6 +44,7 @@ export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) { pairs={pairs} onChange={handleChange} forceUpdateKey={forceUpdateKey} + stateKey={'multipart.' + request.id} /> ); } diff --git a/src-web/components/FormUrlencodedEditor.tsx b/src-web/components/FormUrlencodedEditor.tsx index b7d6424f..2e5eeaae 100644 --- a/src-web/components/FormUrlencodedEditor.tsx +++ b/src-web/components/FormUrlencodedEditor.tsx @@ -5,19 +5,19 @@ import { PairOrBulkEditor } from './core/PairOrBulkEditor'; type Props = { forceUpdateKey: string; - body: HttpRequest['body']; + request: HttpRequest; onChange: (headers: HttpRequest['body']) => void; }; -export function FormUrlencodedEditor({ body, forceUpdateKey, onChange }: Props) { +export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) { const pairs = useMemo( () => - (Array.isArray(body.form) ? body.form : []).map((p) => ({ + (Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({ enabled: !!p.enabled, name: p.name || '', value: p.value || '', })), - [body.form], + [request.body.form], ); const handleChange = useCallback( @@ -36,6 +36,7 @@ export function FormUrlencodedEditor({ body, forceUpdateKey, onChange }: Props) pairs={pairs} onChange={handleChange} forceUpdateKey={forceUpdateKey} + stateKey={`urlencoded.${request.id}`} /> ); } diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index 88a25c31..be5b79ca 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -5,6 +5,7 @@ import type { EditorView } from 'codemirror'; import { formatSdl } from 'format-graphql'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocalStorage } from 'react-use'; +import { useDialog } from '../hooks/useDialog'; import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL'; import { tryFormatJson } from '../lib/formatters'; import { Button } from './core/Button'; @@ -14,15 +15,14 @@ import { Editor } from './core/Editor/Editor'; import { FormattedError } from './core/FormattedError'; import { Icon } from './core/Icon'; import { Separator } from './core/Separator'; -import { useDialog } from '../hooks/useDialog'; type Props = Pick & { baseRequest: HttpRequest; onChange: (body: HttpRequest['body']) => void; - body: HttpRequest['body']; + request: HttpRequest; }; -export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps }: Props) { +export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorProps }: Props) { const editorViewRef = useRef(null); const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage< Record @@ -34,13 +34,13 @@ export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps () => { // Migrate text bodies to GraphQL format // NOTE: This is how GraphQL used to be stored - if ('text' in body) { - const b = tryParseJson(body.text, {}); + if ('text' in request.body) { + const b = tryParseJson(request.body.text, {}); const variables = JSON.stringify(b.variables || undefined, null, 2); return { query: b.query ?? '', variables }; } - return { query: body.query ?? '', variables: body.variables ?? '' }; + return { query: request.body.query ?? '', variables: request.body.variables ?? '' }; }, ); @@ -176,6 +176,7 @@ export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps placeholder="..." ref={editorViewRef} actions={actions} + stateKey={'graphql_body.' + request.id} {...extraEditorProps} />
@@ -189,6 +190,7 @@ export function GraphQLEditor({ body, onChange, baseRequest, ...extraEditorProps defaultValue={currentBody.variables} onChange={handleChangeVariables} placeholder="{}" + stateKey={'graphql_vars.' + request.id} useTemplating autocompleteVariables {...extraEditorProps} diff --git a/src-web/components/GrpcConnectionSetupPane.tsx b/src-web/components/GrpcConnectionSetupPane.tsx index 24e02ad4..32409098 100644 --- a/src-web/components/GrpcConnectionSetupPane.tsx +++ b/src-web/components/GrpcConnectionSetupPane.tsx @@ -224,6 +224,7 @@ export function GrpcConnectionSetupPane({ onUrlChange={handleChangeUrl} onCancel={onCancel} isLoading={isStreaming} + stateKey={'grpc_url.'+activeRequest.id} /> @@ -346,6 +348,7 @@ export function GrpcConnectionSetupPane({ name="request-description" placeholder="Request description" defaultValue={activeRequest.description} + stateKey={`description.${activeRequest.id}`} onChange={handleDescriptionChange} /> diff --git a/src-web/components/GrpcEditor.tsx b/src-web/components/GrpcEditor.tsx index c0d3027f..ffa9ffcc 100644 --- a/src-web/components/GrpcEditor.tsx +++ b/src-web/components/GrpcEditor.tsx @@ -196,6 +196,7 @@ export function GrpcEditor({ ref={editorViewRef} extraExtensions={extraExtensions} actions={actions} + stateKey={`grpc_message.${request.id}`} {...extraEditorProps} />
diff --git a/src-web/components/HeadersEditor.tsx b/src-web/components/HeadersEditor.tsx index 47833240..6bc405e5 100644 --- a/src-web/components/HeadersEditor.tsx +++ b/src-web/components/HeadersEditor.tsx @@ -1,26 +1,27 @@ +import type { HttpRequest } from '@yaakapp-internal/models'; import { charsets } from '../lib/data/charsets'; import { connections } from '../lib/data/connections'; import { encodings } from '../lib/data/encodings'; import { headerNames } from '../lib/data/headerNames'; import { mimeTypes } from '../lib/data/mimetypes'; -import type { HttpRequest } from '@yaakapp-internal/models'; import type { GenericCompletionConfig } from './core/Editor/genericCompletion'; import type { PairEditorProps } from './core/PairEditor'; import { PairOrBulkEditor } from './core/PairOrBulkEditor'; type Props = { forceUpdateKey: string; - headers: HttpRequest['headers']; + request: HttpRequest; onChange: (headers: HttpRequest['headers']) => void; }; -export function HeadersEditor({ headers, onChange, forceUpdateKey }: Props) { +export function HeadersEditor({ request, onChange, forceUpdateKey }: Props) { return ( { +interface Props extends Pick { placeholder: string; className?: string; defaultValue: string; @@ -26,6 +26,7 @@ export function MarkdownEditor({ name, placeholder, heightMode, + stateKey, }: Props) { const containerRef = useRef(null); @@ -55,6 +56,7 @@ export function MarkdownEditor({ onChange={onChange} placeholder={placeholder} heightMode={heightMode} + stateKey={stateKey} /> ); diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 733ce8bf..1460a4a9 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -16,7 +16,6 @@ import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useToast } from '../hooks/useToast'; import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest'; import { languageFromContentType } from '../lib/contentType'; -import { fallbackRequestName } from '../lib/fallbackRequestName'; import { tryFormatJson } from '../lib/formatters'; import { AUTH_TYPE_BASIC, @@ -338,6 +337,7 @@ export const RequestPane = memo(function RequestPane({ {activeRequest && ( <> updateRequest.mutate({ id: activeRequestId, update: { headers } }) } @@ -392,6 +392,7 @@ export const RequestPane = memo(function RequestPane({ @@ -411,6 +412,7 @@ export const RequestPane = memo(function RequestPane({ language="json" onChange={handleBodyTextChange} format={tryFormatJson} + stateKey={`json.${activeRequest.id}`} /> ) : activeRequest.bodyType === BODY_TYPE_XML ? ( ) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( ) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? ( ) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? ( ) : activeRequest.bodyType === BODY_TYPE_BINARY ? ( @@ -462,6 +465,7 @@ export const RequestPane = memo(function RequestPane({ heightMode={fullHeight ? 'full' : 'auto'} defaultValue={`${activeRequest.body?.text ?? ''}`} onChange={handleBodyTextChange} + stateKey={`other.${activeRequest.id}`} /> ) : ( Empty Body @@ -475,7 +479,7 @@ export const RequestPane = memo(function RequestPane({ defaultValue={activeRequest.name} className="font-sans !text-xl !px-0" containerClassName="border-0" - placeholder={fallbackRequestName(activeRequest)} + placeholder={activeRequest.id} onChange={(name) => updateRequest.mutate({ id: activeRequestId, update: { name } }) } @@ -484,6 +488,7 @@ export const RequestPane = memo(function RequestPane({ name="request-description" placeholder="Request description" defaultValue={activeRequest.description} + stateKey={`description.${activeRequest.id}`} onChange={(description) => updateRequest.mutate({ id: activeRequestId, update: { description } }) } diff --git a/src-web/components/Settings/SettingsAppearance.tsx b/src-web/components/Settings/SettingsAppearance.tsx index 2990f9a3..0f9a1d73 100644 --- a/src-web/components/Settings/SettingsAppearance.tsx +++ b/src-web/components/Settings/SettingsAppearance.tsx @@ -197,6 +197,7 @@ export function SettingsAppearance() { ].join('\n')} heightMode="auto" language="javascript" + stateKey={null} /> diff --git a/src-web/components/Settings/SettingsDesign.tsx b/src-web/components/Settings/SettingsDesign.tsx index ad9eb7fc..b7f63706 100644 --- a/src-web/components/Settings/SettingsDesign.tsx +++ b/src-web/components/Settings/SettingsDesign.tsx @@ -107,6 +107,7 @@ export function SettingsDesign() { placeholder="Placeholder" size="sm" rightSlot={} + stateKey={null} />
diff --git a/src-web/components/UrlBar.tsx b/src-web/components/UrlBar.tsx index 20104e60..92068340 100644 --- a/src-web/components/UrlBar.tsx +++ b/src-web/components/UrlBar.tsx @@ -1,9 +1,9 @@ +import type { HttpRequest } from '@yaakapp-internal/models'; import classNames from 'classnames'; import type { EditorView } from 'codemirror'; import type { FormEvent, ReactNode } from 'react'; import { memo, useRef, useState } from 'react'; import { useHotKey } from '../hooks/useHotKey'; -import type { HttpRequest } from '@yaakapp-internal/models'; import type { IconProps } from './core/Icon'; import { IconButton } from './core/IconButton'; import type { InputProps } from './core/Input'; @@ -25,6 +25,7 @@ type Props = Pick & { forceUpdateKey: string; rightSlot?: ReactNode; autocomplete?: InputProps['autocomplete']; + stateKey: InputProps['stateKey']; }; export const UrlBar = memo(function UrlBar({ @@ -43,6 +44,7 @@ export const UrlBar = memo(function UrlBar({ autocomplete, rightSlot, isLoading, + stateKey, }: Props) { const inputRef = useRef(null); const [isFocused, setIsFocused] = useState(false); @@ -65,7 +67,7 @@ export const UrlBar = memo(function UrlBar({
void; }; -export function UrlParametersEditor({ pairs, forceUpdateKey, onChange }: Props) { +export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) { const pairEditor = useRef(null); const [{ urlParametersKey }] = useRequestEditor(); @@ -32,14 +33,15 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange }: Props) ); diff --git a/src-web/components/WorkpaceSettingsDialog.tsx b/src-web/components/WorkpaceSettingsDialog.tsx index 47889aa1..7ed1b857 100644 --- a/src-web/components/WorkpaceSettingsDialog.tsx +++ b/src-web/components/WorkpaceSettingsDialog.tsx @@ -29,6 +29,7 @@ export function WorkspaceSettingsDialog({ workspaceId }: Props) { placeholder="Workspace description" className="min-h-[10rem] border border-border px-2" defaultValue={workspace.description} + stateKey={`description.${workspace.id}`} onChange={(description) => updateWorkspace.mutate({ description })} heightMode='auto' /> diff --git a/src-web/components/core/BulkPairEditor.tsx b/src-web/components/core/BulkPairEditor.tsx index 04cdf366..f496c4cb 100644 --- a/src-web/components/core/BulkPairEditor.tsx +++ b/src-web/components/core/BulkPairEditor.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import {Editor} from "./Editor/Editor"; +import { Editor } from './Editor/Editor'; import type { PairEditorProps } from './PairEditor'; type Props = PairEditorProps; @@ -10,6 +10,7 @@ export function BulkPairEditor({ namePlaceholder, valuePlaceholder, forceUpdateKey, + stateKey, }: Props) { const pairsText = useMemo(() => { return pairs @@ -33,6 +34,7 @@ export function BulkPairEditor({ (function Dropdown close() { handleClose(); }, - })); + }), [handleClose, isOpen, setIsOpen]); useHotKey(hotKeyAction ?? null, () => { setDefaultSelectedIndex(0); diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 7276cc55..5871c957 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -1,5 +1,5 @@ -import { defaultKeymap } from '@codemirror/commands'; -import { forceParsing } from '@codemirror/language'; +import { defaultKeymap, historyField } from '@codemirror/commands'; +import { foldState, forceParsing } from '@codemirror/language'; import { Compartment, EditorState, type Extension } from '@codemirror/state'; import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view'; import type { EnvironmentVariable } from '@yaakapp-internal/models'; @@ -8,12 +8,12 @@ import classNames from 'classnames'; import { EditorView } from 'codemirror'; import type { MutableRefObject, ReactNode } from 'react'; import { + useEffect, Children, cloneElement, forwardRef, isValidElement, useCallback, - useEffect, useImperativeHandle, useMemo, useRef, @@ -71,6 +71,7 @@ export interface EditorProps { extraExtensions?: Extension[]; actions?: ReactNode; hideGutter?: boolean; + stateKey: string | null; } const emptyVariables: EnvironmentVariable[] = []; @@ -102,6 +103,7 @@ export const Editor = forwardRef(function E actions, wrapLines, hideGutter, + stateKey, }: EditorProps, ref, ) { @@ -115,7 +117,7 @@ export const Editor = forwardRef(function E } const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); - useImperativeHandle(ref, () => cm.current?.view); + useImperativeHandle(ref, () => cm.current?.view, []); // Use ref so we can update the handler without re-initializing the editor const handleChange = useRef(onChange); @@ -289,7 +291,6 @@ export const Editor = forwardRef(function E return; } - let view: EditorView; try { const languageCompartment = new Compartment(); const langExt = getLanguageExtension({ @@ -303,32 +304,38 @@ export const Editor = forwardRef(function E onClickMissingVariable, onClickPathParameter, }); + const extensions = [ + languageCompartment.of(langExt), + placeholderCompartment.current.of( + placeholderExt(placeholderElFromText(placeholder ?? '')), + ), + wrapLinesCompartment.current.of(wrapLines ? [EditorView.lineWrapping] : []), + ...getExtensions({ + container, + readOnly, + singleLine, + hideGutter, + stateKey, + onChange: handleChange, + onPaste: handlePaste, + onPasteOverwrite: handlePasteOverwrite, + onFocus: handleFocus, + onBlur: handleBlur, + onKeyDown: handleKeyDown, + }), + ...(extraExtensions ?? []), + ]; - const state = EditorState.create({ - doc: `${defaultValue ?? ''}`, - extensions: [ - languageCompartment.of(langExt), - placeholderCompartment.current.of( - placeholderExt(placeholderElFromText(placeholder ?? '')), - ), - wrapLinesCompartment.current.of(wrapLines ? [EditorView.lineWrapping] : []), - ...getExtensions({ - container, - readOnly, - singleLine, - hideGutter, - onChange: handleChange, - onPaste: handlePaste, - onPasteOverwrite: handlePasteOverwrite, - onFocus: handleFocus, - onBlur: handleBlur, - onKeyDown: handleKeyDown, - }), - ...(extraExtensions ?? []), - ], - }); + const cachedJsonState = getCachedEditorState(stateKey); + const state = cachedJsonState + ? EditorState.fromJSON( + cachedJsonState, + { extensions }, + { fold: foldState, history: historyField }, + ) + : EditorState.create({ doc: `${defaultValue ?? ''}`, extensions }); - view = new EditorView({ state, parent: container }); + const view = new EditorView({ state, parent: container }); // For large documents, the parser may parse the max number of lines and fail to add // things like fold markers because of it. @@ -459,6 +466,7 @@ export const Editor = forwardRef(function E }); function getExtensions({ + stateKey, container, readOnly, singleLine, @@ -470,6 +478,7 @@ function getExtensions({ onBlur, onKeyDown, }: Pick & { + stateKey: EditorProps['stateKey']; container: HTMLDivElement | null; onChange: MutableRefObject; onPaste: MutableRefObject; @@ -519,6 +528,7 @@ function getExtensions({ EditorView.updateListener.of((update) => { if (onChange && update.docChanged) { onChange.current?.(update.state.doc.toString()); + saveCachedEditorState(stateKey, update.state); } }), ]; @@ -529,3 +539,21 @@ const placeholderElFromText = (text: string) => { el.innerHTML = text.replaceAll('\n', '
'); return el; }; + +function saveCachedEditorState(stateKey: string | null, state: EditorState | null) { + if (!stateKey || state == null) return; + const stateJson = state.toJSON({ history: historyField, folds: foldState }); + sessionStorage.setItem(stateKey, JSON.stringify(stateJson)); +} + +function getCachedEditorState(stateKey: string | null) { + if (stateKey == null) return; + const serializedState = stateKey ? sessionStorage.getItem(stateKey) : null; + if (serializedState == null) return; + try { + return JSON.parse(serializedState); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return null; + } +} diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index 1d26900b..54884f9e 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import type { EditorView } from 'codemirror'; -import type { HTMLAttributes, ReactNode } from 'react'; +import type { ReactNode } from 'react'; import { forwardRef, useCallback, useMemo, useRef, useState } from 'react'; import { useStateWithDeps } from '../../hooks/useStateWithDeps'; import type { EditorProps } from './Editor/Editor'; @@ -8,44 +8,41 @@ import { Editor } from './Editor/Editor'; import { IconButton } from './IconButton'; import { HStack } from './Stacks'; -export type InputProps = Omit< - HTMLAttributes, - 'onChange' | 'onFocus' | 'onKeyDown' | 'onPaste' -> & - Pick< - EditorProps, - | 'language' - | 'useTemplating' - | 'autocomplete' - | 'forceUpdateKey' - | 'autoFocus' - | 'autoSelect' - | 'autocompleteVariables' - | 'onKeyDown' - | 'readOnly' - > & { - name?: string; - type?: 'text' | 'password'; - label: ReactNode; - hideLabel?: boolean; - labelPosition?: 'top' | 'left'; - labelClassName?: string; - containerClassName?: string; - onChange?: (value: string) => void; - onFocus?: () => void; - onBlur?: () => void; - onPaste?: (value: string) => void; - onPasteOverwrite?: (value: string) => void; - defaultValue?: string; - leftSlot?: ReactNode; - rightSlot?: ReactNode; - size?: 'xs' | 'sm' | 'md' | 'auto'; - className?: string; - placeholder?: string; - validate?: boolean | ((v: string) => boolean); - require?: boolean; - wrapLines?: boolean; - }; +export type InputProps = Pick< + EditorProps, + | 'language' + | 'useTemplating' + | 'autocomplete' + | 'forceUpdateKey' + | 'autoFocus' + | 'autoSelect' + | 'autocompleteVariables' + | 'onKeyDown' + | 'readOnly' +> & { + name?: string; + type?: 'text' | 'password'; + label: ReactNode; + hideLabel?: boolean; + labelPosition?: 'top' | 'left'; + labelClassName?: string; + containerClassName?: string; + onChange?: (value: string) => void; + onFocus?: () => void; + onBlur?: () => void; + onPaste?: (value: string) => void; + onPasteOverwrite?: (value: string) => void; + defaultValue?: string; + leftSlot?: ReactNode; + rightSlot?: ReactNode; + size?: 'xs' | 'sm' | 'md' | 'auto'; + className?: string; + placeholder?: string; + validate?: boolean | ((v: string) => boolean); + require?: boolean; + wrapLines?: boolean; + stateKey: EditorProps['stateKey']; +}; export const Input = forwardRef(function Input( { @@ -71,6 +68,7 @@ export const Input = forwardRef(function Inp type = 'text', validate, readOnly, + stateKey, ...props }: InputProps, ref, @@ -172,6 +170,7 @@ export const Input = forwardRef(function Inp ref={ref} id={id} singleLine + stateKey={stateKey} wrapLines={wrapLines} onKeyDown={handleKeyDown} type={type === 'password' && !obscured ? 'text' : type} diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index 2d732f44..fc37c856 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -1,8 +1,9 @@ +import { deepEqual } from '@tanstack/react-router'; import classNames from 'classnames'; import type { EditorView } from 'codemirror'; import { - Fragment, forwardRef, + Fragment, useCallback, useEffect, useImperativeHandle, @@ -12,8 +13,8 @@ import { } from 'react'; import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; -import { v4 as uuid } from 'uuid'; import { usePrompt } from '../../hooks/usePrompt'; +import { generateId } from '../../lib/generateId'; import { DropMarker } from '../DropMarker'; import { SelectFile } from '../SelectFile'; import { Checkbox } from './Checkbox'; @@ -31,21 +32,22 @@ export interface PairEditorRef { } export type PairEditorProps = { - pairs: Pair[]; - onChange: (pairs: Pair[]) => void; - forceUpdateKey?: string; + allowFileValues?: boolean; className?: string; + forceUpdateKey?: string; + nameAutocomplete?: GenericCompletionConfig; + nameAutocompleteVariables?: boolean; namePlaceholder?: string; + nameValidate?: InputProps['validate']; + noScroll?: boolean; + onChange: (pairs: Pair[]) => void; + pairs: Pair[]; + stateKey: InputProps['stateKey']; + valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; + valueAutocompleteVariables?: boolean; valuePlaceholder?: string; valueType?: 'text' | 'password'; - nameAutocomplete?: GenericCompletionConfig; - valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; - nameAutocompleteVariables?: boolean; - valueAutocompleteVariables?: boolean; - allowFileValues?: boolean; - nameValidate?: InputProps['validate']; valueValidate?: InputProps['validate']; - noScroll?: boolean; }; export type Pair = { @@ -65,21 +67,22 @@ type PairContainer = { export const PairEditor = forwardRef(function PairEditor( { + stateKey, + allowFileValues, className, forceUpdateKey, nameAutocomplete, nameAutocompleteVariables, namePlaceholder, nameValidate, - valueType, - onChange, noScroll, + onChange, pairs: originalPairs, valueAutocomplete, valueAutocompleteVariables, valuePlaceholder, + valueType, valueValidate, - allowFileValues, }: PairEditorProps, ref, ) { @@ -93,20 +96,28 @@ export const PairEditor = forwardRef(function Pa return [...pairs, newPairContainer()]; }); - useImperativeHandle(ref, () => ({ - focusValue(index: number) { - const id = pairs[index]?.id ?? 'n/a'; - setForceFocusValuePairId(id); - }, - })); + useImperativeHandle( + ref, + () => ({ + focusValue(index: number) { + const id = pairs[index]?.id ?? 'n/a'; + setForceFocusValuePairId(id); + }, + }), + [pairs], + ); useEffect(() => { // Remove empty headers on initial render // TODO: Make this not refresh the entire editor when forceUpdateKey changes, using some // sort of diff method or deterministic IDs based on array index and update key - const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === '')); - const pairs = nonEmpty.map((pair) => newPairContainer(pair)); - setPairs([...pairs, newPairContainer()]); + const nonEmpty = originalPairs.filter( + (h, i) => i !== originalPairs.length - 1 && !(h.name === '' && h.value === ''), + ); + const newPairs = nonEmpty.map((pair) => newPairContainer(pair)); + if (!deepEqual(pairs, newPairs)) { + setPairs(pairs); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [forceUpdateKey]); @@ -211,28 +222,29 @@ export const PairEditor = forwardRef(function Pa {hoveredIndex === i && } ); @@ -260,17 +272,18 @@ type PairEditorRowProps = { index: number; } & Pick< PairEditorProps, - | 'nameAutocomplete' - | 'valueAutocomplete' - | 'nameAutocompleteVariables' - | 'valueAutocompleteVariables' - | 'valueType' - | 'namePlaceholder' - | 'valuePlaceholder' - | 'nameValidate' - | 'valueValidate' - | 'forceUpdateKey' | 'allowFileValues' + | 'forceUpdateKey' + | 'nameAutocomplete' + | 'nameAutocompleteVariables' + | 'namePlaceholder' + | 'nameValidate' + | 'stateKey' + | 'valueAutocomplete' + | 'valueAutocompleteVariables' + | 'valuePlaceholder' + | 'valueType' + | 'valueValidate' >; function PairEditorRow({ @@ -279,8 +292,8 @@ function PairEditorRow({ forceFocusNamePairId, forceFocusValuePairId, forceUpdateKey, - isLast, index, + isLast, nameAutocomplete, nameAutocompleteVariables, namePlaceholder, @@ -291,6 +304,7 @@ function PairEditorRow({ onFocus, onMove, pairContainer, + stateKey, valueAutocomplete, valueAutocompleteVariables, valuePlaceholder, @@ -434,6 +448,7 @@ function PairEditorRow({ ref={nameInputRef} hideLabel useTemplating + stateKey={`name.${pairContainer.id}.${stateKey}`} wrapLines={false} readOnly={pairContainer.pair.readOnlyName} size="sm" @@ -476,6 +491,7 @@ function PairEditorRow({ ref={valueInputRef} hideLabel useTemplating + stateKey={`value.${pairContainer.id}.${stateKey}`} wrapLines={false} size="sm" containerClassName={classNames(isLast && 'border-dashed')} @@ -575,7 +591,7 @@ function PairEditorRow({ } const newPairContainer = (initialPair?: Pair): PairContainer => { - const id = initialPair?.id ?? uuid(); + const id = initialPair?.id ?? generateId(); const pair = initialPair ?? { name: '', value: '', enabled: true, isFile: false }; return { id, pair }; }; diff --git a/src-web/components/core/PlainInput.tsx b/src-web/components/core/PlainInput.tsx index 46a0dea2..69207a92 100644 --- a/src-web/components/core/PlainInput.tsx +++ b/src-web/components/core/PlainInput.tsx @@ -1,13 +1,14 @@ import classNames from 'classnames'; -import type { HTMLAttributes } from 'react'; +import type { HTMLAttributes, FocusEvent } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useStateWithDeps } from '../../hooks/useStateWithDeps'; import { IconButton } from './IconButton'; import type { InputProps } from './Input'; import { HStack } from './Stacks'; -export type PlainInputProps = Omit & +export type PlainInputProps = Omit & Pick, 'onKeyDownCapture'> & { + onFocusRaw?: HTMLAttributes['onFocus']; type?: 'text' | 'password' | 'number'; step?: number; }; @@ -33,7 +34,10 @@ export function PlainInput({ type = 'text', validate, autoSelect, - ...props + placeholder, + autoFocus, + onKeyDownCapture, + onFocusRaw, }: PlainInputProps) { const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]); const [currentValue, setCurrentValue] = useState(defaultValue ?? ''); @@ -41,14 +45,15 @@ export function PlainInput({ const inputRef = useRef(null); const textareaRef = useRef(null); - const handleFocus = useCallback(() => { + const handleFocus = useCallback((e: FocusEvent) => { + onFocusRaw?.(e); setFocused(true); if (autoSelect) { inputRef.current?.select(); textareaRef.current?.select(); } onFocus?.(); - }, [autoSelect, onFocus]); + }, [autoSelect, onFocus, onFocusRaw]); const handleBlur = useCallback(() => { setFocused(false); @@ -135,7 +140,9 @@ export function PlainInput({ className={classNames(commonClassName, 'h-auto')} onFocus={handleFocus} onBlur={handleBlur} - {...props} + autoFocus={autoFocus} + placeholder={placeholder} + onKeyDownCapture={onKeyDownCapture} /> {type === 'password' && ( diff --git a/src-web/components/responseViewers/EventStreamViewer.tsx b/src-web/components/responseViewers/EventStreamViewer.tsx index a4656282..fa795147 100644 --- a/src-web/components/responseViewers/EventStreamViewer.tsx +++ b/src-web/components/responseViewers/EventStreamViewer.tsx @@ -110,7 +110,7 @@ function ActualEventStreamViewer({ response }: Props) { function FormattedEditor({ text, language }: { text: string; language: EditorProps['language'] }) { const formatted = useFormatText({ text, language, pretty: true }); if (formatted.data == null) return null; - return ; + return ; } function EventStreamEventsVirtual({ diff --git a/src-web/components/responseViewers/TextViewer.tsx b/src-web/components/responseViewers/TextViewer.tsx index 1a37ab51..3e59db03 100644 --- a/src-web/components/responseViewers/TextViewer.tsx +++ b/src-web/components/responseViewers/TextViewer.tsx @@ -89,6 +89,7 @@ export function TextViewer({ defaultValue={filterText} onKeyDown={(e) => e.key === 'Escape' && toggleSearch()} onChange={setFilterText} + stateKey={`filter.${responseId}`} />
, ); @@ -113,6 +114,7 @@ export function TextViewer({ isSearching, language, requestId, + responseId, setFilterText, toggleSearch, ]); @@ -165,6 +167,7 @@ export function TextViewer({ language={language} actions={actions} extraExtensions={extraExtensions} + stateKey={null} /> ); } diff --git a/src-web/hooks/useRecentWorkspaces.ts b/src-web/hooks/useRecentWorkspaces.ts index f75c1457..55d776b5 100644 --- a/src-web/hooks/useRecentWorkspaces.ts +++ b/src-web/hooks/useRecentWorkspaces.ts @@ -11,7 +11,7 @@ const fallback: string[] = []; export function useRecentWorkspaces() { const workspaces = useWorkspaces(); const activeWorkspace = useActiveWorkspace(); - const kv = useKeyValue({ + const {value, set} = useKeyValue({ key: kvKey(), namespace, fallback, @@ -19,7 +19,7 @@ export function useRecentWorkspaces() { // Set history when active request changes useEffect(() => { - kv.set((currentHistory: string[]) => { + set((currentHistory: string[]) => { if (activeWorkspace === null) return currentHistory; const withoutCurrent = currentHistory.filter((id) => id !== activeWorkspace.id); return [activeWorkspace.id, ...withoutCurrent]; @@ -28,8 +28,8 @@ export function useRecentWorkspaces() { }, [activeWorkspace]); const onlyValidIds = useMemo( - () => kv.value?.filter((id) => workspaces.some((w) => w.id === id)) ?? [], - [kv.value, workspaces], + () => value?.filter((id) => workspaces.some((w) => w.id === id)) ?? [], + [value, workspaces], ); return onlyValidIds; diff --git a/src-web/lib/generateId.ts b/src-web/lib/generateId.ts index d0c629e8..6162c782 100644 --- a/src-web/lib/generateId.ts +++ b/src-web/lib/generateId.ts @@ -1,3 +1,7 @@ +import { customAlphabet } from 'nanoid'; + +const nanoid = customAlphabet('023456789abcdefghijkmnpqrstuvwxyzABCDEFGHIJKMNPQRSTUVWXYZ', 10); + export function generateId(): string { - return Math.random().toString(36).slice(2); + return nanoid(); } diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index 2062bb7e..8b0f4ace 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -80,7 +80,7 @@ type TauriCmd = | 'cmd_write_file_dev'; export async function invokeCmd(cmd: TauriCmd, args?: InvokeArgs): Promise { - console.log('RUN COMMAND', cmd, args); + // console.log('RUN COMMAND', cmd, args); try { return await invoke(cmd, args); } catch (err) { diff --git a/src-web/package.json b/src-web/package.json index 86c8e552..ab904bc3 100644 --- a/src-web/package.json +++ b/src-web/package.json @@ -45,6 +45,7 @@ "jotai": "^2.9.3", "lucide-react": "^0.439.0", "mime": "^4.0.4", + "nanoid": "^5.0.9", "papaparse": "^5.4.1", "parse-color": "^1.0.0", "react": "^18.3.1",