mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 04:44:12 +02:00
HUGE sidebar and typing performance improvements (#516)
This commit is contained in:
@@ -1,4 +1,9 @@
|
|||||||
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 +80,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 +153,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 +306,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"
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
import { getModel, patchModel } from "@yaakapp-internal/models";
|
import { getModel, 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 { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
@@ -76,15 +74,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;
|
||||||
@@ -286,16 +281,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),
|
||||||
@@ -335,7 +330,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],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -381,7 +376,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}>
|
||||||
@@ -389,7 +384,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}>
|
||||||
@@ -441,7 +436,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" ? (
|
||||||
|
|||||||
@@ -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,10 @@
|
|||||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
||||||
import { getModel, patchModel } from "@yaakapp-internal/models";
|
import {
|
||||||
|
flushAllModelWrites,
|
||||||
|
getModel,
|
||||||
|
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 +13,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 +22,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 { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||||
@@ -53,15 +56,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;
|
||||||
@@ -139,7 +139,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),
|
||||||
@@ -147,6 +147,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,
|
||||||
@@ -156,6 +157,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,
|
||||||
@@ -168,7 +170,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],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -252,7 +254,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}>
|
||||||
@@ -260,7 +262,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}>
|
||||||
@@ -272,7 +274,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>
|
||||||
@@ -289,7 +291,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"
|
||||||
@@ -297,7 +299,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>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { emacs } from "@replit/codemirror-emacs";
|
|||||||
import { vim } from "@replit/codemirror-vim";
|
import { vim } from "@replit/codemirror-vim";
|
||||||
|
|
||||||
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
|
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
|
||||||
|
import { debounce } from "@yaakapp-internal/lib";
|
||||||
import type { EditorKeymap } from "@yaakapp-internal/models";
|
import type { EditorKeymap } from "@yaakapp-internal/models";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
||||||
@@ -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,44 @@ const placeholderElFromText = (text: string | undefined) => {
|
|||||||
return el;
|
return el;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Caching the state is too expensive to do on every update (every keystroke and cursor move),
|
||||||
|
// so debounce it per state key and flush when the editor unmounts.
|
||||||
|
//
|
||||||
|
// The cost scales with the document, and every update pays it in full:
|
||||||
|
// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB
|
||||||
|
// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint`
|
||||||
|
// only samples read-only ones, so editing a large body still hashes all of it
|
||||||
|
// - `sessionStorage.setItem` is synchronous and blocks the main thread
|
||||||
|
// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one
|
||||||
|
// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger
|
||||||
|
// half once documents get into the megabytes.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map
|
||||||
|
// for every state key the session has ever shown. A pending saver holds the last EditorState,
|
||||||
|
// which holds the whole document.
|
||||||
|
function flushCachedEditorState(stateKey: string | null) {
|
||||||
|
if (!stateKey) return;
|
||||||
|
const saver = stateSavers.get(stateKey);
|
||||||
|
if (saver == null) return;
|
||||||
|
saver.flush();
|
||||||
|
stateSavers.delete(stateKey);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@shopify/lang-jsonc": "^1.0.1",
|
"@shopify/lang-jsonc": "^1.0.1",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@tanstack/react-router": "^1.133.13",
|
"@tanstack/react-router": "^1.133.13",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.14.9",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
|||||||
@@ -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,85 @@ 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a model's pending patch, if it has one that hasn't been written yet.
|
||||||
|
*
|
||||||
|
* The store only moves forward when the backend echoes a write back, so between a keystroke and
|
||||||
|
* its debounced write the stored copy is behind what the user typed. Reading through the pending
|
||||||
|
* patch keeps that window invisible to the imperative readers below, which are the ones that go
|
||||||
|
* on to write the model back.
|
||||||
|
*/
|
||||||
|
function withPendingPatch<T>(model: T | null): T | null {
|
||||||
|
if (model == null || pendingPatches.size === 0) return model;
|
||||||
|
const { model: modelType, id } = model as { model?: string; id?: string };
|
||||||
|
const pending = pendingPatches.get(`${modelType}.${id}`);
|
||||||
|
return pending == null ? model : ({ ...model, ...pending.patch } as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a model's pending patch and cancel its scheduled write */
|
||||||
|
function consumePendingPatch(model: AnyModel["model"], id: string) {
|
||||||
|
const key = `${model}.${id}`;
|
||||||
|
const pending = pendingPatches.get(key);
|
||||||
|
if (pending == null) return;
|
||||||
|
pending.write.cancel();
|
||||||
|
pendingPatches.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
let _activeWorkspaceId: string | null = null;
|
let _activeWorkspaceId: string | null = null;
|
||||||
|
|
||||||
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
||||||
@@ -96,7 +180,7 @@ export function getModel<M extends AnyModel["model"], T extends ExtractModel<Any
|
|||||||
const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
|
const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
|
||||||
for (const t of types) {
|
for (const t of types) {
|
||||||
let v = data[t][id];
|
let v = data[t][id];
|
||||||
if (v?.model === t) return v as T;
|
if (v?.model === t) return withPendingPatch(v as T);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -106,7 +190,7 @@ export function getAnyModel(id: string): AnyModel | null {
|
|||||||
for (const t of Object.keys(data)) {
|
for (const t of Object.keys(data)) {
|
||||||
// oxlint-disable-next-line no-explicit-any -- dynamic key access
|
// oxlint-disable-next-line no-explicit-any -- dynamic key access
|
||||||
let v = (data as any)[t]?.[id];
|
let v = (data as any)[t]?.[id];
|
||||||
if (v?.model === t) return v;
|
if (v?.model === t) return withPendingPatch(v);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -116,11 +200,17 @@ export function patchModelById<M extends AnyModel["model"], T extends ExtractMod
|
|||||||
id: string,
|
id: string,
|
||||||
patch: Partial<T> | ((prev: T) => T),
|
patch: Partial<T> | ((prev: T) => T),
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// Reads through any pending debounced patch, so the merge below can't put a stale value back
|
||||||
|
// over something the user has already typed
|
||||||
let prev = getModel<M, T>(model, id);
|
let prev = getModel<M, T>(model, id);
|
||||||
if (prev == null) {
|
if (prev == null) {
|
||||||
throw new Error(`Failed to get model to patch id=${id} model=${model}`);
|
throw new Error(`Failed to get model to patch id=${id} model=${model}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `prev` already carries the pending patch, so this write supersedes it. Leaving it queued
|
||||||
|
// would let it land afterwards and undo whatever this write decided.
|
||||||
|
consumePendingPatch(model, id);
|
||||||
|
|
||||||
const newModel = typeof patch === "function" ? patch(prev) : { ...prev, ...patch };
|
const newModel = typeof patch === "function" ? patch(prev) : { ...prev, ...patch };
|
||||||
return updateModel(newModel);
|
return updateModel(newModel);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+8
-8
@@ -121,7 +121,7 @@
|
|||||||
"@shopify/lang-jsonc": "^1.0.1",
|
"@shopify/lang-jsonc": "^1.0.1",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@tanstack/react-router": "^1.133.13",
|
"@tanstack/react-router": "^1.133.13",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.14.9",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
@@ -4342,12 +4342,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-virtual": {
|
"node_modules/@tanstack/react-virtual": {
|
||||||
"version": "3.13.18",
|
"version": "3.14.9",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz",
|
||||||
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
|
"integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/virtual-core": "3.13.18"
|
"@tanstack/virtual-core": "3.17.7"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -4492,9 +4492,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/virtual-core": {
|
"node_modules/@tanstack/virtual-core": {
|
||||||
"version": "3.13.18",
|
"version": "3.17.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz",
|
||||||
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
"integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { debounce } from "./debounce";
|
||||||
|
|
||||||
|
describe("debounce", () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("calls once with the latest args after the delay", () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const d = debounce(fn, 100);
|
||||||
|
|
||||||
|
d("a");
|
||||||
|
d("b");
|
||||||
|
d("c");
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(fn).toHaveBeenCalledExactlyOnceWith("c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flush invokes a pending call immediately", () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const d = debounce(fn, 100);
|
||||||
|
|
||||||
|
d("a");
|
||||||
|
d("b");
|
||||||
|
d.flush();
|
||||||
|
expect(fn).toHaveBeenCalledExactlyOnceWith("b");
|
||||||
|
|
||||||
|
// The scheduled call was cancelled, so waiting out the delay adds nothing
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(fn).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flush is a no-op with nothing pending", () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const d = debounce(fn, 100);
|
||||||
|
|
||||||
|
d.flush();
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
d("a");
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
d.flush();
|
||||||
|
expect(fn).toHaveBeenCalledExactlyOnceWith("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel drops the pending call and its args", () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const d = debounce(fn, 100);
|
||||||
|
|
||||||
|
d("a");
|
||||||
|
d.cancel();
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A cancelled call must not leak its args into the next one
|
||||||
|
d.flush();
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a fresh delay after firing", () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const d = debounce(fn, 100);
|
||||||
|
|
||||||
|
d("a");
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
d("b");
|
||||||
|
vi.advanceTimersByTime(99);
|
||||||
|
expect(fn).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fn).toHaveBeenLastCalledWith("b");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +24,10 @@ 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, TreeNode } from "./common";
|
||||||
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from "./common";
|
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from "./common";
|
||||||
import { TreeDragOverlay } from "./TreeDragOverlay";
|
import { TreeDragOverlay } from "./TreeDragOverlay";
|
||||||
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem";
|
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem";
|
||||||
@@ -87,7 +88,39 @@ 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);
|
||||||
|
|
||||||
|
// The scroll container is tracked in state as well as a ref, so the virtualizer re-resolves it
|
||||||
|
// once it exists. React attaches refs and runs layout effects child-first, so TreeItemList's
|
||||||
|
// effects run before this ancestor's ref is set: on a fresh mount a ref-only getScrollElement
|
||||||
|
// returns null, and the virtualizer renders nothing until some later render happens to wake it
|
||||||
|
// up. Usually one does, which is why this only showed when the tree remounted into settled data
|
||||||
|
// (filtering down to no results and back).
|
||||||
|
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
const setTreeRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
treeRef.current = el;
|
||||||
|
setScrollEl(el);
|
||||||
|
}, []);
|
||||||
|
const getScrollElement = useCallback(() => scrollEl, [scrollEl]);
|
||||||
|
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;
|
||||||
@@ -97,6 +130,28 @@ function TreeInner<T extends { id: string }>(
|
|||||||
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
|
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
|
||||||
if (r == null) {
|
if (r == null) {
|
||||||
delete treeItemRefs.current[item.id];
|
delete treeItemRefs.current[item.id];
|
||||||
|
|
||||||
|
// Keep keyboard focus inside the tree when the focused row is virtualized away.
|
||||||
|
//
|
||||||
|
// Scrolling the focused row out of the window destroys the button holding focus, and the
|
||||||
|
// browser drops focus to the body rather than moving it anywhere. Everything keyboard-driven
|
||||||
|
// is gated on the tree containing document.activeElement (arrow navigation here, rename,
|
||||||
|
// delete, duplicate and the context menu in the consumer), so the entire keyboard interface
|
||||||
|
// would go dead until the user clicked a row again. They all act on the selected id rather
|
||||||
|
// than the focused element, so parking focus on the container is enough to keep them live.
|
||||||
|
//
|
||||||
|
// NOTE: This has to hang off the unmount rather than a render or a focusout. Scrolling
|
||||||
|
// re-renders the list, not this component, and removing a focused element doesn't reliably
|
||||||
|
// fire focusout.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = treeRef.current;
|
||||||
|
if (el == null) return;
|
||||||
|
const active = document.activeElement;
|
||||||
|
const focusWasDropped = active == null || active === document.body || !active.isConnected;
|
||||||
|
if (focusWasDropped) {
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
treeItemRefs.current[item.id] = r;
|
treeItemRefs.current[item.id] = r;
|
||||||
}
|
}
|
||||||
@@ -125,16 +180,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;
|
||||||
@@ -187,13 +254,42 @@ function TreeInner<T extends { id: string }>(
|
|||||||
[treeId, tryFocus],
|
[treeId, tryFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run something against a row's handle, scrolling the row into view first when it isn't
|
||||||
|
* mounted.
|
||||||
|
*
|
||||||
|
* Virtualized rows only have a handle while they're in the window, but the row these act on is
|
||||||
|
* the selected one, which the user is free to scroll away from before hitting a hotkey. Without
|
||||||
|
* this, renaming an off-screen row silently does nothing and the context menu has no rect to
|
||||||
|
* open against.
|
||||||
|
*/
|
||||||
|
const withTreeItem = useCallback(
|
||||||
|
(id: string, action: (handle: TreeItemHandle) => void) => {
|
||||||
|
const mounted = treeItemRefs.current[id];
|
||||||
|
if (mounted != null) {
|
||||||
|
action(mounted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = visibleItems.findIndex((i) => i.node.item.id === id);
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const handle = treeItemRefs.current[id];
|
||||||
|
if (handle != null) action(handle);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[visibleItems],
|
||||||
|
);
|
||||||
|
|
||||||
const treeHandle = useMemo<TreeHandle>(
|
const treeHandle = useMemo<TreeHandle>(
|
||||||
() => ({
|
() => ({
|
||||||
treeId,
|
treeId,
|
||||||
focus: tryFocus,
|
focus: tryFocus,
|
||||||
hasFocus: hasFocus,
|
hasFocus: hasFocus,
|
||||||
getSelectedItems: () => getSelectedItems(store, treeId, selectableItems),
|
getSelectedItems: () => getSelectedItems(store, treeId, selectableItems),
|
||||||
renameItem: (id) => treeItemRefs.current[id]?.rename(),
|
renameItem: (id) => withTreeItem(id, (handle) => handle.rename()),
|
||||||
selectItem: (id, focus) => {
|
selectItem: (id, focus) => {
|
||||||
if (store.get(selectedIdsFamily(treeId)).includes(id)) {
|
if (store.get(selectedIdsFamily(treeId)).includes(id)) {
|
||||||
// Already selected
|
// Already selected
|
||||||
@@ -207,12 +303,14 @@ function TreeInner<T extends { id: string }>(
|
|||||||
const items = getSelectedItems(store, treeId, selectableItems);
|
const items = getSelectedItems(store, treeId, selectableItems);
|
||||||
const menuItems = await getContextMenu(items);
|
const menuItems = await getContextMenu(items);
|
||||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||||
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
|
if (lastSelectedId == null) return;
|
||||||
if (rect == null) return;
|
withTreeItem(lastSelectedId, (handle) => {
|
||||||
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
|
const rect = handle.rect();
|
||||||
|
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus],
|
[getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus, withTreeItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
|
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
|
||||||
@@ -244,11 +342,8 @@ function TreeInner<T extends { id: string }>(
|
|||||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||||
|
|
||||||
if (shiftKey) {
|
if (shiftKey) {
|
||||||
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
|
const anchorIndex = visibleItems.findIndex((i) => i.node.item.id === anchorSelectedId);
|
||||||
const anchorIndex = validSelectableItems.findIndex(
|
const currIndex = visibleItems.findIndex((v) => v.node.item.id === item.id);
|
||||||
(i) => i.node.item.id === anchorSelectedId,
|
|
||||||
);
|
|
||||||
const currIndex = validSelectableItems.findIndex((v) => v.node.item.id === item.id);
|
|
||||||
|
|
||||||
// Nothing was selected yet, so just select this item
|
// Nothing was selected yet, so just select this item
|
||||||
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
|
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
|
||||||
@@ -259,14 +354,14 @@ function TreeInner<T extends { id: string }>(
|
|||||||
|
|
||||||
if (currIndex > anchorIndex) {
|
if (currIndex > anchorIndex) {
|
||||||
// Selecting down
|
// Selecting down
|
||||||
const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1);
|
const itemsToSelect = visibleItems.slice(anchorIndex, currIndex + 1);
|
||||||
setSelected(
|
setSelected(
|
||||||
itemsToSelect.map((v) => v.node.item.id),
|
itemsToSelect.map((v) => v.node.item.id),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
} else if (currIndex < anchorIndex) {
|
} else if (currIndex < anchorIndex) {
|
||||||
// Selecting up
|
// Selecting up
|
||||||
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
|
const itemsToSelect = visibleItems.slice(currIndex, anchorIndex + 1);
|
||||||
setSelected(
|
setSelected(
|
||||||
itemsToSelect.map((v) => v.node.item.id),
|
itemsToSelect.map((v) => v.node.item.id),
|
||||||
true,
|
true,
|
||||||
@@ -289,7 +384,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectableItems, setSelected, treeId],
|
[setSelected, treeId, visibleItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
|
const handleClick = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
|
||||||
@@ -307,27 +402,25 @@ function TreeInner<T extends { id: string }>(
|
|||||||
const selectPrevItem = useCallback(
|
const selectPrevItem = useCallback(
|
||||||
(e: TreeItemClickEvent) => {
|
(e: TreeItemClickEvent) => {
|
||||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||||
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
|
const index = visibleItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||||
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
const item = visibleItems[index - 1];
|
||||||
const item = validSelectableItems[index - 1];
|
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
handleSelect(item.node.item, e);
|
handleSelect(item.node.item, e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSelect, selectableItems, treeId],
|
[handleSelect, treeId, visibleItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectNextItem = useCallback(
|
const selectNextItem = useCallback(
|
||||||
(e: TreeItemClickEvent) => {
|
(e: TreeItemClickEvent) => {
|
||||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||||
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
|
const index = visibleItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||||
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
const item = visibleItems[index + 1];
|
||||||
const item = validSelectableItems[index + 1];
|
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
handleSelect(item.node.item, e);
|
handleSelect(item.node.item, e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSelect, selectableItems, treeId],
|
[handleSelect, treeId, visibleItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectParentItem = useCallback(
|
const selectParentItem = useCallback(
|
||||||
@@ -448,8 +541,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 +570,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 +602,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(
|
||||||
@@ -659,7 +752,10 @@ function TreeInner<T extends { id: string }>(
|
|||||||
autoScroll
|
autoScroll
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={treeRef}
|
ref={setTreeRef}
|
||||||
|
// Focusable so the container can hold focus when the focused row unmounts. Not reachable
|
||||||
|
// by tabbing, since the rows themselves are what's tabbable.
|
||||||
|
tabIndex={-1}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
"outline-hidden h-full",
|
"outline-hidden h-full",
|
||||||
@@ -680,12 +776,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>
|
||||||
@@ -731,20 +833,3 @@ function DropRegionAfterList({
|
|||||||
// biome-ignore lint/a11y/noStaticElementInteractions: Meh
|
// biome-ignore lint/a11y/noStaticElementInteractions: Meh
|
||||||
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
|
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getValidSelectableItems<T extends { id: string }>(
|
|
||||||
store: JotaiStore,
|
|
||||||
collapsedAtom: CollapsedAtom,
|
|
||||||
selectableItems: SelectableTreeNode<T>[],
|
|
||||||
) {
|
|
||||||
const collapsed = store.get(collapsedAtom);
|
|
||||||
return selectableItems.filter((i) => {
|
|
||||||
if (i.node.hidden) return false;
|
|
||||||
let p = i.node.parent;
|
|
||||||
while (p) {
|
|
||||||
if (collapsed[p.item.id]) return false;
|
|
||||||
p = p.parent;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -107,8 +107,14 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
[editing, getEditOptions],
|
[editing, getEditOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// NOTE: Unregisters on unmount, or the tree keeps a handle to a component that no longer exists.
|
||||||
|
// Harmless while every row stayed mounted, but virtualized rows unmount whenever they leave the
|
||||||
|
// window, and acting on a dead handle does nothing at best (rename) and reports a zeroed rect at
|
||||||
|
// worst (the context menu opening in the corner of the screen).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRef?.(node.item, handle);
|
const item = node.item;
|
||||||
|
setRef?.(item, handle);
|
||||||
|
return () => setRef?.(item, null);
|
||||||
}, [setRef, handle, node.item]);
|
}, [setRef, handle, node.item]);
|
||||||
|
|
||||||
const ancestorIds = useMemo(() => {
|
const ancestorIds = useMemo(() => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { Fragment } from "react";
|
import { Fragment, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { draggingIdsFamily } from "./atoms";
|
||||||
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 +26,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 +49,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 +72,120 @@ 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]);
|
||||||
|
|
||||||
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
// Rows being dragged stay mounted even after they scroll out of the window.
|
||||||
|
//
|
||||||
|
// dnd-kit keeps a live reference to the node being dragged and re-measures it whenever that
|
||||||
|
// node is replaced. Its scroll adjustment is a delta against a baseline captured when that rect
|
||||||
|
// was last measured, and re-measuring resets the baseline to wherever the list happens to be
|
||||||
|
// scrolled right then, discarding the scroll accumulated so far. Letting the dragged row unmount
|
||||||
|
// mid-drag therefore corrupts every coordinate dnd-kit derives, by a bit more each time it
|
||||||
|
// happens, which is why the drop indicator drifts further the longer a drag autoscrolls.
|
||||||
|
const draggingIds = useAtomValue(draggingIdsFamily(treeId));
|
||||||
|
const pinnedIndexes = useMemo(() => {
|
||||||
|
if (draggingIds.length === 0) return [];
|
||||||
|
const rendered = new Set(virtualItems.map((v) => v.index));
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const node = nodes[i];
|
||||||
|
if (node == null || rendered.has(i)) continue;
|
||||||
|
if (draggingIds.includes(node.node.item.id)) out.push(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed off the rendered range
|
||||||
|
}, [draggingIds, nodes, virtualItems]);
|
||||||
|
|
||||||
|
const renderRow = (index: number, start: number, measure: boolean) => {
|
||||||
|
const child = nodes[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={measure ? virtualizer.measureElement : undefined}
|
||||||
|
data-index={index}
|
||||||
|
className="tree-row"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${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={index + 1} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
style={{ ...style, height: `${virtualizer.getTotalSize()}px`, position: "relative" }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<TreeDropMarker node={null} treeId={treeId} index={0} />
|
||||||
|
{virtualItems.map((virtualItem) => renderRow(virtualItem.index, virtualItem.start, true))}
|
||||||
|
{/* NOTE: Not measured. Measuring an out-of-window row would write its size into the
|
||||||
|
virtualizer's cache under an index the window isn't tracking. */}
|
||||||
|
{pinnedIndexes.map((index) =>
|
||||||
|
renderRow(index, virtualizer.measurementsCache[index]?.start ?? 0, false),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user