Compare commits

...
Author SHA1 Message Date
Gregory SchierandGitHub 1f8fc00e7c Merge branch 'main' into worktree-perf 2026-08-02 08:07:00 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
077bfa87b8 Bump body-parser from 2.2.2 to 2.3.0 (#512)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:31:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
733f2e5929 Bump @hono/node-server from 1.19.14 to 2.0.10 (#513)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:04 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0f994f89ac Bump hono from 4.12.25 to 4.12.27 (#514)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
24f76398f9 Bump postcss from 8.5.16 to 8.5.25 (#522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:59 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
14475915df Bump shell-quote from 1.8.4 to 1.10.0 (#517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:57 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a50e04f565 Bump seroval from 1.4.2 to 1.5.6 (#518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:54 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
32fbd66912 Bump fast-uri from 3.1.2 to 3.1.5 (#521)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:52 -07:00
SuandGitHub 3503a9da8e Fix inline rename during IME composition (#519) 2026-08-01 07:45:36 -07:00
Gregory Schier a2a6cb17ca Improve typing and sidebar performance in large workspaces 2026-07-25 07:14:46 -07:00
18 changed files with 462 additions and 118 deletions
@@ -1,4 +1,4 @@
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models"; import { type GrpcRequest, type HttpRequestHeader, patchModel, patchModelDebounced} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui"; import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
@@ -75,11 +75,13 @@ export function GrpcRequestPane({
const { width: paneWidth } = useContainerSize(urlContainerEl); const { width: paneWidth } = useContainerSize(urlContainerEl);
const handleChangeUrl = useCallback( const handleChangeUrl = useCallback(
(url: string) => patchModel(activeRequest, { url }), (url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest], [activeRequest],
); );
const handleChangeMessage = useCallback( 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 }), (message: string) => patchModel(activeRequest, { message }),
[activeRequest], [activeRequest],
); );
@@ -146,12 +148,12 @@ export function GrpcRequestPane({
); );
const handleMetadataChange = useCallback( const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }), (metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
[activeRequest], [activeRequest],
); );
const handleDescriptionChange = useCallback( const handleDescriptionChange = useCallback(
(description: string) => patchModel(activeRequest, { description }), (description: string) => patchModelDebounced(activeRequest, { description }),
[activeRequest], [activeRequest],
); );
@@ -299,7 +301,7 @@ export function GrpcRequestPane({
className="font-sans text-xl! px-0!" className="font-sans text-xl! px-0!"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModelDebounced(activeRequest, { name })}
/> />
<MarkdownEditor <MarkdownEditor
name="request-description" name="request-description"
+15 -20
View File
@@ -1,12 +1,11 @@
import type { HttpRequest } from "@yaakapp-internal/models"; import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models"; import { patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames"; import classNames from "classnames";
import { atom, useAtomValue } from "jotai"; import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId"; import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab"; import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab"; import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -16,7 +15,6 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders"; import { extractPathPlaceholders } from "../lib/pathPlaceholders";
@@ -77,15 +75,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description"; const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs"; const TABS_STORAGE_KEY = "http_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => { // Derived from the identity-stable URL list so this only recomputes when a URL
const activeRequestId = get(activeRequestIdAtom); // actually changes. The active request's own URL is included, but exact matches
const requests = get(allRequestsAtom); // are filtered out at completion time by genericCompletion.
return requests const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
.filter((r) => r.id !== activeRequestId) get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url })); );
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) { export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id; const activeRequestId = activeRequest.id;
@@ -274,16 +269,16 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { mutate: importCurl } = useImportCurl(); const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback( const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }), (body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
[activeRequest], [activeRequest],
); );
const handleBodyTextChange = useCallback( const handleBodyTextChange = useCallback(
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }), (text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest], [activeRequest],
); );
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo( const autocomplete: GenericCompletionConfig = useMemo(
() => getUrlCompletionConfig(autocompleteUrls), () => getUrlCompletionConfig(autocompleteUrls),
@@ -323,7 +318,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
); );
const handleUrlChange = useCallback( const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }), (url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest], [activeRequest],
); );
@@ -369,7 +364,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`} forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers} headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`} stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })} onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_PARAMS}> <TabContent value={TAB_PARAMS}>
@@ -377,7 +372,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`params.${activeRequest.id}`} stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey} forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs} pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })} onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_SETTINGS}> <TabContent value={TAB_SETTINGS}>
@@ -429,7 +424,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
requestId={activeRequest.id} requestId={activeRequest.id}
contentType={contentType} contentType={contentType}
body={activeRequest.body} body={activeRequest.body}
onChange={(body) => patchModel(activeRequest, { body })} onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange} onChangeContentType={handleContentTypeChange}
/> />
) : typeof activeRequest.bodyType === "string" ? ( ) : typeof activeRequest.bodyType === "string" ? (
+6 -1
View File
@@ -112,6 +112,7 @@ function Sidebar({ className }: { className?: string }) {
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`; const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom); const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? []; const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null); const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null); const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null); const filterRef = useRef<InputHandle>(null);
@@ -724,7 +725,11 @@ function Sidebar({ className }: { className?: string }) {
); );
} }
export default Sidebar; // 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);
function getGitContextMenuItems({ function getGitContextMenuItems({
items, items,
@@ -1,5 +1,5 @@
import type { WebsocketRequest } from "@yaakapp-internal/models"; import type { WebsocketRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models"; import { flushAllModelWrites, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws"; import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
import classNames from "classnames"; import classNames from "classnames";
@@ -8,8 +8,7 @@ import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar"; import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { getActiveEnvironment } from "../hooks/useActiveEnvironment"; import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId"; import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab"; import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab"; import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -18,7 +17,6 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection"; import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType"; import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders"; import { extractPathPlaceholders } from "../lib/pathPlaceholders";
@@ -55,15 +53,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description"; const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs"; const TABS_STORAGE_KEY = "websocket_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => { // Derived from the identity-stable URL list so this only recomputes when a URL
const activeRequestId = get(activeRequestIdAtom); // actually changes. The active request's own URL is included, but exact matches
const requests = get(allRequestsAtom); // are filtered out at completion time by genericCompletion.
return requests const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
.filter((r) => r.id !== activeRequestId) get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url })); );
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) { export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id; const activeRequestId = activeRequest.id;
@@ -128,7 +123,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom); const connection = useAtomValue(activeWebsocketConnectionAtom);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo( const autocomplete: GenericCompletionConfig = useMemo(
() => getUrlCompletionConfig(autocompleteUrls), () => getUrlCompletionConfig(autocompleteUrls),
@@ -136,6 +131,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
); );
const handleConnect = useCallback(async () => { const handleConnect = useCallback(async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
await connectWebsocket({ await connectWebsocket({
requestId: activeRequest.id, requestId: activeRequest.id,
environmentId: getActiveEnvironment()?.id ?? null, environmentId: getActiveEnvironment()?.id ?? null,
@@ -145,6 +141,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
if (connection == null) return; if (connection == null) return;
await flushAllModelWrites(); // The backend reads the message from the DB
await sendWebsocket({ await sendWebsocket({
connectionId: connection?.id, connectionId: connection?.id,
environmentId: getActiveEnvironment()?.id ?? null, environmentId: getActiveEnvironment()?.id ?? null,
@@ -157,7 +154,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, [connection]); }, [connection]);
const handleUrlChange = useCallback( const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }), (url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest], [activeRequest],
); );
@@ -241,7 +238,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
headers={activeRequest.headers} headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`} stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })} onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_PARAMS}> <TabContent value={TAB_PARAMS}>
@@ -249,7 +246,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`params.${activeRequest.id}`} stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey} forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs} pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })} onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/> />
</TabContent> </TabContent>
<TabContent value={TAB_MESSAGE}> <TabContent value={TAB_MESSAGE}>
@@ -261,7 +258,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
heightMode={fullHeight ? "full" : "auto"} heightMode={fullHeight ? "full" : "auto"}
defaultValue={activeRequest.message} defaultValue={activeRequest.message}
language={messageLanguage} language={messageLanguage}
onChange={(message) => patchModel(activeRequest, { message })} onChange={(message) => patchModelDebounced(activeRequest, { message })}
stateKey={`json.${activeRequest.id}`} stateKey={`json.${activeRequest.id}`}
/> />
</TabContent> </TabContent>
@@ -278,7 +275,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
className="font-sans text-xl! px-0!" className="font-sans text-xl! px-0!"
containerClassName="border-0" containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)} placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })} onChange={(name) => patchModelDebounced(activeRequest, { name })}
/> />
<MarkdownEditor <MarkdownEditor
name="request-description" name="request-description"
@@ -286,7 +283,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
defaultValue={activeRequest.description} defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`} stateKey={`description.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
onChange={(description) => patchModel(activeRequest, { description })} onChange={(description) => patchModelDebounced(activeRequest, { description })}
/> />
</div> </div>
</TabContent> </TabContent>
@@ -1,4 +1,5 @@
import { startCompletion } from "@codemirror/autocomplete"; import { startCompletion } from "@codemirror/autocomplete";
import { debounce } from "@yaakapp-internal/lib";
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands"; import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
import { foldState, forceParsing } from "@codemirror/language"; import { foldState, forceParsing } from "@codemirror/language";
import type { EditorStateConfig, Extension } from "@codemirror/state"; import type { EditorStateConfig, Extension } from "@codemirror/state";
@@ -381,6 +382,7 @@ function EditorInner({
const initEditorRef = useCallback( const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) { function initEditorRef(container: HTMLDivElement | null) {
if (container === null) { if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy(); cm.current?.view.destroy();
cm.current = null; cm.current = null;
return; return;
@@ -639,7 +641,7 @@ function getExtensions({
onChange.current?.(update.state.doc.toString()); onChange.current?.(update.state.doc.toString());
} }
saveCachedEditorState(stateKey, update.state); saveCachedEditorStateDebounced(stateKey, update.state);
}), }),
]; ];
} }
@@ -652,6 +654,27 @@ const placeholderElFromText = (text: string | undefined) => {
return el; 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) { function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
if (!stateKey || state == null) return; if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields); const stateObj = state.toJSON(stateFields);
+24
View File
@@ -4,6 +4,7 @@ import {
websocketRequestsAtom, websocketRequestsAtom,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai"; import { atom, useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
export const allRequestsAtom = atom((get) => [ export const allRequestsAtom = atom((get) => [
...get(httpRequestsAtom), ...get(httpRequestsAtom),
@@ -14,3 +15,26 @@ export const allRequestsAtom = atom((get) => [
export function useAllRequests() { export function useAllRequests() {
return useAtomValue(allRequestsAtom); 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,
);
+9 -2
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models"; import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis"; import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri"; import { invokeCmd } from "../lib/tauri";
@@ -22,8 +23,14 @@ export function useGrpc(
const go = useMutation<void, string>({ const go = useMutation<void, string>({
mutationKey: ["grpc_go", conn?.id], mutationKey: ["grpc_go", conn?.id],
mutationFn: () => mutationFn: async () => {
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }), await flushAllModelWrites(); // The backend reads the request from the DB
return invokeCmd<void>("cmd_grpc_go", {
requestId,
environmentId: environment?.id,
protoFiles,
});
},
}); });
const send = useMutation({ const send = useMutation({
+8 -10
View File
@@ -6,21 +6,19 @@ import { useMemo } from "react";
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) { export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
const folders = useAtomValue(foldersAtom); const folders = useAtomValue(foldersAtom);
return useMemo(() => getParentFolders(folders, m), [folders, m]); // 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]);
} }
function getParentFolders( function getParentFolders(folders: Folder[], folderId: string | null): Folder[] {
folders: Folder[], if (folderId == null) return [];
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
): Folder[] {
if (currentModel == null) return [];
const parentFolder = currentModel.folderId const parentFolder = folders.find((f) => f.id === folderId);
? folders.find((f) => f.id === currentModel.folderId)
: null;
if (parentFolder == null) { if (parentFolder == null) {
return []; return [];
} }
return [parentFolder, ...getParentFolders(folders, parentFolder)]; return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)];
} }
+60
View File
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { debounce } from "@yaakapp-internal/lib";
import { AnyModel, ModelPayload } from "../bindings/gen_models"; import { AnyModel, ModelPayload } from "../bindings/gen_models";
import { modelStoreDataAtom } from "./atoms"; import { modelStoreDataAtom } from "./atoms";
import { ExtractModel, JotaiStore, ModelStoreData } from "./types"; import { ExtractModel, JotaiStore, ModelStoreData } from "./types";
@@ -12,6 +13,9 @@ const pendingModelWrites = new Set<Promise<unknown>>();
export function initModelStore(store: JotaiStore) { export function initModelStore(store: JotaiStore) {
_store = store; _store = store;
// Don't lose debounced patches if the window closes while one is pending
window.addEventListener("beforeunload", flushAllPendingPatches);
getCurrentWebviewWindow() getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => { .listen<ModelPayload>("model_write", ({ payload }) => {
if (shouldIgnoreModel(payload)) return; if (shouldIgnoreModel(payload)) return;
@@ -53,6 +57,7 @@ function trackModelWrite<T>(write: Promise<T>): Promise<T> {
} }
export async function flushAllModelWrites(): Promise<void> { export async function flushAllModelWrites(): Promise<void> {
flushAllPendingPatches();
const results = await Promise.allSettled(pendingModelWrites); const results = await Promise.allSettled(pendingModelWrites);
const rejected = results.find((result) => result.status === "rejected"); const rejected = results.find((result) => result.status === "rejected");
if (rejected?.status === "rejected") { if (rejected?.status === "rejected") {
@@ -60,6 +65,61 @@ 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; let _activeWorkspaceId: string | null = null;
export async function changeModelStoreWorkspace(workspaceId: string | null) { export async function changeModelStoreWorkspace(workspaceId: string | null) {
+79 -37
View File
@@ -83,12 +83,12 @@
"@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6", "@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "latest", "@yaakapp/cli": "*",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0", "nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.5.16", "postcss": "^8.5.25",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
@@ -6331,20 +6331,20 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^2.0.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.1",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.1", "qs": "^6.15.2",
"raw-body": "^3.0.1", "raw-body": "^3.0.2",
"type-is": "^2.0.1" "type-is": "^2.1.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -6354,6 +6354,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -8386,9 +8399,9 @@
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -9249,9 +9262,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.25", "version": "4.12.27",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -12107,9 +12120,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -13483,9 +13496,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -13503,7 +13516,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.16",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -14616,9 +14629,9 @@
} }
}, },
"node_modules/seroval": { "node_modules/seroval": {
"version": "1.4.2", "version": "1.5.6",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz",
"integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==", "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -14739,9 +14752,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.4", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -15916,17 +15929,34 @@
} }
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^2.0.0",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
"mime-types": "^3.0.0" "mime-types": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/type-is/node_modules/mime-db": { "node_modules/type-is/node_modules/mime-db": {
@@ -17266,9 +17296,9 @@
"version": "0.2.1", "version": "0.2.1",
"dependencies": { "dependencies": {
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13", "@hono/node-server": "^2.0.10",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.25", "hono": "^4.12.27",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -17276,6 +17306,18 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
"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==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"hono": "^4"
}
},
"plugins/action-copy-curl": { "plugins/action-copy-curl": {
"name": "@yaak/action-copy-curl", "name": "@yaak/action-copy-curl",
"version": "0.1.0" "version": "0.1.0"
+1 -1
View File
@@ -121,7 +121,7 @@
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0", "nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.5.16", "postcss": "^8.5.25",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
+22 -3
View File
@@ -1,13 +1,32 @@
// oxlint-disable-next-line no-explicit-any // oxlint-disable-next-line no-explicit-any
export function debounce(fn: (...args: any[]) => void, delay = 500) { export function debounce(fn: (...args: any[]) => void, delay = 500) {
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout> | null = null;
// oxlint-disable-next-line no-explicit-any
let lastArgs: any[] | null = null;
// oxlint-disable-next-line no-explicit-any // oxlint-disable-next-line no-explicit-any
const result = (...args: any[]) => { const result = (...args: any[]) => {
clearTimeout(timer); lastArgs = args;
timer = setTimeout(() => fn(...args), delay); if (timer != null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
}, delay);
}; };
result.cancel = () => { 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); clearTimeout(timer);
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
}; };
return result; return result;
} }
+53 -14
View File
@@ -1,4 +1,5 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core"; import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import type { Virtualizer } from "@tanstack/react-virtual";
import { import {
DndContext, DndContext,
MeasuringStrategy, MeasuringStrategy,
@@ -23,7 +24,7 @@ import {
} from "react"; } from "react";
import { useKey, useKeyPressEvent } from "react-use"; import { useKey, useKeyPressEvent } from "react-use";
import { computeSideForDragMove } from "../../lib/dnd"; import { computeSideForDragMove } from "../../lib/dnd";
import { useStore } from "jotai"; import { useAtomValue, useStore } from "jotai";
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms"; import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
import { type CollapsedAtom, CollapsedAtomContext } from "./context"; import { type CollapsedAtom, CollapsedAtomContext } from "./context";
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from "./common"; import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from "./common";
@@ -87,7 +88,27 @@ function TreeInner<T extends { id: string }>(
) { ) {
const store = useStore(); const store = useStore();
const treeRef = useRef<HTMLDivElement>(null); 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); 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<{ const [showContextMenu, setShowContextMenu] = useState<{
items: unknown[]; items: unknown[];
x: number; x: number;
@@ -125,16 +146,28 @@ function TreeInner<T extends { id: string }>(
}, []); }, []);
const tryFocus = useCallback(() => { const tryFocus = useCallback(() => {
const $el = treeRef.current?.querySelector<HTMLButtonElement>( const find = () =>
'.tree-item button[tabindex="0"]', treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]');
); const $el = find();
if ($el == null) { 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) {
return false; return false;
} }
$el.focus(); virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
$el.scrollIntoView({ block: "nearest" }); requestAnimationFrame(() => find()?.focus({ preventScroll: true }));
return true; return true;
}, []); }, [store, treeId, visibleItems]);
const ensureTabbableItem = useCallback(() => { const ensureTabbableItem = useCallback(() => {
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId; const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
@@ -448,8 +481,8 @@ function TreeInner<T extends { id: string }>(
store.set(hoveredParentFamily(treeId), { store.set(hoveredParentFamily(treeId), {
parentId: root.item.id, parentId: root.item.id,
parentDepth: root.depth, parentDepth: root.depth,
index: selectableItems.length, index: visibleItems.length,
childIndex: selectableItems.length, childIndex: visibleItems.length,
}); });
return; return;
} }
@@ -477,8 +510,8 @@ function TreeInner<T extends { id: string }>(
const item = node.item; const item = node.item;
let hoveredParent = node.parent; let hoveredParent = node.parent;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1; const dragIndex = visibleItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null; const hovered = visibleItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === "before" ? 0 : 1); const hoveredIndex = dragIndex + (side === "before" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1); let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1);
@@ -509,7 +542,7 @@ function TreeInner<T extends { id: string }>(
}); });
} }
}, },
[root.depth, root.item.id, selectableItems, treeId], [root.depth, root.item.id, selectableItems, treeId, visibleItems],
); );
const handleDragStart = useCallback( const handleDragStart = useCallback(
@@ -680,12 +713,18 @@ function TreeInner<T extends { id: string }>(
"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none", "[&_.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(+.tree-item.selected)]:rounded-b-none",
"[&_.tree-item.selected:has(+.drop-marker+.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 <TreeItemList
addTreeItemRef={handleAddTreeItemRef} addTreeItemRef={handleAddTreeItemRef}
nodes={selectableItems} nodes={visibleItems}
treeId={treeId} treeId={treeId}
getScrollElement={getScrollElement}
onVirtualizerReady={handleVirtualizerReady}
{...treeItemListProps} {...treeItemListProps}
/> />
</div> </div>
@@ -20,6 +20,7 @@ import {
} from "./context"; } from "./context";
import type { TreeNode } from "./common"; import type { TreeNode } from "./common";
import { getNodeKey } from "./common"; import { getNodeKey } from "./common";
import { isImeCompositionEvent } from "./keyboard";
import type { TreeProps } from "./Tree"; import type { TreeProps } from "./Tree";
import { TreeIndentGuide } from "./TreeIndentGuide"; import { TreeIndentGuide } from "./TreeIndentGuide";
@@ -170,6 +171,8 @@ function TreeItem_<T extends { id: string }>({
const handleEditKeyDown = useCallback( const handleEditKeyDown = useCallback(
async (e: ReactKeyboardEvent<HTMLInputElement>) => { async (e: ReactKeyboardEvent<HTMLInputElement>) => {
e.stopPropagation(); // Don't trigger other tree keys (like arrows) e.stopPropagation(); // Don't trigger other tree keys (like arrows)
if (isImeCompositionEvent(e.nativeEvent)) return;
switch (e.key) { switch (e.key) {
case "Enter": case "Enter":
if (editing) { if (editing) {
@@ -1,5 +1,7 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { Fragment } from "react"; import { Fragment, useLayoutEffect, useRef, useState } from "react";
import type { SelectableTreeNode } from "./common"; import type { SelectableTreeNode } from "./common";
import type { TreeProps } from "./Tree"; import type { TreeProps } from "./Tree";
import { TreeDropMarker } from "./TreeDropMarker"; import { TreeDropMarker } from "./TreeDropMarker";
@@ -22,9 +24,22 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
className?: string; className?: string;
forceDepth?: number; forceDepth?: number;
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void; 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 }>({ 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 }>({
className, className,
getItemKey, getItemKey,
nodes, nodes,
@@ -32,6 +47,8 @@ export function TreeItemList<T extends { id: string }>({
treeId, treeId,
forceDepth, forceDepth,
addTreeItemRef, addTreeItemRef,
getScrollElement: _getScrollElement,
onVirtualizerReady: _onVirtualizerReady,
...props ...props
}: TreeItemListProps<T>) { }: TreeItemListProps<T>) {
return ( return (
@@ -53,3 +70,89 @@ export function TreeItemList<T extends { id: string }>({
</ul> </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>
);
}
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vite-plus/test";
import { isImeCompositionEvent } from "./keyboard";
describe("isImeCompositionEvent", () => {
test("detects an active standards-based composition", () => {
expect(isImeCompositionEvent({ isComposing: true, keyCode: 13 })).toBe(true);
});
test("detects the Safari/WebKit key code fallback", () => {
expect(isImeCompositionEvent({ isComposing: false, keyCode: 229 })).toBe(true);
});
test("does not classify an ordinary Enter keydown as composition", () => {
expect(isImeCompositionEvent({ isComposing: false, keyCode: 13 })).toBe(false);
});
test("does not classify an ordinary Escape keydown as composition", () => {
expect(isImeCompositionEvent({ isComposing: false, keyCode: 27 })).toBe(false);
});
});
@@ -0,0 +1,7 @@
export type ImeKeyboardEvent = Pick<KeyboardEvent, "isComposing" | "keyCode">;
export function isImeCompositionEvent(event: ImeKeyboardEvent): boolean {
// Safari can clear `isComposing` on the keydown that finishes composition.
// `229` is retained as the compatibility signal that an IME is processing it.
return event.isComposing || event.keyCode === 229;
}
+2 -2
View File
@@ -15,9 +15,9 @@
}, },
"dependencies": { "dependencies": {
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13", "@hono/node-server": "^2.0.10",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.25", "hono": "^4.12.27",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {