Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 7860c0af4d Bump @hono/node-server from 1.19.14 to 2.1.0
Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.14 to 2.1.0.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.14...v2.1.0)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 2.1.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 20:11:04 +00:00
Gregory SchierandGitHub f6d926f4b9 Ignore bundled plugin directories left behind by past renames (#530) 2026-08-12 11:22:08 -07:00
Gregory SchierandGitHub a6be9dbaee Allow renaming URL path parameters from the Params tab (#528) 2026-08-12 10:17:02 -07:00
Gregory SchierandGitHub 67a628d67a Link date-fns format docs from timestamp.format (#529) 2026-08-12 09:50:36 -07:00
Gregory SchierandGitHub 0bf7eaed81 Correct timestamp.format help text to reference date-fns, not dayjs (#527) 2026-08-12 08:12:19 -07:00
784a3d3a32 fix(ci): align workflow Node.js version with package engines (#520)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 08:18:22 -07:00
21 changed files with 605 additions and 423 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "24"
- name: Install source generators
run: |
@@ -1,4 +1,4 @@
import { type GrpcRequest, type HttpRequestHeader, patchModel, patchModelDebounced} from "@yaakapp-internal/models";
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
@@ -75,13 +75,11 @@ export function GrpcRequestPane({
const { width: paneWidth } = useContainerSize(urlContainerEl);
const handleChangeUrl = useCallback(
(url: string) => patchModelDebounced(activeRequest, { url }),
(url: string) => patchModel(activeRequest, { url }),
[activeRequest],
);
const handleChangeMessage = useCallback(
// Not debounced: handleSend reads message from the store, so a pending
// debounced patch would send stale text
(message: string) => patchModel(activeRequest, { message }),
[activeRequest],
);
@@ -148,12 +146,12 @@ export function GrpcRequestPane({
);
const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
[activeRequest],
);
const handleDescriptionChange = useCallback(
(description: string) => patchModelDebounced(activeRequest, { description }),
(description: string) => patchModel(activeRequest, { description }),
[activeRequest],
);
@@ -301,7 +299,7 @@ export function GrpcRequestPane({
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
onChange={(name) => patchModel(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
+48 -31
View File
@@ -1,11 +1,12 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import { getModel, patchModel } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -15,9 +16,10 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import {
BODY_TYPE_BINARY,
@@ -40,7 +42,6 @@ import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from "@yaakapp-internal/ui";
import type { Pair } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -75,12 +76,15 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs";
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
@@ -128,20 +132,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest],
);
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const item = items.find((p) => p.name === name);
if (item) {
item.readOnlyName = true;
} else {
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
}, [activeRequest.url, activeRequest.urlParameters]);
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
// value detaches from the placeholder.
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
// holds onto it until the URL's placeholders change, so a captured request would go stale and
// patch its parameter list back over newer edits.
const handleRenamePathPlaceholder = useCallback(
(oldName: string, newName: string) => {
const request = getModel("http_request", activeRequestId);
if (request == null) return false;
const patch = renamePathPlaceholder(request, oldName, newName);
if (patch == null) return false; // Unusable name, so the editor reverts the field
void patchModel(request, patch);
return true;
},
[activeRequestId],
);
const { urlParameterPairs, urlParametersKey } = useMemo(
() =>
derivePathPlaceholderPairs(
activeRequest.url,
activeRequest.urlParameters,
handleRenamePathPlaceholder,
),
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
let numParams = 0;
if (
@@ -269,16 +286,16 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
[activeRequest],
);
const handleBodyTextChange = useCallback(
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest],
);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => getUrlCompletionConfig(autocompleteUrls),
@@ -318,7 +335,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
);
const handleUrlChange = useCallback(
(url: string) => patchModelDebounced(activeRequest, { url }),
(url: string) => patchModel(activeRequest, { url }),
[activeRequest],
);
@@ -364,7 +381,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
onChange={(headers) => patchModel(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -372,7 +389,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
@@ -424,7 +441,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChange={(body) => patchModel(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === "string" ? (
+1 -6
View File
@@ -112,7 +112,6 @@ function Sidebar({ className }: { className?: string }) {
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
@@ -725,11 +724,7 @@ function Sidebar({ className }: { className?: string }) {
);
}
// Memoized so route navigations (which re-render the workspace layout) don't
// re-render the sidebar subtree. In large workspaces a sidebar re-render is
// very expensive: it re-renders DndContext, whose context churn re-renders
// every visible TreeItem regardless of their memo comparators.
export default memo(Sidebar);
export default Sidebar;
function getGitContextMenuItems({
items,
@@ -1,15 +1,14 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useCallback, useRef } from "react";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
pairs: HttpRequest["headers"];
pairs: EditablePair[];
stateKey: PairEditorProps["stateKey"];
onChange: (headers: HttpRequest["urlParameters"]) => void;
onChange: PairEditorProps["onChange"];
};
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
@@ -1,5 +1,5 @@
import type { WebsocketRequest } from "@yaakapp-internal/models";
import { flushAllModelWrites, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import { getModel, patchModel } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
import classNames from "classnames";
@@ -8,7 +8,8 @@ import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -17,9 +18,9 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge";
@@ -27,7 +28,6 @@ import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import type { Pair } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -53,12 +53,15 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs";
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
@@ -79,20 +82,33 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
[],
);
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const item = items.find((p) => p.name === name);
if (item) {
item.readOnlyName = true;
} else {
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
}, [activeRequest.url, activeRequest.urlParameters]);
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
// value detaches from the placeholder.
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
// holds onto it until the URL's placeholders change, so a captured request would go stale and
// patch its parameter list back over newer edits.
const handleRenamePathPlaceholder = useCallback(
(oldName: string, newName: string) => {
const request = getModel("websocket_request", activeRequestId);
if (request == null) return false;
const patch = renamePathPlaceholder(request, oldName, newName);
if (patch == null) return false; // Unusable name, so the editor reverts the field
void patchModel(request, patch);
return true;
},
[activeRequestId],
);
const { urlParameterPairs, urlParametersKey } = useMemo(
() =>
derivePathPlaceholderPairs(
activeRequest.url,
activeRequest.urlParameters,
handleRenamePathPlaceholder,
),
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
const tabs = useMemo<TabItem[]>(() => {
return [
@@ -123,7 +139,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => getUrlCompletionConfig(autocompleteUrls),
@@ -131,7 +147,6 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
);
const handleConnect = useCallback(async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
await connectWebsocket({
requestId: activeRequest.id,
environmentId: getActiveEnvironment()?.id ?? null,
@@ -141,7 +156,6 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const handleSend = useCallback(async () => {
if (connection == null) return;
await flushAllModelWrites(); // The backend reads the message from the DB
await sendWebsocket({
connectionId: connection?.id,
environmentId: getActiveEnvironment()?.id ?? null,
@@ -154,7 +168,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, [connection]);
const handleUrlChange = useCallback(
(url: string) => patchModelDebounced(activeRequest, { url }),
(url: string) => patchModel(activeRequest, { url }),
[activeRequest],
);
@@ -238,7 +252,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
forceUpdateKey={forceUpdateKey}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
onChange={(headers) => patchModel(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -246,7 +260,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_MESSAGE}>
@@ -258,7 +272,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
heightMode={fullHeight ? "full" : "auto"}
defaultValue={activeRequest.message}
language={messageLanguage}
onChange={(message) => patchModelDebounced(activeRequest, { message })}
onChange={(message) => patchModel(activeRequest, { message })}
stateKey={`json.${activeRequest.id}`}
/>
</TabContent>
@@ -275,7 +289,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
onChange={(name) => patchModel(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
@@ -283,7 +297,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey}
onChange={(description) => patchModelDebounced(activeRequest, { description })}
onChange={(description) => patchModel(activeRequest, { description })}
/>
</div>
</TabContent>
@@ -1,5 +1,4 @@
import { startCompletion } from "@codemirror/autocomplete";
import { debounce } from "@yaakapp-internal/lib";
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
import { foldState, forceParsing } from "@codemirror/language";
import type { EditorStateConfig, Extension } from "@codemirror/state";
@@ -382,7 +381,6 @@ function EditorInner({
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy();
cm.current = null;
return;
@@ -641,7 +639,7 @@ function getExtensions({
onChange.current?.(update.state.doc.toString());
}
saveCachedEditorStateDebounced(stateKey, update.state);
saveCachedEditorState(stateKey, update.state);
}),
];
}
@@ -654,27 +652,6 @@ const placeholderElFromText = (text: string | undefined) => {
return el;
};
// Serializing the state (full doc + history) and md5-ing the doc is too
// expensive to do on every update (each keystroke and cursor move), so
// debounce it per state key and flush when the editor unmounts.
const SAVE_STATE_DEBOUNCE_MS = 500;
const stateSavers = new Map<string, ReturnType<typeof debounce>>();
function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) {
if (!stateKey) return;
let saver = stateSavers.get(stateKey);
if (saver == null) {
saver = debounce((s: EditorState) => saveCachedEditorState(stateKey, s), SAVE_STATE_DEBOUNCE_MS);
stateSavers.set(stateKey, saver);
}
saver(state);
}
function flushCachedEditorState(stateKey: string | null) {
if (!stateKey) return;
stateSavers.get(stateKey)?.flush();
}
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields);
+119 -22
View File
@@ -36,10 +36,19 @@ import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
export interface PairEditorHandle {
/**
* Focus a row's name field once it's able to take focus. Focus can't land immediately when the
* row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming
* active — so this retries for up to ~1s. A newer focus request cancels a pending one.
*/
focusName(id: string): void;
/** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
focusValue(id: string): void;
}
/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */
const MAX_FOCUS_ATTEMPTS = 60;
export type PairEditorProps = {
allowFileValues?: boolean;
allowMultilineValues?: boolean;
@@ -53,7 +62,7 @@ export type PairEditorProps = {
nameValidate?: InputProps["validate"];
noScroll?: boolean;
onChange: (pairs: PairWithId[]) => void;
pairs: Pair[];
pairs: EditablePair[];
stateKey: InputProps["stateKey"];
setRef?: (n: PairEditorHandle) => void;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
@@ -72,13 +81,35 @@ export type Pair = {
contentType?: string;
filename?: string;
isFile?: boolean;
readOnlyName?: boolean;
};
export type PairWithId = Pair & {
id: string;
};
/**
* A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain
* `Pair` stays the shape that gets written to models.
*/
export type EditablePair = Pair & {
/**
* When set, name edits are held until the field blurs and then committed through this, instead
* of calling `onChange` on every keystroke. Return false to reject the new name, which reverts
* the field. For names that can't be written directly, like a URL path placeholder that lives
* in the URL itself.
*/
commitName?: (name: string) => boolean;
};
type EditablePairWithId = EditablePair & {
id: string;
};
/** Strip the editor-only fields, so they can never reach a model write */
function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId {
return pair;
}
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30;
@@ -106,8 +137,8 @@ export function PairEditor({
setRef,
}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
const [pairs, setPairs] = useState<PairWithId[]>([]);
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
const [showAll, toggleShowAll] = useToggle(false);
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
@@ -115,16 +146,38 @@ export function PairEditor({
const rowsRef = useRef<Record<string, RowHandle | null>>({});
const pendingFocusFrame = useRef<number | null>(null);
useEffect(
() => () => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
},
[],
);
const focusWhenReady = useCallback((id: string, field: "name" | "value") => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
let attemptsLeft = MAX_FOCUS_ATTEMPTS;
const attempt = () => {
pendingFocusFrame.current = null;
const row = rowsRef.current[id];
const landed = field === "name" ? row?.focusName() : row?.focusValue();
if (landed || --attemptsLeft <= 0) return;
pendingFocusFrame.current = requestAnimationFrame(attempt);
};
attempt();
}, []);
const handle = useMemo<PairEditorHandle>(
() => ({
focusName(id: string) {
rowsRef.current[id]?.focusName();
focusWhenReady(id, "name");
},
focusValue(id: string) {
rowsRef.current[id]?.focusValue();
focusWhenReady(id, "value");
},
}),
[],
[focusWhenReady],
);
const initPairEditorRow = useCallback(
@@ -147,7 +200,7 @@ export function PairEditor({
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
useEffect(() => {
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
const newPairs: PairWithId[] = [];
const newPairs: EditablePairWithId[] = [];
for (let i = 0; i < originalPairs.length; i++) {
const p = originalPairs[i];
if (!p) continue; // Make TS happy
@@ -155,6 +208,21 @@ export function PairEditor({
newPairs.push(ensurePairId(p));
}
// When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps
// every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed
// themselves when `defaultValue` changes, and a rebuild would drop the user's focus and
// selection — like tabbing from a placeholder's name into its value.
const trailingPair = pairs[pairs.length - 1];
const sameRows =
trailingPair != null &&
isPairEmpty(trailingPair) &&
pairs.length === newPairs.length + 1 &&
newPairs.every((p, i) => p.id === pairs[i]?.id);
if (sameRows) {
setPairs([...newPairs, trailingPair]);
return;
}
// Add empty last pair if there is none
const lastPair = newPairs[newPairs.length - 1];
if (lastPair == null || !isPairEmpty(lastPair)) {
@@ -166,10 +234,10 @@ export function PairEditor({
}, [forceUpdateKey]);
const setPairsAndSave = useCallback(
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
setPairs((oldPairs) => {
const pairs = fn(oldPairs);
onChange(pairs);
onChange(pairs.map(toPairData));
return pairs;
});
},
@@ -177,7 +245,7 @@ export function PairEditor({
);
const handleChange = useCallback(
(pair: PairWithId) =>
(pair: EditablePairWithId) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
[setPairsAndSave],
);
@@ -362,14 +430,14 @@ export function PairEditor({
type PairEditorRowProps = {
className?: string;
pair: PairWithId;
pair: EditablePairWithId;
forceFocusNamePairId?: string | null;
forceFocusValuePairId?: string | null;
onChange?: (pair: PairWithId) => void;
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
onFocusName?: (pair: PairWithId) => void;
onFocusValue?: (pair: PairWithId) => void;
onSubmit?: (pair: PairWithId) => void;
onChange?: (pair: EditablePairWithId) => void;
onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
onFocusName?: (pair: EditablePairWithId) => void;
onFocusValue?: (pair: EditablePairWithId) => void;
onSubmit?: (pair: EditablePairWithId) => void;
isLast?: boolean;
disabled?: boolean;
disableDrag?: boolean;
@@ -397,8 +465,8 @@ type PairEditorRowProps = {
>;
interface RowHandle {
focusName(): void;
focusValue(): void;
focusName(): boolean;
focusValue(): boolean;
}
export function PairEditorRow({
@@ -436,9 +504,11 @@ export function PairEditorRow({
const handle = useRef<RowHandle>({
focusName() {
nameInputRef.current?.focus();
return nameInputRef.current?.isFocused() ?? false;
},
focusValue() {
valueInputRef.current?.focus();
return valueInputRef.current?.isFocused() ?? false;
},
});
@@ -471,11 +541,37 @@ export function PairEditorRow({
[onChange, pair],
);
// The name being typed into a deferred-commit field, before it's committed or reverted
const pendingName = useRef<string | null>(null);
const handleChangeName = useMemo(
() => (name: string) => onChange?.({ ...pair, name }),
() => (name: string) => {
// Keep the edit local until commit. Writing on every keystroke would reset the editor from
// beneath the cursor, since the pairs are derived from the name being edited.
if (pair.commitName != null) pendingName.current = name;
else onChange?.({ ...pair, name });
},
[onChange, pair],
);
const revertName = useCallback(() => {
const nameInput = nameInputRef.current;
if (nameInput != null) {
const changes = { from: 0, to: nameInput.value().length, insert: pair.name };
nameInput.dispatch({ changes });
}
pendingName.current = null;
}, [pair.name]);
const handleBlurName = useCallback(() => {
if (pair.commitName == null) return;
const name = pendingName.current;
pendingName.current = null;
if (name == null || name === pair.name) return;
if (!pair.commitName(name)) revertName();
}, [pair, revertName]);
const handleChangeValueText = useMemo(
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
[onChange, pair],
@@ -596,7 +692,7 @@ export function PairEditorRow({
stateKey={`name.${pair.id}.${stateKey}`}
disabled={disabled}
wrapLines={false}
readOnly={pair.readOnlyName || isDraggingGlobal}
readOnly={isDraggingGlobal}
size="sm"
required={!isLast && !!pair.enabled && !!pair.value}
validate={nameValidate}
@@ -606,6 +702,7 @@ export function PairEditorRow({
defaultValue={pair.name}
label="Name"
name={`name[${index}]`}
onBlur={handleBlurName}
onChange={handleChangeName}
onFocus={handleFocusName}
placeholder={namePlaceholder ?? "name"}
@@ -808,7 +905,7 @@ function FileActionsDropdown({
);
}
function emptyPair(): PairWithId {
function emptyPair(): EditablePairWithId {
return ensurePairId({ enabled: true, name: "", value: "" });
}
@@ -1,9 +1,10 @@
import { generateId } from "../../lib/generateId";
import type { Pair, PairWithId } from "./PairEditor";
export function ensurePairId(p: Pair): PairWithId {
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
if (typeof p.id === "string") {
return p as PairWithId;
return p as T & PairWithId;
}
return { ...p, id: p.id ?? generateId() };
}
-24
View File
@@ -4,7 +4,6 @@ import {
websocketRequestsAtom,
} from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
export const allRequestsAtom = atom((get) => [
...get(httpRequestsAtom),
@@ -15,26 +14,3 @@ export const allRequestsAtom = atom((get) => [
export function useAllRequests() {
return useAtomValue(allRequestsAtom);
}
const stringArrayEqual = (a: string[], b: string[]) =>
a.length === b.length && a.every((v, i) => v === b[i]);
// Identity-stable derivations so subscribers don't recompute or re-render when
// unrelated request fields change (eg. every debounced edit of a request)
export const allRequestIdsAtom = selectAtom(
allRequestsAtom,
(requests) => requests.map((r) => r.id),
stringArrayEqual,
);
export const allRequestUrlsAtom = selectAtom(
allRequestsAtom,
(requests) => {
const urls = new Set<string>();
for (const r of requests) {
if (r.url) urls.add(r.url);
}
return Array.from(urls);
},
stringArrayEqual,
);
+2 -9
View File
@@ -1,7 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { emit } from "@tauri-apps/api/event";
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
@@ -23,14 +22,8 @@ export function useGrpc(
const go = useMutation<void, string>({
mutationKey: ["grpc_go", conn?.id],
mutationFn: async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
return invokeCmd<void>("cmd_grpc_go", {
requestId,
environmentId: environment?.id,
protoFiles,
});
},
mutationFn: () =>
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }),
});
const send = useMutation({
+10 -8
View File
@@ -6,19 +6,21 @@ import { useMemo } from "react";
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
const folders = useAtomValue(foldersAtom);
// Key on folderId, not the model itself, so edits to the model (eg. every URL
// keystroke replacing the active request) don't produce a new array identity
const folderId = m?.folderId ?? null;
return useMemo(() => getParentFolders(folders, folderId), [folders, folderId]);
return useMemo(() => getParentFolders(folders, m), [folders, m]);
}
function getParentFolders(folders: Folder[], folderId: string | null): Folder[] {
if (folderId == null) return [];
function getParentFolders(
folders: Folder[],
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
): Folder[] {
if (currentModel == null) return [];
const parentFolder = folders.find((f) => f.id === folderId);
const parentFolder = currentModel.folderId
? folders.find((f) => f.id === currentModel.folderId)
: null;
if (parentFolder == null) {
return [];
}
return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)];
return [parentFolder, ...getParentFolders(folders, parentFolder)];
}
+187 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, test } from "vite-plus/test";
import { extractPathPlaceholders } from "./pathPlaceholders";
import {
derivePathPlaceholderPairs,
extractPathPlaceholders,
renamePathPlaceholder,
} from "./pathPlaceholders";
describe("extractPathPlaceholders", () => {
test("extracts a single placeholder", () => {
@@ -26,3 +30,185 @@ describe("extractPathPlaceholders", () => {
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
});
});
describe("derivePathPlaceholderPairs", () => {
const neverRename = () => false;
test("adds a row for a placeholder with no parameter", () => {
const { urlParameterPairs } = derivePathPlaceholderPairs("/users/:id", [], neverRename);
expect(urlParameterPairs).toMatchObject([{ name: ":id", value: "", enabled: true }]);
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
});
test("gives the existing parameter for a placeholder a commitName, without mutating it", () => {
const parameter = { name: ":id", value: "123", enabled: true, id: "p1" };
const { urlParameterPairs } = derivePathPlaceholderPairs(
"/users/:id",
[parameter],
neverRename,
);
expect(urlParameterPairs[0]).toMatchObject({ name: ":id", value: "123", id: "p1" });
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
expect(parameter).toEqual({ name: ":id", value: "123", enabled: true, id: "p1" });
});
test("leaves query parameters alone", () => {
const { urlParameterPairs } = derivePathPlaceholderPairs(
"/users/:id",
[{ name: "q", value: "hi", enabled: true, id: "p1" }],
neverRename,
);
expect(urlParameterPairs[0]).toEqual({ name: "q", value: "hi", enabled: true, id: "p1" });
expect(urlParameterPairs[1]?.commitName).toBeTypeOf("function");
});
test("commitName renames this row's placeholder", () => {
const renames: [string, string][] = [];
const { urlParameterPairs } = derivePathPlaceholderPairs(
"/a/:x/b/:y",
[],
(oldName, newName) => {
renames.push([oldName, newName]);
return true;
},
);
urlParameterPairs[1]?.commitName?.(":z");
expect(renames).toEqual([[":y", ":z"]]);
});
test("drops empty parameters", () => {
const { urlParameterPairs } = derivePathPlaceholderPairs(
"/users",
[
{ name: "", value: "", enabled: true, id: "p1" },
{ name: "q", value: "", enabled: true, id: "p2" },
],
neverRename,
);
expect(urlParameterPairs).toMatchObject([{ name: "q", id: "p2" }]);
});
test("collapses a placeholder that appears twice into one row", () => {
const { urlParameterPairs } = derivePathPlaceholderPairs("/a/:id/b/:id", [], neverRename);
expect(urlParameterPairs).toMatchObject([{ name: ":id" }]);
});
test("gives a derived row the same id every time, so re-deriving is stable", () => {
const first = derivePathPlaceholderPairs("/users/:id", [], neverRename);
const second = derivePathPlaceholderPairs("/users/:id", [], neverRename);
expect(first.urlParameterPairs[0]?.id).toEqual(second.urlParameterPairs[0]?.id);
});
test("derived row ids avoid colliding with a persisted derived id", () => {
// A derived id sticks to the parameter once the user gives the row a value. If its placeholder
// is then renamed away in the URL bar, the parameter survives as a stray still holding the id,
// and the replacement placeholder's row must not collide with it.
const stray = { name: ":old", value: "42", enabled: true, id: "path-placeholder:0" };
const { urlParameterPairs } = derivePathPlaceholderPairs("/pets/:new", [stray], neverRename);
const ids = urlParameterPairs.map((p) => p.id);
expect(new Set(ids).size).toEqual(ids.length);
});
test("keeps a derived row's id stable across a rename", () => {
const before = derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename);
const after = derivePathPlaceholderPairs("/a/:x2/b/:y", [], neverRename);
expect(after.urlParameterPairs.map((p) => p.id)).toEqual(
before.urlParameterPairs.map((p) => p.id),
);
});
test("keys off the placeholder names", () => {
expect(derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename).urlParametersKey).toEqual(
":x,:y",
);
expect(derivePathPlaceholderPairs("/a/b", [], neverRename).urlParametersKey).toEqual("");
});
});
describe("renamePathPlaceholder", () => {
const model = (url: string, urlParameters: { name: string; value: string }[] = []) => ({
url,
urlParameters,
});
test("renames the placeholder in the URL", () => {
expect(
renamePathPlaceholder(model("https://x.com/pets/:petId/info"), ":petId", ":animalId"),
).toEqual({ url: "https://x.com/pets/:animalId/info", urlParameters: [] });
});
test("carries the parameter value over to the new name", () => {
const patch = renamePathPlaceholder(
model("/pets/:petId", [
{ name: "q", value: "1" },
{ name: ":petId", value: "42" },
]),
":petId",
":animalId",
);
expect(patch).toEqual({
url: "/pets/:animalId",
urlParameters: [
{ name: "q", value: "1" },
{ name: ":animalId", value: "42" },
],
});
});
test("renames every occurrence of a repeated placeholder", () => {
expect(renamePathPlaceholder(model("/a/:id/b/:id"), ":id", ":key")?.url).toEqual(
"/a/:key/b/:key",
);
});
test("adds a missing leading colon", () => {
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", "animalId")?.url).toEqual(
"/pets/:animalId",
);
});
test("renames a placeholder followed by a literal colon", () => {
expect(renamePathPlaceholder(model("/tasks/:id:cancel"), ":id", ":taskId")?.url).toEqual(
"/tasks/:taskId:cancel",
);
});
test("does not rename a placeholder the new name is a prefix of", () => {
expect(renamePathPlaceholder(model("/a/:id/b/:idx"), ":id", ":key")?.url).toEqual(
"/a/:key/b/:idx",
);
});
test("does not touch a same-named segment that isn't a placeholder", () => {
expect(renamePathPlaceholder(model("/id/:id?x=:id"), ":id", ":key")?.url).toEqual(
"/id/:key?x=:id",
);
});
test("treats regex characters in the old name literally", () => {
expect(renamePathPlaceholder(model("/a/:i.d/b/:iXd"), ":i.d", ":key")?.url).toEqual(
"/a/:key/b/:iXd",
);
});
test.each([[""], [":"], [":a/b"], [":a?b"], [":a#b"], [":a:b"], [":a b"], [":a\tb"]])(
"rejects the unusable name %j",
(name) => {
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", name)).toBeNull();
},
);
test("rejects a name already used by another placeholder", () => {
expect(renamePathPlaceholder(model("/pets/:petId/:ownerId"), ":petId", ":ownerId")).toBeNull();
});
test("allows renaming a placeholder to itself", () => {
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", ":petId")?.url).toEqual(
"/pets/:petId",
);
});
test("rejects renaming a placeholder that isn't in the URL", () => {
expect(renamePathPlaceholder(model("/pets/:petId"), ":other", ":animalId")).toBeNull();
});
});
+88
View File
@@ -1,3 +1,6 @@
import type { HttpUrlParameter } from "@yaakapp-internal/models";
import type { EditablePair } from "../components/core/PairEditor";
/**
* Extract `:name`-style path placeholders from a URL string.
*
@@ -12,3 +15,88 @@
export function extractPathPlaceholders(url: string): string[] {
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
}
/**
* Build the rows for the Params tab: the request's URL parameters, plus a row for each path
* placeholder in the URL that doesn't have one yet. A placeholder that appears more than once
* in the URL still gets a single row.
*
* Only placeholder rows get a `commitName`, which makes the editor hold name edits until blur and
* hand them to `renamePlaceholder` instead of writing on every keystroke — renaming has to rewrite
* the URL too. `renamePlaceholder` returns false to reject the new name, which reverts the field.
*
* `urlParametersKey` changes whenever the URL's placeholders do, and is used to reset the pair
* editor so derived rows appear and disappear along with the URL.
*/
export function derivePathPlaceholderPairs(
url: string,
urlParameters: HttpUrlParameter[],
renamePlaceholder: (oldName: string, newName: string) => boolean,
): { urlParameterPairs: EditablePair[]; urlParametersKey: string } {
const placeholderNames = extractPathPlaceholders(url);
const commitNameFor = (oldName: string) => (newName: string) =>
renamePlaceholder(oldName, newName);
// NOTE: Copy each parameter because `commitName` is UI-only. Adding it in place would mutate the
// persisted model.
const urlParameterPairs: EditablePair[] = urlParameters
.filter((p) => p.name || p.value)
.map((p) =>
placeholderNames.includes(p.name) ? { ...p, commitName: commitNameFor(p.name) } : { ...p },
);
// NOTE: Ids are derived from the placeholder's position instead of generated, so neither
// re-deriving nor renaming hands a row a new identity. The pair editor keys rows by id, so a
// changed id remounts the row and drops the user's focus.
//
// A derived id sticks to the parameter once the user gives the row a value, so a parameter that
// outlives its placeholder (renamed away in the URL bar) still holds one. Skip past taken ids
// so a new placeholder at that position can't collide with it.
const takenIds = new Set(urlParameterPairs.map((p) => p.id));
const uniquePlaceholderNames = [...new Set(placeholderNames)];
for (const [index, name] of uniquePlaceholderNames.entries()) {
if (urlParameterPairs.some((p) => p.name === name)) continue;
let id = `path-placeholder:${index}`;
for (let bump = index + 1; takenIds.has(id); bump++) id = `path-placeholder:${bump}`;
takenIds.add(id);
urlParameterPairs.push({ name, value: "", enabled: true, commitName: commitNameFor(name), id });
}
return { urlParameterPairs, urlParametersKey: placeholderNames.join(",") };
}
/**
* Compute the patch for renaming a path placeholder: every occurrence replaced in the URL, and
* the matching URL parameter renamed so the user's value follows along. Both have to be applied
* together, or the value detaches from the placeholder.
*
* Returns `null` when the rename can't be applied, meaning the caller should leave the model
* alone. That's the case when the new name wouldn't parse as a placeholder anymore (empty, or
* containing `/`, `?`, `#`, `:`, or whitespace) or when it's already used by another placeholder
* in the URL. A missing leading `:` is added rather than rejected, since focusing the name field
* selects all of its text and typing over it is the natural way to rename.
*/
export function renamePathPlaceholder(
model: { url: string; urlParameters: HttpUrlParameter[] },
oldName: string,
newName: string,
): { url: string; urlParameters: HttpUrlParameter[] } | null {
const name = newName.startsWith(":") ? newName : `:${newName}`;
if (!/^:[^/?#:\s]+$/.test(name)) return null;
const placeholderNames = extractPathPlaceholders(model.url);
if (!placeholderNames.includes(oldName)) return null;
if (name !== oldName && placeholderNames.includes(name)) return null;
const pattern = new RegExp(`(/)${escapeRegExp(oldName)}(?=[/?#:]|$)`, "g");
return {
url: model.url.replace(pattern, (_match, slash: string) => `${slash}${name}`),
urlParameters: model.urlParameters.map((p) => (p.name === oldName ? { ...p, name } : p)),
};
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
-60
View File
@@ -1,6 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { debounce } from "@yaakapp-internal/lib";
import { AnyModel, ModelPayload } from "../bindings/gen_models";
import { modelStoreDataAtom } from "./atoms";
import { ExtractModel, JotaiStore, ModelStoreData } from "./types";
@@ -13,9 +12,6 @@ const pendingModelWrites = new Set<Promise<unknown>>();
export function initModelStore(store: JotaiStore) {
_store = store;
// Don't lose debounced patches if the window closes while one is pending
window.addEventListener("beforeunload", flushAllPendingPatches);
getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => {
if (shouldIgnoreModel(payload)) return;
@@ -57,7 +53,6 @@ function trackModelWrite<T>(write: Promise<T>): Promise<T> {
}
export async function flushAllModelWrites(): Promise<void> {
flushAllPendingPatches();
const results = await Promise.allSettled(pendingModelWrites);
const rejected = results.find((result) => result.status === "rejected");
if (rejected?.status === "rejected") {
@@ -65,61 +60,6 @@ export async function flushAllModelWrites(): Promise<void> {
}
}
const PATCH_DEBOUNCE_MS = 400;
interface PendingPatch {
model: AnyModel["model"];
id: string;
patch: Record<string, unknown>;
write: ReturnType<typeof debounce>;
}
const pendingPatches = new Map<string, PendingPatch>();
/**
* Like patchModel, but coalesces rapid patches to the same model (eg. one per
* keystroke) into a single write. Later fields overwrite earlier ones, so it's
* only safe for whole-value fields like url, body, or headers. Pending patches
* flush after a short delay, and flushAllModelWrites() (called before sends and
* duplicates) flushes them immediately.
*/
export function patchModelDebounced<
M extends AnyModel["model"],
T extends ExtractModel<AnyModel, M>,
>(base: Pick<T, "id" | "model">, patch: Partial<T>): void {
const key = `${base.model}.${base.id}`;
let pending = pendingPatches.get(key);
if (pending == null) {
pending = {
model: base.model,
id: base.id,
patch: {},
write: debounce(() => writePendingPatch(key), PATCH_DEBOUNCE_MS),
};
pendingPatches.set(key, pending);
}
pending.patch = { ...pending.patch, ...patch };
pending.write();
}
function writePendingPatch(key: string) {
const pending = pendingPatches.get(key);
if (pending == null) return;
pendingPatches.delete(key);
try {
void patchModelById(pending.model, pending.id, pending.patch);
} catch (err) {
// Model may have been deleted while the patch was pending
console.warn("Failed to flush pending patch", key, err);
}
}
export function flushAllPendingPatches() {
for (const pending of Array.from(pendingPatches.values())) {
pending.write.flush();
}
}
let _activeWorkspaceId: string | null = null;
export async function changeModelStoreWorkspace(workspaceId: string | null) {
+46 -1
View File
@@ -247,7 +247,8 @@ impl PluginManager {
pub async fn list_bundled_plugin_dirs(&self) -> Result<Vec<String>> {
let plugins_dir = self.get_plugins_dir();
info!("Loading bundled plugins from {plugins_dir:?}");
read_plugins_dir(&plugins_dir).await
let dirs = read_plugins_dir(&plugins_dir).await?;
Ok(dirs.into_iter().filter(|dir| !is_removed_bundled_plugin_dir(dir)).collect())
}
pub async fn resolve_plugins_for_runtime_from_db(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
@@ -1173,6 +1174,23 @@ fn prefer_plugin(candidate: &Plugin, existing: &Plugin) -> bool {
candidate.created_at > existing.created_at
}
/// Bundled plugin directories that shipped in past versions and no longer exist. Updates
/// can leave these behind on disk, where they'd be discovered as bundled plugins and load
/// alongside the plugin that replaced them, producing duplicate actions in menus.
///
/// Ignoring them here also drops any plugin rows users already have, because bundled rows
/// whose directory isn't in this list are filtered out by `resolve_plugins_for_runtime`.
///
/// `exporter-curl` was renamed to `action-copy-curl` in 19ffcd18, which is why affected
/// installs show "Copy as cURL" twice.
const REMOVED_BUNDLED_PLUGIN_DIRS: &[&str] = &["exporter-curl"];
/// Whether a plugin directory path is one of the known-removed bundled plugins.
fn is_removed_bundled_plugin_dir(dir: &str) -> bool {
let name = dir.trim_end_matches(['/', '\\']).rsplit(['/', '\\']).next().unwrap_or_default();
REMOVED_BUNDLED_PLUGIN_DIRS.contains(&name)
}
async fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> {
let mut result = read_dir(dir).await?;
let mut dirs: Vec<String> = vec![];
@@ -1198,3 +1216,30 @@ fn fix_windows_paths(p: &PathBuf) -> String {
// 2. Convert backslashes to forward slashes for Node.js compatibility
PathBuf::from(safe_path).to_slash_lossy().to_string()
}
#[cfg(test)]
mod tests {
use super::is_removed_bundled_plugin_dir;
#[test]
fn ignores_removed_bundled_plugins() {
assert!(is_removed_bundled_plugin_dir(
"/Applications/Yaak.app/vendored/plugins/exporter-curl"
));
// Windows paths are slash-normalized before reaching here, but handle both
assert!(is_removed_bundled_plugin_dir(
r"C:\Users\me\AppData\Local\Yaak\vendored\plugins\exporter-curl"
));
assert!(is_removed_bundled_plugin_dir("vendored/plugins/exporter-curl/"));
}
#[test]
fn keeps_current_bundled_plugins() {
assert!(!is_removed_bundled_plugin_dir(
"/Applications/Yaak.app/vendored/plugins/action-copy-curl"
));
assert!(!is_removed_bundled_plugin_dir("vendored/plugins/importer-curl"));
// Must match the whole directory name, not a substring
assert!(!is_removed_bundled_plugin_dir("vendored/plugins/my-exporter-curl"));
}
}
+6 -6
View File
@@ -1484,9 +1484,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"version": "1.19.17",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
"integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -17307,9 +17307,9 @@
}
},
"plugins-external/mcp-server/node_modules/@hono/node-server": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.10.tgz",
"integrity": "sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
"integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==",
"license": "MIT",
"engines": {
"node": ">=20"
+3 -22
View File
@@ -1,32 +1,13 @@
// oxlint-disable-next-line no-explicit-any
export function debounce(fn: (...args: any[]) => void, delay = 500) {
let timer: ReturnType<typeof setTimeout> | null = null;
// oxlint-disable-next-line no-explicit-any
let lastArgs: any[] | null = null;
let timer: ReturnType<typeof setTimeout>;
// oxlint-disable-next-line no-explicit-any
const result = (...args: any[]) => {
lastArgs = args;
if (timer != null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
}, delay);
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
result.cancel = () => {
if (timer != null) clearTimeout(timer);
timer = null;
lastArgs = null;
};
// Invoke a pending call immediately instead of waiting out the delay
result.flush = () => {
if (timer == null) return;
clearTimeout(timer);
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
};
return result;
}
+14 -53
View File
@@ -1,5 +1,4 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import type { Virtualizer } from "@tanstack/react-virtual";
import {
DndContext,
MeasuringStrategy,
@@ -24,7 +23,7 @@ import {
} from "react";
import { useKey, useKeyPressEvent } from "react-use";
import { computeSideForDragMove } from "../../lib/dnd";
import { useAtomValue, useStore } from "jotai";
import { useStore } from "jotai";
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
import { type CollapsedAtom, CollapsedAtomContext } from "./context";
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from "./common";
@@ -88,27 +87,7 @@ function TreeInner<T extends { id: string }>(
) {
const store = useStore();
const treeRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLElement, Element> | null>(null);
const getScrollElement = useCallback(() => treeRef.current, []);
const handleVirtualizerReady = useCallback((v: Virtualizer<HTMLElement, Element>) => {
virtualizerRef.current = v;
}, []);
const selectableItems = useSelectableItems(root);
// Only render nodes that are actually visible (not filtered out, and not
// inside a collapsed folder). Mounting every node regardless of visibility
// makes large workspaces unusable: thousands of hidden TreeItems each run
// their dnd/context hooks on every tree commit just to return null.
const collapsedMap = useAtomValue(collapsedAtom);
const visibleItems = useMemo(() => {
return selectableItems.filter((i) => {
if (i.node.hidden) return false;
for (let p = i.node.parent; p != null; p = p.parent) {
if (collapsedMap[p.item.id]) return false;
}
return true;
});
}, [selectableItems, collapsedMap]);
const [showContextMenu, setShowContextMenu] = useState<{
items: unknown[];
x: number;
@@ -146,28 +125,16 @@ function TreeInner<T extends { id: string }>(
}, []);
const tryFocus = useCallback(() => {
const find = () =>
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]');
const $el = find();
if ($el != null) {
// preventScroll so scrolling stays single-sourced (focus() implicitly
// scrolls, which fights the virtualizer's scrollToIndex)
$el.focus({ preventScroll: true });
$el.scrollIntoView({ block: "nearest" });
return true;
}
// The focused row may be virtualized out of range. Scroll it into range,
// then focus it once it has mounted.
const lastFocusedId = store.get(focusIdsFamily(treeId)).lastId;
const index = visibleItems.findIndex((i) => i.node.item.id === lastFocusedId);
if (index < 0) {
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
'.tree-item button[tabindex="0"]',
);
if ($el == null) {
return false;
}
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
requestAnimationFrame(() => find()?.focus({ preventScroll: true }));
$el.focus();
$el.scrollIntoView({ block: "nearest" });
return true;
}, [store, treeId, visibleItems]);
}, []);
const ensureTabbableItem = useCallback(() => {
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
@@ -481,8 +448,8 @@ function TreeInner<T extends { id: string }>(
store.set(hoveredParentFamily(treeId), {
parentId: root.item.id,
parentDepth: root.depth,
index: visibleItems.length,
childIndex: visibleItems.length,
index: selectableItems.length,
childIndex: selectableItems.length,
});
return;
}
@@ -510,8 +477,8 @@ function TreeInner<T extends { id: string }>(
const item = node.item;
let hoveredParent = node.parent;
const dragIndex = visibleItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = visibleItems[dragIndex]?.node ?? null;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === "before" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1);
@@ -542,7 +509,7 @@ function TreeInner<T extends { id: string }>(
});
}
},
[root.depth, root.item.id, selectableItems, treeId, visibleItems],
[root.depth, root.item.id, selectableItems, treeId],
);
const handleDragStart = useCallback(
@@ -713,18 +680,12 @@ function TreeInner<T extends { id: string }>(
"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none",
"[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none",
// Virtualized rows are wrapped in .tree-row divs, so the sibling
// relationships above need wrapper-aware equivalents
"[&_.tree-row:has(.tree-item.selected)+.tree-row_.tree-item.selected]:rounded-t-none",
"[&_.tree-row:has(.tree-item.selected):has(+.tree-row_.tree-item.selected)_.tree-item.selected]:rounded-b-none",
)}
>
<TreeItemList
addTreeItemRef={handleAddTreeItemRef}
nodes={visibleItems}
nodes={selectableItems}
treeId={treeId}
getScrollElement={getScrollElement}
onVirtualizerReady={handleVirtualizerReady}
{...treeItemListProps}
/>
</div>
@@ -1,7 +1,5 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { CSSProperties } from "react";
import { Fragment, useLayoutEffect, useRef, useState } from "react";
import { Fragment } from "react";
import type { SelectableTreeNode } from "./common";
import type { TreeProps } from "./Tree";
import { TreeDropMarker } from "./TreeDropMarker";
@@ -24,22 +22,9 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
className?: string;
forceDepth?: number;
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
/**
* Enable virtualization by providing the scroll container. Rows are then
* windowed with @tanstack/react-virtual and only visible rows mount.
*/
getScrollElement?: () => HTMLElement | null;
onVirtualizerReady?: (v: Virtualizer<HTMLElement, Element>) => void;
};
export function TreeItemList<T extends { id: string }>(props: TreeItemListProps<T>) {
if (props.getScrollElement != null) {
return <VirtualTreeItemList {...props} getScrollElement={props.getScrollElement} />;
}
return <StaticTreeItemList {...props} />;
}
function StaticTreeItemList<T extends { id: string }>({
export function TreeItemList<T extends { id: string }>({
className,
getItemKey,
nodes,
@@ -47,8 +32,6 @@ function StaticTreeItemList<T extends { id: string }>({
treeId,
forceDepth,
addTreeItemRef,
getScrollElement: _getScrollElement,
onVirtualizerReady: _onVirtualizerReady,
...props
}: TreeItemListProps<T>) {
return (
@@ -70,89 +53,3 @@ function StaticTreeItemList<T extends { id: string }>({
</ul>
);
}
// Rows are --height-sm (2rem). Derive the pixel estimate from the actual root
// font size so scroll math stays accurate under interface scaling.
function estimateRowHeightPx() {
const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return 2 * rem;
}
function VirtualTreeItemList<T extends { id: string }>({
className,
getItemKey,
nodes,
style,
treeId,
forceDepth,
addTreeItemRef,
getScrollElement,
onVirtualizerReady,
...props
}: TreeItemListProps<T> & { getScrollElement: () => HTMLElement | null }) {
const listRef = useRef<HTMLUListElement>(null);
// Offset of the list within the scroll container (eg. container padding),
// so windowing and scrollToIndex targets aren't shifted by it
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const list = listRef.current;
const scroller = getScrollElement();
if (list == null || scroller == null) return;
const offset =
list.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
setScrollMargin(offset);
}, [getScrollElement]);
const virtualizer = useVirtualizer({
count: nodes.length,
getScrollElement,
estimateSize: estimateRowHeightPx,
overscan: 10,
scrollMargin,
});
useLayoutEffect(() => {
onVirtualizerReady?.(virtualizer);
}, [virtualizer, onVirtualizerReady]);
return (
<ul
ref={listRef}
style={{ ...style, height: `${virtualizer.getTotalSize()}px`, position: "relative" }}
className={className}
>
<TreeDropMarker node={null} treeId={treeId} index={0} />
{virtualizer.getVirtualItems().map((virtualItem) => {
const child = nodes[virtualItem.index];
if (child == null) return null;
return (
<div
// Key by item so window shifts don't remount rows unnecessarily
key={getItemKey(child.node.item)}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="tree-row"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
}}
>
<TreeItem
treeId={treeId}
setRef={addTreeItemRef}
node={child.node}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
{...props}
/>
<TreeDropMarker node={child.node} treeId={treeId} index={virtualItem.index + 1} />
</div>
);
})}
</ul>
);
}
@@ -42,12 +42,27 @@ const expressionArg: TemplateFunctionArg = {
const formatArg: TemplateFunctionArg = {
name: "format",
label: "Format String",
description: "Format string to describe the output (eg. 'yyyy-MM-dd at HH:mm:ss')",
description:
"date-fns format string to describe the output (eg. \"yyyy-MM-dd 'at' HH:mm:ss\"). " +
"Wrap literal text in single quotes to escape it",
optional: true,
placeholder: "yyyy-MM-dd HH:mm:ss",
type: "text",
};
const formatDocsBanner: TemplateFunctionArg = {
type: "banner",
color: "info",
inputs: [
{
type: "markdown",
content:
"Uses [date-fns format tokens](https://date-fns.org/docs/format), " +
"not dayjs or Moment. Wrap literal text in single quotes to escape it.",
},
],
};
export const plugin: PluginDefinition = {
templateFunctions: [
{
@@ -79,8 +94,8 @@ export const plugin: PluginDefinition = {
},
{
name: "timestamp.format",
description: "Format a date using a dayjs-compatible format string",
args: [dateArg, formatArg],
description: "Format a date using a date-fns format string",
args: [formatDocsBanner, dateArg, formatArg],
previewArgs: [formatArg.name],
onRender: async (_ctx, args) => formatDatetime(args.values),
},