mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-09 13:28:38 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8fc00e7c | ||
|
|
a2a6cb17ca |
@@ -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 classNames from "classnames";
|
||||
import type { CSSProperties } from "react";
|
||||
@@ -75,11 +75,13 @@ export function GrpcRequestPane({
|
||||
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
||||
|
||||
const handleChangeUrl = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
(url: string) => patchModelDebounced(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],
|
||||
);
|
||||
@@ -146,12 +148,12 @@ export function GrpcRequestPane({
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
||||
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(description: string) => patchModel(activeRequest, { description }),
|
||||
(description: string) => patchModelDebounced(activeRequest, { description }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
@@ -299,7 +301,7 @@ export function GrpcRequestPane({
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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 classNames from "classnames";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
@@ -16,7 +15,6 @@ 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";
|
||||
@@ -77,15 +75,12 @@ const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
||||
|
||||
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);
|
||||
// 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 })),
|
||||
);
|
||||
|
||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
@@ -274,16 +269,16 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
const { mutate: importCurl } = useImportCurl();
|
||||
|
||||
const handleBodyChange = useCallback(
|
||||
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
|
||||
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleBodyTextChange = useCallback(
|
||||
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
@@ -323,7 +318,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
@@ -369,7 +364,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||
headers={activeRequest.headers}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
||||
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PARAMS}>
|
||||
@@ -377,7 +372,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={urlParameterPairs}
|
||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS}>
|
||||
@@ -429,7 +424,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={(body) => patchModel(activeRequest, { body })}
|
||||
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : typeof activeRequest.bodyType === "string" ? (
|
||||
|
||||
@@ -112,6 +112,7 @@ 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);
|
||||
@@ -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({
|
||||
items,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
||||
import classNames from "classnames";
|
||||
@@ -8,8 +8,7 @@ import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
@@ -18,7 +17,6 @@ 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";
|
||||
@@ -55,15 +53,12 @@ const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TABS_STORAGE_KEY = "websocket_request_tabs";
|
||||
|
||||
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);
|
||||
// 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 })),
|
||||
);
|
||||
|
||||
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
@@ -128,7 +123,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
@@ -136,6 +131,7 @@ 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,
|
||||
@@ -145,6 +141,7 @@ 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,
|
||||
@@ -157,7 +154,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
}, [connection]);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
@@ -241,7 +238,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
headers={activeRequest.headers}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
||||
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PARAMS}>
|
||||
@@ -249,7 +246,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={urlParameterPairs}
|
||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_MESSAGE}>
|
||||
@@ -261,7 +258,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={activeRequest.message}
|
||||
language={messageLanguage}
|
||||
onChange={(message) => patchModel(activeRequest, { message })}
|
||||
onChange={(message) => patchModelDebounced(activeRequest, { message })}
|
||||
stateKey={`json.${activeRequest.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
@@ -278,7 +275,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
@@ -286,7 +283,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
onChange={(description) => patchModelDebounced(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -381,6 +382,7 @@ function EditorInner({
|
||||
const initEditorRef = useCallback(
|
||||
function initEditorRef(container: HTMLDivElement | null) {
|
||||
if (container === null) {
|
||||
flushCachedEditorState(stateKey);
|
||||
cm.current?.view.destroy();
|
||||
cm.current = null;
|
||||
return;
|
||||
@@ -639,7 +641,7 @@ function getExtensions({
|
||||
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;
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
websocketRequestsAtom,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
|
||||
export const allRequestsAtom = atom((get) => [
|
||||
...get(httpRequestsAtom),
|
||||
@@ -14,3 +15,26 @@ 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,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -22,8 +23,14 @@ export function useGrpc(
|
||||
|
||||
const go = useMutation<void, string>({
|
||||
mutationKey: ["grpc_go", conn?.id],
|
||||
mutationFn: () =>
|
||||
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }),
|
||||
mutationFn: async () => {
|
||||
await flushAllModelWrites(); // The backend reads the request from the DB
|
||||
return invokeCmd<void>("cmd_grpc_go", {
|
||||
requestId,
|
||||
environmentId: environment?.id,
|
||||
protoFiles,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const send = useMutation({
|
||||
|
||||
@@ -6,21 +6,19 @@ import { useMemo } from "react";
|
||||
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
|
||||
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(
|
||||
folders: Folder[],
|
||||
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
|
||||
): Folder[] {
|
||||
if (currentModel == null) return [];
|
||||
function getParentFolders(folders: Folder[], folderId: string | null): Folder[] {
|
||||
if (folderId == null) return [];
|
||||
|
||||
const parentFolder = currentModel.folderId
|
||||
? folders.find((f) => f.id === currentModel.folderId)
|
||||
: null;
|
||||
const parentFolder = folders.find((f) => f.id === folderId);
|
||||
if (parentFolder == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [parentFolder, ...getParentFolders(folders, parentFolder)];
|
||||
return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -12,6 +13,9 @@ 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;
|
||||
@@ -53,6 +57,7 @@ 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") {
|
||||
@@ -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;
|
||||
|
||||
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
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
|
||||
const result = (...args: any[]) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), delay);
|
||||
lastArgs = args;
|
||||
if (timer != null) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const argsToUse = lastArgs ?? [];
|
||||
lastArgs = null;
|
||||
fn(...argsToUse);
|
||||
}, 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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
DndContext,
|
||||
MeasuringStrategy,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
} from "react";
|
||||
import { useKey, useKeyPressEvent } from "react-use";
|
||||
import { computeSideForDragMove } from "../../lib/dnd";
|
||||
import { useStore } from "jotai";
|
||||
import { useAtomValue, useStore } from "jotai";
|
||||
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
|
||||
import { type CollapsedAtom, CollapsedAtomContext } from "./context";
|
||||
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from "./common";
|
||||
@@ -87,7 +88,27 @@ 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;
|
||||
@@ -125,16 +146,28 @@ function TreeInner<T extends { id: string }>(
|
||||
}, []);
|
||||
|
||||
const tryFocus = useCallback(() => {
|
||||
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
|
||||
'.tree-item button[tabindex="0"]',
|
||||
);
|
||||
if ($el == null) {
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
$el.focus();
|
||||
$el.scrollIntoView({ block: "nearest" });
|
||||
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
|
||||
requestAnimationFrame(() => find()?.focus({ preventScroll: true }));
|
||||
return true;
|
||||
}, []);
|
||||
}, [store, treeId, visibleItems]);
|
||||
|
||||
const ensureTabbableItem = useCallback(() => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
@@ -448,8 +481,8 @@ function TreeInner<T extends { id: string }>(
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: root.item.id,
|
||||
parentDepth: root.depth,
|
||||
index: selectableItems.length,
|
||||
childIndex: selectableItems.length,
|
||||
index: visibleItems.length,
|
||||
childIndex: visibleItems.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -477,8 +510,8 @@ function TreeInner<T extends { id: string }>(
|
||||
|
||||
const item = node.item;
|
||||
let hoveredParent = node.parent;
|
||||
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
|
||||
const hovered = selectableItems[dragIndex]?.node ?? null;
|
||||
const dragIndex = visibleItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
|
||||
const hovered = visibleItems[dragIndex]?.node ?? null;
|
||||
const hoveredIndex = dragIndex + (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(
|
||||
@@ -680,12 +713,18 @@ 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={selectableItems}
|
||||
nodes={visibleItems}
|
||||
treeId={treeId}
|
||||
getScrollElement={getScrollElement}
|
||||
onVirtualizerReady={handleVirtualizerReady}
|
||||
{...treeItemListProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { SelectableTreeNode } from "./common";
|
||||
import type { TreeProps } from "./Tree";
|
||||
import { TreeDropMarker } from "./TreeDropMarker";
|
||||
@@ -22,9 +24,22 @@ 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 }>({
|
||||
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,
|
||||
getItemKey,
|
||||
nodes,
|
||||
@@ -32,6 +47,8 @@ export function TreeItemList<T extends { id: string }>({
|
||||
treeId,
|
||||
forceDepth,
|
||||
addTreeItemRef,
|
||||
getScrollElement: _getScrollElement,
|
||||
onVirtualizerReady: _onVirtualizerReady,
|
||||
...props
|
||||
}: TreeItemListProps<T>) {
|
||||
return (
|
||||
@@ -53,3 +70,89 @@ export function TreeItemList<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user