mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Proxy viewer extraction: response viewers into packages/ui (#754)
This commit is contained in:
1 parent
b5508e6bb1
commit
b6c1f700f1
137 files changed
+3498
-2890
No files matched your search
@@ -16,9 +16,9 @@ import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
export const createFolder = createFastMutation<
|
||||
string | null,
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-intern
|
||||
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
export const moveToWorkspace = createFastMutation({
|
||||
mutationKey: ["move_workspace"],
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { Button } from "./core/Button";
|
||||
import { Link } from "./core/Link";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
@@ -8,7 +8,6 @@ import { getContentTypeFromHeaders } from "../lib/model_util";
|
||||
import { getResponseBodyText } from "../lib/responseBody";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
@@ -7,7 +7,6 @@ import { isProbablyTextContentType } from "../lib/contentType";
|
||||
import { getContentTypeFromHeaders } from "../lib/model_util";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -25,12 +25,13 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TruncatedWideTableCell,
|
||||
KeyValueRows,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import classNames from "classnames";
|
||||
import { EventDetailHeader } from "./core/EventViewer";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
CopyIconButton as BaseCopyIconButton,
|
||||
type CopyIconButtonProps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
|
||||
text: string | (() => Promise<string | null>);
|
||||
export function CopyIconButton(props: Omit<CopyIconButtonProps, "copy" | "onCopyFailed">) {
|
||||
return <BaseCopyIconButton {...props} copy={copy} onCopyFailed={showCopyFailed} />;
|
||||
}
|
||||
|
||||
export function CopyIconButton({ text, ...props }: Props) {
|
||||
const [copied, setCopied] = useTimedBoolean();
|
||||
return (
|
||||
<IconButton
|
||||
{...props}
|
||||
icon={copied ? "check" : "copy"}
|
||||
showConfirm
|
||||
onClick={async () => {
|
||||
const content = typeof text === "function" ? await text() : text;
|
||||
if (content == null) {
|
||||
showToast({
|
||||
id: "failed-to-copy",
|
||||
color: "danger",
|
||||
message: "Failed to copy",
|
||||
});
|
||||
} else {
|
||||
copyToClipboard(content, { disableToast: true });
|
||||
setCopied();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
function copy(text: string) {
|
||||
copyToClipboard(text, { disableToast: true });
|
||||
}
|
||||
|
||||
function showCopyFailed() {
|
||||
showToast({
|
||||
id: "failed-to-copy",
|
||||
color: "danger",
|
||||
message: "Failed to copy",
|
||||
});
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { ColorIndicator } from "./ColorIndicator";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Label } from "./core/Label";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Label } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
onCreate: (id: string) => void;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { gitMutations } from "@yaakapp-internal/git";
|
||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { VStack, Label } from "@yaakapp-internal/ui";
|
||||
import { useId, useState } from "react";
|
||||
import { router } from "../lib/router";
|
||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||
@@ -9,7 +9,6 @@ import { rpc } from "../lib/rpc";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Label } from "./core/Label";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { EncryptionHelp } from "./EncryptionHelp";
|
||||
import { gitCallbacks } from "./git/callbacks";
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
FormInputText,
|
||||
JsonPrimitive,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, VStack, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
@@ -26,7 +26,6 @@ import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import type { InputProps } from "./core/Input";
|
||||
import { Input } from "./core/Input";
|
||||
import { Label } from "./core/Label";
|
||||
import type { Pair } from "./core/PairEditor";
|
||||
import { PairEditor } from "./core/PairEditor";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Environment, Workspace } from "@yaakapp-internal/models";
|
||||
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SplitLayout, Tree, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import {
|
||||
@@ -30,7 +30,6 @@ import { showColorPicker } from "../lib/showColorPicker";
|
||||
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
||||
import { ContextMenu } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import type { PairEditorHandle } from "./core/PairEditor";
|
||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { IconTooltip } from "@yaakapp-internal/ui";
|
||||
|
||||
export function EnvironmentSharableTooltip() {
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import slugify from "slugify";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
@@ -13,6 +12,7 @@ import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Link } from "./core/Link";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
onHide: () => void;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { foldersAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
LoadingIcon,
|
||||
HttpResponseDurationTag,
|
||||
HttpStatusTag,
|
||||
SizeTag,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
@@ -13,11 +21,8 @@ import { showDialog } from "../lib/dialog";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { router } from "../lib/router";
|
||||
import { Button } from "./core/Button";
|
||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { Separator } from "./core/Separator";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
import { HttpResponsePane } from "./HttpResponsePane";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, VStack, CountBadge } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
@@ -11,7 +11,6 @@ import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { hideDialog } from "../lib/dialog";
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { Input } from "./core/Input";
|
||||
import { Link } from "./core/Link";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
|
||||
@@ -20,13 +20,13 @@ import { showConfirm } from "../lib/confirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
import { Button } from "./core/Button";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
|
||||
services: ReflectResponseService[] | null;
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||
import { useGrpc } from "../hooks/useGrpc";
|
||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { PathList } from "./core/PathList";
|
||||
import { Link } from "./core/Link";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
patchModel,
|
||||
patchModelDebounced,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, useContainerSize, VStack, CountBadge } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
@@ -15,7 +15,6 @@ import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { RadioDropdown } from "./core/RadioDropdown";
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
type IconProps,
|
||||
LoadingIcon,
|
||||
VStack,
|
||||
useStateWithDeps,
|
||||
KeyValueRows,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -9,13 +17,12 @@ import {
|
||||
pinnedGrpcConnectionIdAtom,
|
||||
useGrpcEvents,
|
||||
} from "../hooks/usePinnedGrpcConnection";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { Button } from "./core/Button";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, CountBadge } from "@yaakapp-internal/ui";
|
||||
import { acceptLanguages } from "../lib/data/acceptLanguages";
|
||||
import { cacheControlDirectives } from "../lib/data/cacheControl";
|
||||
import { charsets } from "../lib/data/charsets";
|
||||
@@ -10,7 +10,6 @@ import { headerNames } from "../lib/data/headerNames";
|
||||
import { mimeTypes } from "../lib/data/mimetypes";
|
||||
import { userAgents } from "../lib/data/userAgents";
|
||||
import { validateHttpHeader } from "../lib/validateHttpHeader";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import type { InputProps } from "./core/Input";
|
||||
|
||||
@@ -15,8 +15,6 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import {
|
||||
@@ -35,11 +33,10 @@ import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { BinaryFileEditor } from "./BinaryFileEditor";
|
||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { InlineCode, languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
@@ -54,6 +51,7 @@ import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
const GraphQLEditor = lazy(() =>
|
||||
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
Banner,
|
||||
HStack,
|
||||
Icon,
|
||||
LoadingIcon,
|
||||
VStack,
|
||||
CountBadge,
|
||||
HttpResponseDurationTag,
|
||||
HttpStatusTag,
|
||||
SizeTag,
|
||||
Tooltip,
|
||||
AudioViewer,
|
||||
CsvViewer,
|
||||
ImageViewer,
|
||||
SvgViewer,
|
||||
VideoViewer,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ComponentType, CSSProperties } from "react";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
@@ -17,15 +33,10 @@ import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
|
||||
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
|
||||
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { Tooltip } from "./core/Tooltip";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
@@ -33,14 +44,9 @@ import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
import { ResponseCookies } from "./ResponseCookies";
|
||||
import { ResponseHeaders } from "./ResponseHeaders";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
||||
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
|
||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
|
||||
@@ -13,9 +13,8 @@ import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
|
||||
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import type { TimelineViewMode } from "./HttpResponsePane";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../lib/errorMessage";
|
||||
import { pluralize } from "../lib/pluralize";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import {
|
||||
ImportSourceList,
|
||||
@@ -22,9 +21,9 @@ import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
||||
import { CheckboxTree } from "./core/CheckboxTree";
|
||||
import { Chip } from "./core/Chip";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { Select } from "./core/Select";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
import { pluralize } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
currentWorkspace: Workspace | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, Icon } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
@@ -12,7 +12,6 @@ import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { jsonParseLinter } from "./core/Editor/json-lint";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
|
||||
interface Props {
|
||||
forceUpdateKey: string;
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
HStack,
|
||||
LoadingIcon,
|
||||
AudioViewer,
|
||||
ImageViewer,
|
||||
SvgViewer,
|
||||
VideoViewer,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import type { SniffedValue } from "./core/Editor/sniffValue";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { decodeValue, largeValueActions } from "../lib/largeValue";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
|
||||
@@ -3,12 +3,12 @@ import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { router } from "../lib/router";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Select } from "./core/Select";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
activeWorkspaceId: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GrpcConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
@@ -9,10 +9,9 @@ import {
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
connections: GrpcConnection[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, formatMillis, HttpStatusTag, SizeTag } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
@@ -12,10 +12,7 @@ import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
responses: HttpResponse[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel, getModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
@@ -9,10 +9,9 @@ import {
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
connections: WebsocketConnection[];
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
|
||||
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
|
||||
import { LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { getMimeTypeFromContentType } from "../lib/contentType";
|
||||
import {
|
||||
LoadingIcon,
|
||||
languageFromContentType,
|
||||
AudioViewer,
|
||||
CsvViewer,
|
||||
ImageViewer,
|
||||
SvgViewer,
|
||||
VideoViewer,
|
||||
WebPageViewer,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||
import { TextViewer } from "./responseViewers/TextViewer";
|
||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||
import { WebPageViewer } from "./responseViewers/WebPageViewer";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
|
||||
@@ -3,9 +3,9 @@ import classNames from "classnames";
|
||||
import { useMemo } from "react";
|
||||
import type { JSX } from "react/jsx-runtime";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import { CountBadge, KeyValueRows } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { HeadersTable, KeyValueRows } from "@yaakapp-internal/ui";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { DetailsBanner, useDetailsBannerStoredValue } from "./core/DetailsBanner";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function ResponseHeaders({ response }: Props) {
|
||||
const responseHeaders = useMemo(
|
||||
() =>
|
||||
[...response.headers].sort((a, b) =>
|
||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||
),
|
||||
[response.headers],
|
||||
);
|
||||
const requestHeaders = useMemo(
|
||||
() =>
|
||||
[...response.requestHeaders].sort((a, b) =>
|
||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||
),
|
||||
[response.requestHeaders],
|
||||
);
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
|
||||
@@ -58,53 +43,21 @@ export function ResponseHeaders({ response }: Props) {
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</DetailsBanner>
|
||||
<DetailsBanner
|
||||
<HeadersTable
|
||||
title="Request Headers"
|
||||
headers={response.requestHeaders}
|
||||
labelColor="primary"
|
||||
storageKey={`${response.requestId}.request_headers`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Request Headers <CountBadge showZero count={requestHeaders.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{requestHeaders.length === 0 ? (
|
||||
<NoHeaders />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{requestHeaders.map((h, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<KeyValueRow labelColor="primary" key={i} label={h.name}>
|
||||
{h.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
<DetailsBanner
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
/>
|
||||
<HeadersTable
|
||||
defaultOpen
|
||||
title="Response Headers"
|
||||
headers={response.headers}
|
||||
labelColor="info"
|
||||
storageKey={`${response.requestId}.response_headers`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Response Headers <CountBadge showZero count={responseHeaders.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{responseHeaders.length === 0 ? (
|
||||
<NoHeaders />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{responseHeaders.map((h, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<KeyValueRow labelColor="info" key={i} label={h.name}>
|
||||
{h.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoHeaders() {
|
||||
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { KeyValueRows } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, IconTooltip, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import mime from "mime";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -7,8 +7,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { ButtonProps } from "./core/Button";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { Label } from "./core/Label";
|
||||
|
||||
type Props = Omit<ButtonProps, "type"> & {
|
||||
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useKeyPressEvent } from "react-use";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { capitalize } from "../../lib/capitalize";
|
||||
import { CountBadge } from "../core/CountBadge";
|
||||
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
|
||||
import { SettingsCertificates } from "./SettingsCertificates";
|
||||
import { SettingsGeneral } from "./SettingsGeneral";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { differenceInDays } from "date-fns";
|
||||
import { formatDate } from "date-fns/format";
|
||||
import { useState } from "react";
|
||||
import { useToggle } from "../../hooks/useToggle";
|
||||
import { pluralizeCount } from "../../lib/pluralize";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Button } from "../core/Button";
|
||||
@@ -12,6 +11,7 @@ import { Link } from "../core/Link";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { Separator } from "../core/Separator";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
export function SettingsLicense() {
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
useDebouncedValue,
|
||||
CountBadge,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
@@ -31,7 +32,6 @@ import { showConfirmDelete } from "../../lib/confirm";
|
||||
import { minPromiseMillis } from "../../lib/minPromiseMillis";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { CountBadge } from "../core/CountBadge";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Link } from "../core/Link";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
|
||||
@@ -69,7 +69,6 @@ import type { Ast } from "./core/Editor/filter/query";
|
||||
import { evaluate, parseQuery } from "./core/Editor/filter/query";
|
||||
import { formatFieldFilter } from "./core/Editor/filter/format";
|
||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import {
|
||||
Icon,
|
||||
LoadingIcon,
|
||||
@@ -77,6 +76,7 @@ import {
|
||||
isSelectedFamily,
|
||||
selectedIdsFamily,
|
||||
InlineCode,
|
||||
HttpStatusTag,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
@@ -22,11 +22,9 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
@@ -40,6 +38,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
import { languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
LoadingIcon,
|
||||
VStack,
|
||||
useStateWithDeps,
|
||||
languageFromContentType,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { hexy } from "hexy";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -10,8 +17,6 @@ import {
|
||||
setPinnedWebsocketConnectionId,
|
||||
useWebsocketEvents,
|
||||
} from "../hooks/usePinnedWebsocketConnection";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { Button } from "./core/Button";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
|
||||
@@ -5,20 +5,17 @@ import {
|
||||
setWorkspaceKey,
|
||||
} from "@yaakapp-internal/crypto";
|
||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, VStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import type { ButtonProps } from "./core/Button";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { Label } from "./core/Label";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { SettingRow } from "./core/SettingRow";
|
||||
import { EncryptionHelp } from "./EncryptionHelp";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, InlineCode, CountBadge } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
@@ -9,7 +9,6 @@ import { showDialog } from "../lib/dialog";
|
||||
import { router } from "../lib/router";
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { SettingsList, SettingsSection } from "./core/SettingRow";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { Editor } from "./Editor/LazyEditor";
|
||||
import type { Pair, PairEditorProps, PairWithId } from "./PairEditor";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
type Props = PairEditorProps;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked: boolean | "indeterminate";
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface Props {
|
||||
count: number | true;
|
||||
count2?: number | true;
|
||||
className?: string;
|
||||
color?: Color;
|
||||
showZero?: boolean;
|
||||
}
|
||||
|
||||
export function CountBadge({ count, count2, className, color, showZero }: Props) {
|
||||
if (count === 0 && !showZero) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
className,
|
||||
"flex items-center",
|
||||
"opacity-70 border text-4xs rounded-sm mb-0.5 px-1 ml-1 h-4 font-mono",
|
||||
color == null && "border-border-subtle",
|
||||
color === "primary" && "text-primary",
|
||||
color === "secondary" && "text-secondary",
|
||||
color === "success" && "text-success",
|
||||
color === "notice" && "text-notice",
|
||||
color === "warning" && "text-warning",
|
||||
color === "danger" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{count === true ? (
|
||||
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
|
||||
) : (
|
||||
count
|
||||
)}
|
||||
{count2 != null && (
|
||||
<>
|
||||
/
|
||||
{count2 === true ? (
|
||||
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
|
||||
) : (
|
||||
count2
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +1,27 @@
|
||||
import classNames from "classnames";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
DetailsBanner as BaseDetailsBanner,
|
||||
type DetailsBannerProps,
|
||||
type StoredValue,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtom, type WritableAtom } from "jotai";
|
||||
import { type SetStateAction, useMemo } from "react";
|
||||
import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage";
|
||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
||||
import { Banner } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDetailsElement> {
|
||||
summary: ReactNode;
|
||||
color?: BannerProps["color"];
|
||||
defaultOpen?: boolean;
|
||||
storageKey?: string;
|
||||
export function DetailsBanner(props: Omit<DetailsBannerProps, "useStoredValue">) {
|
||||
return <BaseDetailsBanner {...props} useStoredValue={useDetailsBannerStoredValue} />;
|
||||
}
|
||||
|
||||
export function DetailsBanner({
|
||||
className,
|
||||
color,
|
||||
summary,
|
||||
children,
|
||||
defaultOpen,
|
||||
storageKey,
|
||||
...extraProps
|
||||
}: Props) {
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes
|
||||
const openAtom = useMemo(
|
||||
export function useDetailsBannerStoredValue<T extends StoredValue>(
|
||||
key: string | string[] | null,
|
||||
fallback: T,
|
||||
) {
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when the key changes
|
||||
const valueAtom = useMemo<WritableAtom<T, [SetStateAction<T>], void>>(
|
||||
() =>
|
||||
storageKey
|
||||
? atomWithKVStorage<boolean>(["details_banner", storageKey], defaultOpen ?? false)
|
||||
: atom(defaultOpen ?? false),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(openAtom);
|
||||
|
||||
const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {
|
||||
if (storageKey) {
|
||||
setIsOpen(e.currentTarget.open);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Banner color={color} className={className}>
|
||||
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
|
||||
<summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70">
|
||||
<div
|
||||
className={classNames(
|
||||
"transition-transform",
|
||||
"group-open:rotate-90",
|
||||
"w-0 h-0 border-t-[0.3em] border-b-[0.3em] border-l-[0.5em] border-r-0",
|
||||
"border-t-transparent border-b-transparent border-l-text-subtle",
|
||||
)}
|
||||
/>
|
||||
{summary}
|
||||
</summary>
|
||||
<div className="mt-1.5 pb-2">{children}</div>
|
||||
</details>
|
||||
</Banner>
|
||||
key != null
|
||||
? atomWithKVStorage<T>(["details_banner", ...[key].flat()], fallback)
|
||||
: atom(fallback),
|
||||
[key],
|
||||
);
|
||||
return useAtom(valueAtom);
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { HStack, Icon, type IconProps, LoadingIcon, Overlay, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
type IconProps,
|
||||
LoadingIcon,
|
||||
Overlay,
|
||||
VStack,
|
||||
useStateWithDeps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { atom } from "jotai";
|
||||
import * as m from "motion/react-m";
|
||||
@@ -28,8 +36,6 @@ import { useKey, useWindowSize } from "react-use";
|
||||
import { useClickOutside } from "../../hooks/useClickOutside";
|
||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
||||
import { useHotKey } from "../../hooks/useHotKey";
|
||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { getNodeText } from "../../lib/getNodeText";
|
||||
import { jotaiStore } from "../../lib/jotai";
|
||||
import { fireAndForget } from "../../lib/fireAndForget";
|
||||
@@ -39,6 +45,7 @@ import { Hotkey } from "./Hotkey";
|
||||
import { IconButton } from "./IconButton";
|
||||
import type { SeparatorAction } from "./Separator";
|
||||
import { Separator } from "./Separator";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
export type DropdownItemSeparator = {
|
||||
type: "separator";
|
||||
|
||||
@@ -5,7 +5,8 @@ import { EditorView } from "@codemirror/view";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef } from "react";
|
||||
import "./DiffViewer.css";
|
||||
import { readonlyExtensions, syntaxHighlightStyle } from "./extensions";
|
||||
import { syntaxHighlightStyle } from "@yaakapp-internal/ui/editor";
|
||||
import { readonlyExtensions } from "./extensions";
|
||||
|
||||
interface Props {
|
||||
/** Original/previous version (left side) */
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
@reference "../../../main.css";
|
||||
|
||||
.cm-wrapper {
|
||||
@apply h-full overflow-hidden;
|
||||
|
||||
.cm-editor {
|
||||
@apply w-full block text-base;
|
||||
|
||||
/* Regular cursor */
|
||||
|
||||
.cm-cursor {
|
||||
@apply border-text!;
|
||||
/* Widen the cursor a bit */
|
||||
@apply border-l-[2px];
|
||||
}
|
||||
|
||||
/* Vim-mode cursor */
|
||||
|
||||
.cm-fat-cursor {
|
||||
@apply outline-0! bg-text!;
|
||||
@apply text-surface!;
|
||||
}
|
||||
|
||||
/* Matching bracket */
|
||||
|
||||
.cm-matchingBracket {
|
||||
@apply bg-transparent border-b border-b-text-subtle;
|
||||
}
|
||||
|
||||
&:not(.cm-focused) {
|
||||
.cm-cursor,
|
||||
.cm-fat-cursor {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
@apply py-0;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
@apply w-full;
|
||||
/* Important! Ensure it spans the entire width */
|
||||
@apply w-full text-text px-0;
|
||||
|
||||
/* So the search highlight border is not cut off by editor view */
|
||||
@apply pl-[1px];
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
@apply text-placeholder;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
/* Inherit line-height from outside */
|
||||
line-height: inherit;
|
||||
|
||||
* {
|
||||
@apply cursor-text;
|
||||
@apply caret-transparent!;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
@apply bg-selection!;
|
||||
}
|
||||
|
||||
/* Fix WebKit/WKWebView rendering bug where selection layer leaves a ghost
|
||||
residual line below wrapped lines after deselecting (CodeMirror issue #1600, #1627).
|
||||
The layer div must be hidden when empty to force a repaint. */
|
||||
.cm-selectionLayer:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Style gutters */
|
||||
|
||||
.cm-gutters {
|
||||
@apply border-0 text-text-subtlest bg-surface pr-1.5;
|
||||
/* Not sure why, but there's a tiny gap left of the gutter that you can see text
|
||||
through. Move left slightly to fix that. */
|
||||
@apply -left-[1px];
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-gutter-lint {
|
||||
@apply w-auto!;
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply px-0;
|
||||
}
|
||||
|
||||
.cm-lint-marker {
|
||||
@apply cursor-default opacity-80 hover:opacity-100 transition-opacity;
|
||||
@apply rounded-full w-[0.9em] h-[0.9em];
|
||||
|
||||
content: "";
|
||||
|
||||
&.cm-lint-marker-error {
|
||||
@apply bg-danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-tag {
|
||||
/* Colors */
|
||||
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
|
||||
@apply hover:border-border hover:text-text hover:bg-surface-highlight;
|
||||
|
||||
@apply inline border px-1 mx-[0.5px] rounded-sm dark:shadow;
|
||||
|
||||
-webkit-text-security: none;
|
||||
|
||||
* {
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
/* The chevron on a tag that opens a menu. The tag itself is the button, so this is
|
||||
only the affordance that says so. */
|
||||
.tag-action {
|
||||
@apply inline-flex items-center align-middle ml-1;
|
||||
@apply text-text-subtlest;
|
||||
|
||||
svg {
|
||||
@apply w-[0.9em] h-[0.9em];
|
||||
}
|
||||
}
|
||||
|
||||
/* A preview of the value a tag stands for. Sized before the image loads, so arriving
|
||||
never changes the line's height. */
|
||||
.tag-thumbnail {
|
||||
@apply inline-block align-middle mr-1 rounded-xs;
|
||||
@apply w-[1.1em] h-[1.1em] object-cover bg-surface-highlight;
|
||||
}
|
||||
|
||||
.fn {
|
||||
@apply inline-block;
|
||||
.fn-inner {
|
||||
@apply text-text-subtle max-w-[40em] italic inline-flex items-end whitespace-pre text-[0.9em];
|
||||
}
|
||||
.fn-arg-name {
|
||||
/* Nothing yet */
|
||||
@apply opacity-60;
|
||||
}
|
||||
.fn-arg-value {
|
||||
@apply inline-block truncate;
|
||||
}
|
||||
.fn-bracket {
|
||||
@apply text-text-subtle opacity-30;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hyperlink-widget {
|
||||
& > * {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
&:hover > * {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
-webkit-text-security: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-singleline {
|
||||
.cm-editor {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-xs;
|
||||
|
||||
/* Hide scrollbars */
|
||||
|
||||
&::-webkit-scrollbar-corner,
|
||||
&::-webkit-scrollbar {
|
||||
@apply hidden!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-multiline {
|
||||
&.cm-full-height {
|
||||
@apply relative;
|
||||
|
||||
.cm-editor {
|
||||
@apply inset-0 absolute;
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply font-mono text-editor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Style search matches */
|
||||
.cm-searchMatch {
|
||||
@apply bg-transparent!;
|
||||
@apply rounded-[2px] outline outline-1;
|
||||
|
||||
&.cm-searchMatch-selected {
|
||||
@apply outline-text;
|
||||
@apply bg-text!;
|
||||
|
||||
&,
|
||||
* {
|
||||
@apply text-surface! font-semibold!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Obscure text for password fields */
|
||||
.cm-wrapper.cm-obscure-text .cm-line {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
/* Obscure text for password fields */
|
||||
.cm-wrapper.cm-obscure-text .cm-line {
|
||||
-webkit-text-security: disc;
|
||||
|
||||
.cm-placeholder {
|
||||
-webkit-text-security: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor .cm-gutterElement {
|
||||
@apply flex items-center;
|
||||
transition: color var(--transition-duration);
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon {
|
||||
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded-sm;
|
||||
@apply cursor-default!;
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon::after {
|
||||
@apply block w-1.5 h-1.5 p-0.5 border-transparent border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];
|
||||
}
|
||||
|
||||
/* Rotate the fold gutter chevron when open */
|
||||
.cm-editor .fold-gutter-icon[data-open]::after {
|
||||
@apply rotate-[-45deg];
|
||||
}
|
||||
|
||||
/* Adjust fold gutter icon position after rotation */
|
||||
.cm-editor .fold-gutter-icon:not([data-open])::after {
|
||||
@apply relative -left-[0.1em] top-[0.1em] rotate-[-135deg];
|
||||
}
|
||||
|
||||
.cm-editor .fold-gutter-icon:hover {
|
||||
@apply text-text bg-surface-highlight;
|
||||
}
|
||||
|
||||
.cm-editor .cm-foldPlaceholder {
|
||||
@apply px-2 border border-border-subtle bg-surface-highlight;
|
||||
@apply hover:text-text hover:border-border-subtle text-text;
|
||||
@apply cursor-default!;
|
||||
}
|
||||
|
||||
.cm-editor .cm-activeLineGutter {
|
||||
@apply bg-transparent text-text-subtle;
|
||||
}
|
||||
|
||||
/* Cursor and mouse cursor for readonly mode */
|
||||
.cm-wrapper.cm-readonly {
|
||||
&.cm-singleline * {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-singleline .cm-editor {
|
||||
.cm-content {
|
||||
@apply h-full flex items-center;
|
||||
|
||||
/* Break characters on line wrapping mode, useful for URL field.
|
||||
* We can make this dynamic if we need it to be configurable later
|
||||
*/
|
||||
|
||||
&.cm-lineWrapping {
|
||||
@apply break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip-lint {
|
||||
@apply font-mono! text-editor! rounded-sm! overflow-hidden! bg-surface-highlight! border! border-border! shadow!;
|
||||
|
||||
.cm-diagnostic-error {
|
||||
@apply border-l-danger px-4 py-2;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-lintPoint {
|
||||
&.cm-lintPoint-error {
|
||||
&::after {
|
||||
@apply border-b-danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-hover {
|
||||
@apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;
|
||||
@apply p-1.5;
|
||||
|
||||
/* Style the tooltip for popping up "open in browser" and other stuff */
|
||||
|
||||
a,
|
||||
button {
|
||||
@apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded-sm;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply cursor-default!;
|
||||
|
||||
&::after {
|
||||
@apply text-text bg-text h-3 w-3 ml-1;
|
||||
content: "";
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-size: contain;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* NOTE: Extra selector required to override default styles */
|
||||
.cm-tooltip.cm-tooltip-autocomplete,
|
||||
.cm-tooltip.cm-completionInfo {
|
||||
@apply shadow-lg bg-surface rounded-sm text-text-subtle border border-border-subtle z-50 pointer-events-auto;
|
||||
|
||||
& * {
|
||||
@apply font-mono! text-editor!;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply opacity-80 italic;
|
||||
|
||||
&::after {
|
||||
content: "a" !important; /* Default (eg. for GraphQL) */
|
||||
}
|
||||
|
||||
&.cm-completionIcon-function::after {
|
||||
content: "f" !important;
|
||||
@apply text-info;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-variable::after {
|
||||
content: "x" !important;
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-namespace::after {
|
||||
content: "n" !important;
|
||||
@apply text-warning;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-constant::after {
|
||||
content: "c" !important;
|
||||
@apply text-notice;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-class::after {
|
||||
content: "o" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-enum::after {
|
||||
content: "e" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-interface::after {
|
||||
content: "i" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-keyword::after {
|
||||
content: "k" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-method::after {
|
||||
content: "m" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-property::after {
|
||||
content: "a" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-text::after {
|
||||
content: "t" !important;
|
||||
}
|
||||
|
||||
&.cm-completionIcon-type::after {
|
||||
content: "t" !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-completionInfo {
|
||||
@apply mx-0.5 -mt-0.5 font-sans;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply transition-none;
|
||||
}
|
||||
|
||||
&.cm-tooltip-autocomplete {
|
||||
@apply font-mono;
|
||||
|
||||
& > ul {
|
||||
@apply p-1 max-h-[40vh];
|
||||
}
|
||||
|
||||
& > ul > li {
|
||||
@apply cursor-default px-2 h-[2em] rounded-sm text-text flex items-center;
|
||||
}
|
||||
|
||||
& > ul > li[aria-selected] {
|
||||
@apply bg-surface-highlight text-text;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply text-sm flex items-center pb-0.5 shrink-0;
|
||||
}
|
||||
|
||||
.cm-completionLabel {
|
||||
@apply text-text;
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@apply ml-auto pl-6 text-text-subtle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor .cm-panels {
|
||||
@apply bg-surface-highlight backdrop-blur-sm p-1 mb-1 text-text z-20 rounded-md;
|
||||
|
||||
input,
|
||||
button {
|
||||
@apply rounded-sm outline-hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply border-border-subtle bg-surface-highlight text-text hover:border-info;
|
||||
@apply appearance-none bg-none cursor-default;
|
||||
}
|
||||
|
||||
button[name="close"] {
|
||||
@apply text-text-subtle! hocus:text-text! px-2! -mr-1.5!;
|
||||
}
|
||||
|
||||
input {
|
||||
@apply bg-surface border-border-subtle focus:border-border-focus;
|
||||
@apply border outline-hidden;
|
||||
}
|
||||
|
||||
input.cm-textfield {
|
||||
@apply cursor-text;
|
||||
}
|
||||
|
||||
.cm-search label {
|
||||
@apply inline-flex items-center h-6 px-1.5 rounded-sm border border-border-subtle cursor-default text-text-subtle text-xs;
|
||||
|
||||
input[type="checkbox"] {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
&:has(:checked) {
|
||||
@apply text-primary border-border;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the "All" button */
|
||||
|
||||
button[name="select"] {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/* Replace next/prev button text with chevron icons */
|
||||
|
||||
.cm-search button[name="next"],
|
||||
.cm-search button[name="prev"] {
|
||||
@apply text-[0px] w-7 h-6 inline-flex items-center justify-center border border-border-subtle mr-1;
|
||||
}
|
||||
|
||||
.cm-search button[name="prev"]::after,
|
||||
.cm-search button[name="next"]::after {
|
||||
@apply block w-3.5 h-3.5 bg-text;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.cm-search button[name="prev"]::after {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.cm-search button[name="next"]::after {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.cm-search-match-count {
|
||||
@apply text-text-subtle text-xs font-mono whitespace-nowrap px-1.5 py-0.5 self-center;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +1,53 @@
|
||||
import { startCompletion } from "@codemirror/autocomplete";
|
||||
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
|
||||
import { foldState, forceParsing } from "@codemirror/language";
|
||||
import type { EditorStateConfig, Extension } from "@codemirror/state";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view";
|
||||
import { emacs } from "@replit/codemirror-emacs";
|
||||
import { vim } from "@replit/codemirror-vim";
|
||||
|
||||
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import type { EditorKeymap } from "@yaakapp-internal/models";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { CodeEditor, type CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import type { GraphQLSchema } from "graphql";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment";
|
||||
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
|
||||
import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables";
|
||||
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
|
||||
import { useRequestEditor } from "../../../hooks/useRequestEditor";
|
||||
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
|
||||
import { docFingerprint } from "../../../lib/docFingerprint";
|
||||
import { editEnvironment } from "../../../lib/editEnvironment";
|
||||
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
|
||||
import { jotaiStore } from "../../../lib/jotai";
|
||||
import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption";
|
||||
import { TemplateFunctionDialog } from "../../TemplateFunctionDialog";
|
||||
import { IconButton } from "../IconButton";
|
||||
import "./Editor.css";
|
||||
import {
|
||||
baseExtensions,
|
||||
editableExtensions,
|
||||
getLanguageExtension,
|
||||
multiLineExtensions,
|
||||
readonlyExtensions,
|
||||
} from "./extensions";
|
||||
import { getLanguageExtension } from "./extensions";
|
||||
import type { GenericCompletionConfig } from "./genericCompletion";
|
||||
import { singleLineExtensions } from "./singleLine";
|
||||
import { largeValues, mediaValues } from "./largeValues";
|
||||
|
||||
// VSCode's Tab actions mess with the single-line editor tab actions, so remove it.
|
||||
const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab");
|
||||
|
||||
const keymapExtensions: Record<EditorKeymap, Extension> = {
|
||||
vim: vim(),
|
||||
emacs: emacs(),
|
||||
vscode: keymap.of(vsCodeWithoutTab),
|
||||
default: [],
|
||||
};
|
||||
|
||||
export interface EditorProps {
|
||||
actions?: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
autoSelect?: boolean;
|
||||
export type EditorProps = Omit<CodeEditorProps, "language" | "extensions" | "keymap"> & {
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
autocompleteFunctions?: boolean;
|
||||
autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
|
||||
className?: string;
|
||||
defaultValue?: string | null;
|
||||
disableTabIndent?: boolean;
|
||||
disabled?: boolean;
|
||||
extraExtensions?: Extension[] | Extension;
|
||||
forcedEnvironmentId?: string;
|
||||
forceUpdateKey?: string | number;
|
||||
format?: (v: string) => Promise<string>;
|
||||
heightMode?: "auto" | "full";
|
||||
hideGutter?: boolean;
|
||||
id?: string;
|
||||
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
|
||||
lintExtension?: Extension;
|
||||
graphQLSchema?: GraphQLSchema | null;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onPaste?: (value: string) => void;
|
||||
onPasteOverwrite?: (e: ClipboardEvent, value: string) => void;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
singleLine?: boolean;
|
||||
containerOnly?: boolean;
|
||||
stateKey: string | null;
|
||||
tooltipContainer?: HTMLElement;
|
||||
type?: "text" | "password";
|
||||
wrapLines?: boolean;
|
||||
setRef?: (view: EditorView | null) => void;
|
||||
}
|
||||
|
||||
const stateFields = { history: historyField, folds: foldState };
|
||||
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
|
||||
};
|
||||
|
||||
const emptyVariables: WrappedEnvironmentVariable[] = [];
|
||||
const emptyExtension: Extension = [];
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
return <EditorInner key={props.stateKey} {...props} />;
|
||||
}
|
||||
|
||||
function EditorInner({
|
||||
actions,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
export function Editor({
|
||||
autocomplete,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
className,
|
||||
defaultValue,
|
||||
disableTabIndent,
|
||||
disabled,
|
||||
extraExtensions,
|
||||
forcedEnvironmentId,
|
||||
forceUpdateKey,
|
||||
format,
|
||||
heightMode,
|
||||
hideGutter,
|
||||
graphQLSchema,
|
||||
hideGutter,
|
||||
language,
|
||||
lintExtension,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onPasteOverwrite,
|
||||
placeholder,
|
||||
readOnly,
|
||||
singleLine,
|
||||
containerOnly,
|
||||
stateKey,
|
||||
type,
|
||||
wrapLines,
|
||||
setRef,
|
||||
...props
|
||||
}: EditorProps) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
|
||||
@@ -158,20 +64,10 @@ function EditorInner({
|
||||
wrapLines = settings.editorSoftWrap;
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
if (props.disabled) {
|
||||
readOnly = true;
|
||||
}
|
||||
|
||||
if (
|
||||
singleLine ||
|
||||
language == null ||
|
||||
language === "text" ||
|
||||
language === "url" ||
|
||||
language === "pairs"
|
||||
) {
|
||||
disableTabIndent = true;
|
||||
}
|
||||
|
||||
if (format == null && !readOnly) {
|
||||
format =
|
||||
language === "json"
|
||||
@@ -181,130 +77,22 @@ function EditorInner({
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleChange = useRef<EditorProps["onChange"]>(onChange);
|
||||
useEffect(() => {
|
||||
handleChange.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handlePaste = useRef<EditorProps["onPaste"]>(onPaste);
|
||||
useEffect(() => {
|
||||
handlePaste.current = onPaste;
|
||||
}, [onPaste]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handlePasteOverwrite = useRef<EditorProps["onPasteOverwrite"]>(onPasteOverwrite);
|
||||
useEffect(() => {
|
||||
handlePasteOverwrite.current = onPasteOverwrite;
|
||||
}, [onPasteOverwrite]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleFocus = useRef<EditorProps["onFocus"]>(onFocus);
|
||||
useEffect(() => {
|
||||
handleFocus.current = onFocus;
|
||||
}, [onFocus]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleBlur = useRef<EditorProps["onBlur"]>(onBlur);
|
||||
useEffect(() => {
|
||||
handleBlur.current = onBlur;
|
||||
}, [onBlur]);
|
||||
|
||||
// Use ref so we can update the handler without re-initializing the editor
|
||||
const handleKeyDown = useRef<EditorProps["onKeyDown"]>(onKeyDown);
|
||||
useEffect(() => {
|
||||
handleKeyDown.current = onKeyDown;
|
||||
}, [onKeyDown]);
|
||||
|
||||
// Update placeholder
|
||||
const placeholderCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configurePlaceholder() {
|
||||
if (cm.current === null) return;
|
||||
const ext = placeholderExt(placeholderElFromText(placeholder));
|
||||
const effects = placeholderCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects });
|
||||
// Template dialogs are opened against the editor the tag was clicked in
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const { setRef } = props;
|
||||
const handleSetRef = useCallback(
|
||||
(view: EditorView | null) => {
|
||||
viewRef.current = view;
|
||||
setRef?.(view);
|
||||
},
|
||||
[placeholder],
|
||||
);
|
||||
|
||||
// Update vim
|
||||
const keymapCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configureKeymap() {
|
||||
if (cm.current === null) return;
|
||||
const current = keymapCompartment.current.get(cm.current.view.state) ?? [];
|
||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||
if (settings.editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do
|
||||
if (settings.editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do
|
||||
if (settings.editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do
|
||||
if (settings.editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do
|
||||
|
||||
const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default;
|
||||
const effects = keymapCompartment.current.reconfigure(ext);
|
||||
cm.current.view.dispatch({ effects });
|
||||
},
|
||||
[settings.editorKeymap],
|
||||
);
|
||||
|
||||
// Update wrap lines
|
||||
const wrapLinesCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configureWrapLines() {
|
||||
if (cm.current === null) return;
|
||||
const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||
if (wrapLines && current !== emptyExtension) return; // Nothing to do
|
||||
if (!wrapLines && current === emptyExtension) return; // Nothing to do
|
||||
|
||||
const ext = wrapLines ? EditorView.lineWrapping : emptyExtension;
|
||||
const effects = wrapLinesCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects });
|
||||
},
|
||||
[wrapLines],
|
||||
);
|
||||
|
||||
// Update tab indent
|
||||
const tabIndentCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configureTabIndent() {
|
||||
if (cm.current === null) return;
|
||||
const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||
if (disableTabIndent && current !== emptyExtension) return; // Nothing to do
|
||||
if (!disableTabIndent && current === emptyExtension) return; // Nothing to do
|
||||
|
||||
const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension;
|
||||
const effects = tabIndentCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects });
|
||||
},
|
||||
[disableTabIndent],
|
||||
);
|
||||
|
||||
// Update read-only
|
||||
const readOnlyCompartment = useRef(new Compartment());
|
||||
useEffect(
|
||||
function configureReadOnly() {
|
||||
if (cm.current === null) return;
|
||||
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
||||
const next = readOnly ? readonlyExtensions : editableExtensions;
|
||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||
if (current === next) return;
|
||||
|
||||
const effects = readOnlyCompartment.current.reconfigure(next);
|
||||
cm.current?.view.dispatch({ effects });
|
||||
},
|
||||
[readOnly],
|
||||
[setRef],
|
||||
);
|
||||
|
||||
const onClickFunction = useCallback(
|
||||
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
||||
const show = () => {
|
||||
if (cm.current === null) return;
|
||||
TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
|
||||
if (viewRef.current === null) return;
|
||||
TemplateFunctionDialog.show(fn, tagValue, startPos, viewRef.current);
|
||||
};
|
||||
|
||||
if (fn.name === "secure") {
|
||||
@@ -344,455 +132,64 @@ function EditorInner({
|
||||
!!autocompleteFunctions,
|
||||
);
|
||||
|
||||
// Update the language extension when the language changes
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps
|
||||
useEffect(() => {
|
||||
if (cm.current === null) return;
|
||||
const { view, languageCompartment } = cm.current;
|
||||
const ext = getLanguageExtension({
|
||||
useTemplating,
|
||||
const languageExtension = useMemo(
|
||||
() =>
|
||||
getLanguageExtension({
|
||||
useTemplating,
|
||||
language,
|
||||
lintExtension,
|
||||
hideGutter,
|
||||
environmentVariables,
|
||||
autocomplete,
|
||||
completionOptions,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
graphQLSchema: graphQLSchema ?? null,
|
||||
}),
|
||||
[
|
||||
language,
|
||||
lintExtension,
|
||||
hideGutter,
|
||||
environmentVariables,
|
||||
autocomplete,
|
||||
completionOptions,
|
||||
environmentVariables,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
graphQLSchema: graphQLSchema ?? null,
|
||||
});
|
||||
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
|
||||
}, [
|
||||
language,
|
||||
lintExtension,
|
||||
autocomplete,
|
||||
environmentVariables,
|
||||
onClickFunction,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
completionOptions,
|
||||
useTemplating,
|
||||
graphQLSchema,
|
||||
hideGutter,
|
||||
]);
|
||||
|
||||
// Initialize the editor when ref mounts
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- only reinitialize when necessary
|
||||
const initEditorRef = useCallback(
|
||||
function initEditorRef(container: HTMLDivElement | null) {
|
||||
if (container === null) {
|
||||
flushCachedEditorState(stateKey);
|
||||
cm.current?.view.destroy();
|
||||
cm.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const languageCompartment = new Compartment();
|
||||
const langExt = getLanguageExtension({
|
||||
useTemplating,
|
||||
language,
|
||||
lintExtension,
|
||||
completionOptions,
|
||||
autocomplete,
|
||||
environmentVariables,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
graphQLSchema: graphQLSchema ?? null,
|
||||
});
|
||||
const extensions = [
|
||||
languageCompartment.of(langExt),
|
||||
placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),
|
||||
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
|
||||
tabIndentCompartment.current.of(
|
||||
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
|
||||
),
|
||||
keymapCompartment.current.of(
|
||||
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
||||
),
|
||||
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
|
||||
...getExtensions({
|
||||
container,
|
||||
singleLine,
|
||||
hideGutter,
|
||||
stateKey,
|
||||
onChange: handleChange,
|
||||
onPaste: handlePaste,
|
||||
onPasteOverwrite: handlePasteOverwrite,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onKeyDown: handleKeyDown,
|
||||
}),
|
||||
...(Array.isArray(extraExtensions)
|
||||
? extraExtensions
|
||||
: extraExtensions
|
||||
? [extraExtensions]
|
||||
: []),
|
||||
];
|
||||
|
||||
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
|
||||
|
||||
const doc = `${defaultValue ?? ""}`;
|
||||
const config: EditorStateConfig = { extensions, doc };
|
||||
|
||||
const state = cachedJsonState
|
||||
? EditorState.fromJSON(cachedJsonState, config, stateFields)
|
||||
: EditorState.create(config);
|
||||
|
||||
// PERF: Built detached and attached in a microtask, rather than passing `parent` and
|
||||
// letting the constructor attach it first.
|
||||
//
|
||||
// The constructor sets `contenteditable` on the content element and then reads
|
||||
// `document.fonts.ready`. Both force the browser to flush style and layout when the
|
||||
// element is already in the document, and whatever the previous editor attached has
|
||||
// just dirtied it again — so mounting n editors in one commit costs n forced layouts.
|
||||
// The environment editor mounts two per variable row (~60 of them), which is where its
|
||||
// half-second open went.
|
||||
//
|
||||
// Building detached moves that work off the live document, and deferring the attach to
|
||||
// a microtask means every editor in the commit is built before any of them is inserted:
|
||||
// one forced layout for the batch instead of one each. The microtask still runs before
|
||||
// paint, so nothing is visible in the meantime.
|
||||
const view = new EditorView({ state });
|
||||
|
||||
// For large documents, the parser may parse the max number of lines and fail to add
|
||||
// things like fold markers because of it.
|
||||
// This forces it to parse more but keeps the timeout to the default of 100 ms.
|
||||
forceParsing(view, 9e6, 100);
|
||||
|
||||
cm.current = { view, languageCompartment };
|
||||
queueMicrotask(() => {
|
||||
// The editor was torn down (or replaced) before we got here
|
||||
if (cm.current?.view !== view) return;
|
||||
|
||||
// Runs after the surrounding try/catch has returned, so it needs its own
|
||||
try {
|
||||
container.appendChild(view.dom);
|
||||
// Everything the constructor measured, it measured detached
|
||||
view.requestMeasure();
|
||||
|
||||
if (autoFocus) {
|
||||
view.focus();
|
||||
}
|
||||
if (autoSelect) {
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to attach Codemirror", e);
|
||||
}
|
||||
});
|
||||
setRef?.(view);
|
||||
} catch (e) {
|
||||
console.log("Failed to initialize Codemirror", e);
|
||||
}
|
||||
},
|
||||
[forceUpdateKey],
|
||||
completionOptions,
|
||||
useTemplating,
|
||||
graphQLSchema,
|
||||
hideGutter,
|
||||
],
|
||||
);
|
||||
|
||||
// For read-only mode, update content when `defaultValue` changes
|
||||
useEffect(
|
||||
function updateReadOnlyEditor() {
|
||||
if (readOnly && cm.current?.view != null) {
|
||||
updateContents(cm.current.view, defaultValue || "");
|
||||
}
|
||||
},
|
||||
[defaultValue, readOnly],
|
||||
);
|
||||
|
||||
// Force input to update when receiving change and not in focus
|
||||
useLayoutEffect(
|
||||
function updateNonFocusedEditor() {
|
||||
const notFocused = !cm.current?.view.hasFocus;
|
||||
if (notFocused && cm.current != null) {
|
||||
updateContents(cm.current.view, defaultValue || "");
|
||||
}
|
||||
},
|
||||
[defaultValue],
|
||||
);
|
||||
|
||||
// Add bg classes to actions, so they appear over the text
|
||||
const decoratedActions = useMemo(() => {
|
||||
const results = [];
|
||||
const actionClassName = classNames(
|
||||
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow",
|
||||
);
|
||||
|
||||
if (format) {
|
||||
results.push(
|
||||
<IconButton
|
||||
showConfirm
|
||||
key="format"
|
||||
size="sm"
|
||||
title="Reformat contents"
|
||||
icon="magic_wand"
|
||||
variant="border"
|
||||
className={classNames(actionClassName)}
|
||||
onClick={async () => {
|
||||
if (cm.current === null) return;
|
||||
const { doc } = cm.current.view.state;
|
||||
const formatted = await format(doc.toString());
|
||||
// Update editor and blur because the cursor will reset anyway
|
||||
cm.current.view.dispatch({
|
||||
changes: { from: 0, to: doc.length, insert: formatted },
|
||||
});
|
||||
cm.current.view.contentDOM.blur();
|
||||
// Fire change event
|
||||
onChange?.(formatted);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
results.push(
|
||||
Children.map(actions, (existingChild) => {
|
||||
if (!isValidElement<{ className?: string }>(existingChild)) return null;
|
||||
const existingProps = existingChild.props;
|
||||
|
||||
return cloneElement(existingChild, {
|
||||
...existingProps,
|
||||
className: classNames(existingProps.className, actionClassName),
|
||||
});
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}, [actions, format, onChange]);
|
||||
|
||||
const cmContainer = (
|
||||
<div
|
||||
ref={initEditorRef}
|
||||
className={classNames(
|
||||
className,
|
||||
"cm-wrapper text-base",
|
||||
disabled && "opacity-disabled",
|
||||
type === "password" && "cm-obscure-text",
|
||||
heightMode === "auto" ? "cm-auto-height" : "cm-full-height",
|
||||
singleLine ? "cm-singleline" : "cm-multiline",
|
||||
readOnly && "cm-readonly",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (singleLine || containerOnly) {
|
||||
return cmContainer;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative h-full w-full x-theme-editor bg-surface">
|
||||
{cmContainer}
|
||||
{decoratedActions && (
|
||||
<HStack
|
||||
space={1}
|
||||
justifyContent="end"
|
||||
className={classNames(
|
||||
"absolute bottom-2 left-0 right-0",
|
||||
"pointer-events-none", // No pointer events, so we don't block the editor
|
||||
)}
|
||||
>
|
||||
{decoratedActions}
|
||||
</HStack>
|
||||
)}
|
||||
</div>
|
||||
<CodeEditor
|
||||
{...props}
|
||||
editableExtensions={mediaValues}
|
||||
extensions={extraExtensions}
|
||||
format={format}
|
||||
hideGutter={hideGutter}
|
||||
keyDownHandler={startCompletionOnHotkey}
|
||||
keymap={settings.editorKeymap}
|
||||
language={codeEditorLanguage(language)}
|
||||
languageExtension={languageExtension}
|
||||
lintExtension={lintExtension}
|
||||
readOnly={readOnly}
|
||||
readOnlyExtensions={largeValues}
|
||||
setRef={handleSetRef}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getExtensions({
|
||||
stateKey,
|
||||
container,
|
||||
singleLine,
|
||||
hideGutter,
|
||||
onChange,
|
||||
onPaste,
|
||||
onPasteOverwrite,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: Pick<EditorProps, "singleLine" | "hideGutter"> & {
|
||||
stateKey: EditorProps["stateKey"];
|
||||
container: HTMLDivElement | null;
|
||||
onChange: RefObject<EditorProps["onChange"]>;
|
||||
onPaste: RefObject<EditorProps["onPaste"]>;
|
||||
onPasteOverwrite: RefObject<EditorProps["onPasteOverwrite"]>;
|
||||
onFocus: RefObject<EditorProps["onFocus"]>;
|
||||
onBlur: RefObject<EditorProps["onBlur"]>;
|
||||
onKeyDown: RefObject<EditorProps["onKeyDown"]>;
|
||||
}) {
|
||||
// TODO: Ensure tooltips render inside the dialog if we are in one.
|
||||
const parent =
|
||||
container?.closest<HTMLDivElement>('[role="dialog"]') ??
|
||||
document.querySelector<HTMLDivElement>("#cm-portal") ??
|
||||
undefined;
|
||||
|
||||
return [
|
||||
...baseExtensions, // Must be first
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: "off",
|
||||
autocorrect: "off",
|
||||
// Keeps macOS Writing Tools from offering to write code for us
|
||||
writingsuggestions: "false",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
onFocus.current?.();
|
||||
},
|
||||
blur: () => {
|
||||
onBlur.current?.();
|
||||
},
|
||||
keydown: (e, view) => {
|
||||
// Check if the hotkey matches the editor.autocomplete action
|
||||
if (eventMatchesHotkey(e, "editor.autocomplete")) {
|
||||
e.preventDefault();
|
||||
startCompletion(view);
|
||||
return true;
|
||||
}
|
||||
onKeyDown.current?.(e);
|
||||
},
|
||||
paste: (e, v) => {
|
||||
const textData = e.clipboardData?.getData("text/plain") ?? "";
|
||||
onPaste.current?.(textData);
|
||||
if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
|
||||
onPasteOverwrite.current?.(e, textData);
|
||||
}
|
||||
},
|
||||
}),
|
||||
tooltips({ parent }),
|
||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
|
||||
...(singleLine ? [singleLineExtensions()] : []),
|
||||
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
|
||||
|
||||
// ------------------------ //
|
||||
// Things that must be last //
|
||||
// ------------------------ //
|
||||
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.startState === update.state) return;
|
||||
|
||||
if (onChange && update.docChanged) {
|
||||
onChange.current?.(update.state.doc.toString());
|
||||
}
|
||||
|
||||
saveCachedEditorStateDebounced(stateKey, update.state);
|
||||
}),
|
||||
];
|
||||
/** The client's own languages bring their extension along, so CodeEditor needn't know them */
|
||||
function codeEditorLanguage(language: EditorProps["language"]): CodeEditorProps["language"] {
|
||||
return language === "url" || language === "pairs" || language === "timeline" ? null : language;
|
||||
}
|
||||
|
||||
const placeholderElFromText = (text: string | undefined) => {
|
||||
const el = document.createElement("div");
|
||||
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
|
||||
// to do with how Yaak "hacks" it with CSS for single line input.
|
||||
el.innerHTML = text ? text.replaceAll("\n", "<br/>") : " ";
|
||||
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) {
|
||||
if (!stateKey || state == null) return;
|
||||
const stateObj = state.toJSON(stateFields);
|
||||
|
||||
// Save state in sessionStorage by removing doc and saving a fingerprint of it instead.
|
||||
// This will be checked on restore and put back in if it matches.
|
||||
// Editable documents get the exact hash, so a collision can never restore undo history
|
||||
// belonging to other content
|
||||
stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly });
|
||||
stateObj.doc = undefined;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));
|
||||
} catch (err) {
|
||||
console.log("Failed to save to editor state", stateKey, err);
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
|
||||
if (stateKey == null) return;
|
||||
|
||||
try {
|
||||
const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));
|
||||
if (stateStr == null) return null;
|
||||
|
||||
const { docHash, ...state } = JSON.parse(stateStr);
|
||||
|
||||
// Ensure the doc matches the one that was used to save the state
|
||||
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
state.doc = doc;
|
||||
return state;
|
||||
} catch (err) {
|
||||
console.log("Failed to restore editor storage", stateKey, err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeFullStateKey(stateKey: string): string {
|
||||
return `editor.${stateKey}`;
|
||||
}
|
||||
|
||||
function updateContents(view: EditorView, text: string) {
|
||||
// Replace codemirror contents
|
||||
const currentDoc = view.state.doc.toString();
|
||||
|
||||
if (currentDoc === text) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith(currentDoc)) {
|
||||
// If we're just appending, append only the changes. This preserves
|
||||
// things like scroll position.
|
||||
view.dispatch({
|
||||
changes: view.state.changes({
|
||||
from: currentDoc.length,
|
||||
insert: text.slice(currentDoc.length),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// If we're replacing everything, reset the entire content
|
||||
view.dispatch({
|
||||
changes: view.state.changes({
|
||||
from: 0,
|
||||
to: currentDoc.length,
|
||||
insert: text,
|
||||
}),
|
||||
});
|
||||
}
|
||||
function startCompletionOnHotkey(e: KeyboardEvent, view: EditorView): boolean {
|
||||
if (!eventMatchesHotkey(e, "editor.autocomplete")) return false;
|
||||
e.preventDefault();
|
||||
startCompletion(view);
|
||||
return true;
|
||||
}
|
||||
@@ -1,141 +1,30 @@
|
||||
import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { history, historyKeymap } from "@codemirror/commands";
|
||||
import { go } from "@codemirror/lang-go";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { php } from "@codemirror/lang-php";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import { xml } from "@codemirror/lang-xml";
|
||||
import {
|
||||
bracketMatching,
|
||||
codeFolding,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
HighlightStyle,
|
||||
indentOnInput,
|
||||
LanguageSupport,
|
||||
StreamLanguage,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { c, csharp, kotlin, objectiveC } from "@codemirror/legacy-modes/mode/clike";
|
||||
import { clojure } from "@codemirror/legacy-modes/mode/clojure";
|
||||
import { http } from "@codemirror/legacy-modes/mode/http";
|
||||
import { oCaml } from "@codemirror/legacy-modes/mode/mllike";
|
||||
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
|
||||
import { r } from "@codemirror/legacy-modes/mode/r";
|
||||
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { swift } from "@codemirror/legacy-modes/mode/swift";
|
||||
import { linter, lintGutter, lintKeymap } from "@codemirror/lint";
|
||||
import { search, searchKeymap } from "@codemirror/search";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import {
|
||||
crosshairCursor,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
rectangularSelection,
|
||||
} from "@codemirror/view";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc";
|
||||
closeBracketsExtensions,
|
||||
getLanguageExtension as getBaseLanguageExtension,
|
||||
type LanguageSupports,
|
||||
readonlyExtensions as baseReadonlyExtensions,
|
||||
syntaxExtensions,
|
||||
} from "@yaakapp-internal/ui/editor";
|
||||
import { graphql } from "cm6-graphql";
|
||||
import type { GraphQLSchema } from "graphql";
|
||||
import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId";
|
||||
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
|
||||
import { jotaiStore } from "../../../lib/jotai";
|
||||
import { renderMarkdown } from "../../../lib/markdown";
|
||||
import { pluralizeCount } from "../../../lib/pluralize";
|
||||
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
||||
import type { EditorProps } from "./Editor";
|
||||
import { jsonParseLinter } from "./json-lint";
|
||||
import { largeValues, mediaValues } from "./largeValues";
|
||||
import { largeValues } from "./largeValues";
|
||||
import { pairs } from "./pairs/extension";
|
||||
import { searchMatchCount } from "./searchMatchCount";
|
||||
import { text } from "./text/extension";
|
||||
import { timeline } from "./timeline/extension";
|
||||
import type { TwigCompletionOption } from "./twig/completion";
|
||||
import { twig } from "./twig/extension";
|
||||
import { pathParametersPlugin } from "./twig/pathParameters";
|
||||
import { url } from "./url/extension";
|
||||
|
||||
export const syntaxHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
|
||||
color: "var(--textSubtlest)",
|
||||
},
|
||||
{
|
||||
tag: [t.emphasis],
|
||||
textDecoration: "underline",
|
||||
},
|
||||
{
|
||||
tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
|
||||
color: "var(--textSubtle)",
|
||||
},
|
||||
{
|
||||
tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
|
||||
color: "var(--info)",
|
||||
},
|
||||
{ tag: [t.variableName], color: "var(--success)" },
|
||||
{ tag: [t.bool], color: "var(--warning)" },
|
||||
{ tag: [t.attributeName, t.propertyName], color: "var(--primary)" },
|
||||
{ tag: [t.attributeValue], color: "var(--warning)" },
|
||||
{ tag: [t.string], color: "var(--notice)" },
|
||||
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: "var(--danger)" },
|
||||
]);
|
||||
|
||||
const syntaxTheme = EditorView.theme({}, { dark: true });
|
||||
|
||||
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
|
||||
|
||||
const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
|
||||
return () => new LanguageSupport(StreamLanguage.define(mode));
|
||||
};
|
||||
|
||||
const syntaxExtensions: Record<
|
||||
NonNullable<EditorProps["language"]>,
|
||||
null | (() => LanguageSupport)
|
||||
> = {
|
||||
graphql: null,
|
||||
json: jsonc,
|
||||
javascript: javascript,
|
||||
// HTML as XML because HTML is oddly slow
|
||||
html: xml,
|
||||
xml: xml,
|
||||
url: url,
|
||||
pairs: pairs,
|
||||
text: text,
|
||||
timeline: timeline,
|
||||
markdown: markdown,
|
||||
c: legacyLang(c),
|
||||
clojure: legacyLang(clojure),
|
||||
csharp: legacyLang(csharp),
|
||||
go: go,
|
||||
http: legacyLang(http),
|
||||
java: java,
|
||||
kotlin: legacyLang(kotlin),
|
||||
objective_c: legacyLang(objectiveC),
|
||||
ocaml: legacyLang(oCaml),
|
||||
php: php,
|
||||
powershell: legacyLang(powerShell),
|
||||
python: python,
|
||||
r: legacyLang(r),
|
||||
ruby: legacyLang(ruby),
|
||||
shell: legacyLang(shell),
|
||||
swift: legacyLang(swift),
|
||||
};
|
||||
|
||||
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"];
|
||||
const languages: LanguageSupports = { ...syntaxExtensions, url, pairs, timeline };
|
||||
|
||||
export function getLanguageExtension({
|
||||
useTemplating,
|
||||
@@ -157,18 +46,7 @@ export function getLanguageExtension({
|
||||
onClickPathParameter: (name: string) => void;
|
||||
completionOptions: TwigCompletionOption[];
|
||||
graphQLSchema: GraphQLSchema | null;
|
||||
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">) {
|
||||
const extraExtensions: Extension[] = [];
|
||||
|
||||
if (language === "url") {
|
||||
extraExtensions.push(pathParametersPlugin(onClickPathParameter));
|
||||
}
|
||||
|
||||
// Only close brackets on languages that need it
|
||||
if (language && closeBracketsFor.includes(language)) {
|
||||
extraExtensions.push(closeBracketsExtensions);
|
||||
}
|
||||
|
||||
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">): Extension {
|
||||
// GraphQL is a special exception
|
||||
if (language === "graphql") {
|
||||
return [
|
||||
@@ -189,155 +67,31 @@ export function getLanguageExtension({
|
||||
}));
|
||||
},
|
||||
}),
|
||||
extraExtensions,
|
||||
[closeBracketsExtensions],
|
||||
];
|
||||
}
|
||||
|
||||
if (language === "json") {
|
||||
extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
|
||||
extraExtensions.push(
|
||||
jsoncLanguage.data.of({
|
||||
commentTokens: { line: "//", block: { open: "/*", close: "*/" } },
|
||||
}),
|
||||
);
|
||||
if (!hideGutter) {
|
||||
extraExtensions.push(lintGutter());
|
||||
}
|
||||
}
|
||||
|
||||
const maybeBase = language ? syntaxExtensions[language] : null;
|
||||
const base = typeof maybeBase === "function" ? maybeBase() : null;
|
||||
if (base == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!useTemplating) {
|
||||
return [base, extraExtensions];
|
||||
}
|
||||
|
||||
return twig({
|
||||
base,
|
||||
environmentVariables,
|
||||
completionOptions,
|
||||
autocomplete,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
extraExtensions,
|
||||
return getBaseLanguageExtension({
|
||||
language,
|
||||
hideGutter,
|
||||
languages,
|
||||
lintExtension: language === "json" ? (lintExtension ?? linter(jsonParseLinter())) : undefined,
|
||||
extraExtensions: language === "url" ? [pathParametersPlugin(onClickPathParameter)] : [],
|
||||
wrap: useTemplating
|
||||
? (base, extraExtensions) =>
|
||||
twig({
|
||||
base,
|
||||
environmentVariables,
|
||||
completionOptions,
|
||||
autocomplete,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
extraExtensions,
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys.
|
||||
// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings.
|
||||
const filteredCompletionKeymap = completionKeymap.filter((binding) => {
|
||||
const key = binding.key?.toLowerCase() ?? "";
|
||||
const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? "";
|
||||
// Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i)
|
||||
const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`");
|
||||
return !isStartTrigger;
|
||||
});
|
||||
|
||||
export const baseExtensions = [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
dropCursor(),
|
||||
drawSelection(),
|
||||
autocompletion({
|
||||
tooltipClass: () => "x-theme-menu",
|
||||
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
|
||||
defaultKeymap: false, // We handle the trigger via configurable hotkeys
|
||||
compareCompletions: (a, b) => {
|
||||
// Don't sort completions at all, only on boost
|
||||
return (a.boost ?? 0) - (b.boost ?? 0);
|
||||
},
|
||||
}),
|
||||
syntaxHighlighting(syntaxHighlightStyle),
|
||||
syntaxTheme,
|
||||
keymap.of([...historyKeymap, ...filteredCompletionKeymap]),
|
||||
];
|
||||
|
||||
export const readonlyExtensions = [
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.contentAttributes.of({ tabindex: "-1" }),
|
||||
// Nobody is editing this, so every rule applies
|
||||
largeValues,
|
||||
];
|
||||
|
||||
/**
|
||||
* The counterpart for a document being edited.
|
||||
*
|
||||
* Only encoded media collapses here: it is pasted rather than typed, and replaced rather than
|
||||
* edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that
|
||||
* hide text by length alone stay out. See {@link mediaValues}.
|
||||
*/
|
||||
export const editableExtensions = [mediaValues];
|
||||
|
||||
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
|
||||
search({ top: true }),
|
||||
searchMatchCount(),
|
||||
hideGutter
|
||||
? []
|
||||
: [
|
||||
lineNumbers(),
|
||||
foldGutter({
|
||||
markerDOM: (open) => {
|
||||
const el = document.createElement("div");
|
||||
el.classList.add("fold-gutter-icon");
|
||||
el.tabIndex = -1;
|
||||
if (open) {
|
||||
el.setAttribute("data-open", "");
|
||||
}
|
||||
return el;
|
||||
},
|
||||
}),
|
||||
],
|
||||
codeFolding({
|
||||
placeholderDOM(_view, onclick, prepared) {
|
||||
const el = document.createElement("span");
|
||||
el.onclick = onclick;
|
||||
el.className = "cm-foldPlaceholder";
|
||||
el.innerText = prepared || "…";
|
||||
el.title = "unfold";
|
||||
el.ariaLabel = "folded code";
|
||||
return el;
|
||||
},
|
||||
/**
|
||||
* Show the number of items when code folded. NOTE: this doesn't get called when restoring
|
||||
* a previous serialized editor state, which is a bummer
|
||||
*/
|
||||
preparePlaceholder(state, range) {
|
||||
let count: number | undefined;
|
||||
let startToken = "{";
|
||||
let endToken = "}";
|
||||
|
||||
const prevLine = state.doc.lineAt(range.from).text;
|
||||
const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{");
|
||||
|
||||
if (isArray) {
|
||||
startToken = "[";
|
||||
endToken = "]";
|
||||
}
|
||||
|
||||
const internal = state.sliceDoc(range.from, range.to);
|
||||
const toParse = startToken + internal + endToken;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(toParse);
|
||||
count = Object.keys(parsed).length;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
if (count !== undefined) {
|
||||
const label = isArray ? "item" : "key";
|
||||
return pluralizeCount(label, count);
|
||||
}
|
||||
},
|
||||
}),
|
||||
indentOnInput(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
bracketMatching(),
|
||||
highlightActiveLineGutter(),
|
||||
keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]),
|
||||
];
|
||||
// Nobody is editing this, so every rule applies
|
||||
export const readonlyExtensions = [...baseReadonlyExtensions, largeValues];
|
||||
@@ -1,59 +1,15 @@
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||
import {
|
||||
type JsonLintOptions,
|
||||
jsonParseLinter as baseJsonParseLinter,
|
||||
} from "@yaakapp-internal/ui/editor";
|
||||
import { replaceTemplateTags } from "../../../lib/templateTags";
|
||||
|
||||
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
||||
const MESSAGES: Record<string, string> = {
|
||||
InvalidSymbol: "Invalid symbol",
|
||||
InvalidNumberFormat: "Invalid number format",
|
||||
PropertyNameExpected: "Property name expected",
|
||||
ValueExpected: "Value expected",
|
||||
ColonExpected: "Colon expected",
|
||||
CommaExpected: "Comma expected",
|
||||
CloseBraceExpected: "Closing brace expected",
|
||||
CloseBracketExpected: "Closing bracket expected",
|
||||
EndOfFileExpected: "End of file expected",
|
||||
InvalidCommentToken: "Comments are not allowed",
|
||||
UnexpectedEndOfComment: "Unexpected end of comment",
|
||||
UnexpectedEndOfString: "Unexpected end of string",
|
||||
UnexpectedEndOfNumber: "Unexpected end of number",
|
||||
InvalidUnicode: "Invalid unicode sequence",
|
||||
InvalidEscapeCharacter: "Invalid escape character",
|
||||
InvalidCharacter: "Invalid character",
|
||||
};
|
||||
|
||||
interface JsonLintOptions {
|
||||
allowComments?: boolean;
|
||||
allowTrailingCommas?: boolean;
|
||||
export function jsonParseLinter(options?: Omit<JsonLintOptions, "escape">) {
|
||||
return baseJsonParseLinter({ ...options, escape: escapeTemplateTags });
|
||||
}
|
||||
|
||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const doc = view.state.doc.toString();
|
||||
if (doc.trim() === "") return [];
|
||||
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start));
|
||||
|
||||
const errors: ParseError[] = [];
|
||||
parse(escapedDoc, errors, {
|
||||
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
||||
disallowComments: !(options?.allowComments ?? true),
|
||||
});
|
||||
|
||||
// Later errors are mostly consequences of the first one, so only that one is shown
|
||||
const error = errors[0];
|
||||
if (error == null) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
from: error.offset,
|
||||
to: error.offset + error.length,
|
||||
severity: "error",
|
||||
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
||||
},
|
||||
];
|
||||
};
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
function escapeTemplateTags(doc: string): string {
|
||||
return replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start));
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { ViewPlugin } from "@codemirror/view";
|
||||
import type { JsonPathSegment } from "./jsonPath";
|
||||
import { jsonPathSegmentsAt } from "./jsonPath";
|
||||
|
||||
export interface BreadcrumbUpdate {
|
||||
/** Path to the cursor, or `null` when the document isn't JSON. */
|
||||
segments: JsonPathSegment[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the JSON path under the cursor when the editor is created (it may restore a
|
||||
* cached selection), and whenever the selection, document, or parsed tree changes. The
|
||||
* tree matters because large documents parse in the background, so a path read early
|
||||
* can be wrong until the parser reaches the cursor.
|
||||
*
|
||||
* The callback is not fired on blur: moving focus to the filter box (by clicking
|
||||
* a crumb) must not erase the breadcrumb that was just acted on.
|
||||
*/
|
||||
export function jsonBreadcrumbExtension(onUpdate: (update: BreadcrumbUpdate) => void): Extension {
|
||||
const report = (view: EditorView) => {
|
||||
onUpdate({ segments: jsonPathSegmentsAt(view.state, view.state.selection.main.head) });
|
||||
};
|
||||
|
||||
return ViewPlugin.define((view) => {
|
||||
report(view);
|
||||
return {
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.selectionSet ||
|
||||
update.docChanged ||
|
||||
syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||
) {
|
||||
report(update.view);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { jsonc } from "@shopify/lang-jsonc";
|
||||
import { forceParsing } from "@codemirror/language";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { jsonPathSegmentsAt, jsonPathToSegments, segmentsToJsonPath } from "./jsonPath";
|
||||
|
||||
function stateFor(doc: string): EditorState {
|
||||
const state = EditorState.create({ doc, extensions: [jsonc()] });
|
||||
// Ensure the whole doc is parsed so resolveInner sees a complete tree.
|
||||
forceParsing({ state } as never, doc.length, 5000);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** JSONPath produced when the cursor sits just after the first occurrence of `needle`. */
|
||||
function pathAt(doc: string, needle: string): string {
|
||||
const pos = doc.indexOf(needle) + needle.length;
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
if (segs == null) throw new Error("no JSON tree");
|
||||
return segmentsToJsonPath(segs);
|
||||
}
|
||||
|
||||
/** JSONPath produced when the cursor sits just before the first occurrence of `needle`. */
|
||||
function pathBefore(doc: string, needle: string): string {
|
||||
const pos = doc.indexOf(needle);
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
if (segs == null) throw new Error("no JSON tree");
|
||||
return segmentsToJsonPath(segs);
|
||||
}
|
||||
|
||||
describe("jsonPathSegmentsAt", () => {
|
||||
test("nested object keys", () => {
|
||||
const doc = `{ "address": { "geo": { "lat": "-37" } } }`;
|
||||
expect(pathAt(doc, `-37`)).toBe(`$.address.geo.lat`);
|
||||
});
|
||||
|
||||
test("array element index", () => {
|
||||
const doc = `{ "featured": [ { "id": 1 }, { "id": 2 } ] }`;
|
||||
expect(pathAt(doc, `2`)).toBe(`$.featured[1].id`);
|
||||
});
|
||||
|
||||
test("root array element", () => {
|
||||
const doc = `[ { "id": 1 }, { "id": 2 } ]`;
|
||||
expect(pathAt(doc, `"id": 2`)).toBe(`$[1].id`);
|
||||
});
|
||||
|
||||
test("key containing a dot uses bracket-quote notation", () => {
|
||||
const doc = `{ "sort": { "link": { "filter": { "category.id": [ "100" ] } } } }`;
|
||||
expect(pathAt(doc, `"100"`)).toBe(`$.sort.link.filter["category.id"][0]`);
|
||||
});
|
||||
|
||||
test("cursor on a property name resolves that key", () => {
|
||||
const doc = `{ "outer": { "inner": 5 } }`;
|
||||
expect(pathAt(doc, `"inner"`)).toBe(`$.outer.inner`);
|
||||
});
|
||||
|
||||
test("deeply nested arrays and objects", () => {
|
||||
const doc = `{ "a": [ { "b": [ 10, 20, 30 ] } ] }`;
|
||||
expect(pathAt(doc, `30`)).toBe(`$.a[0].b[2]`);
|
||||
});
|
||||
|
||||
test("root scalar yields the root path", () => {
|
||||
expect(pathAt(`"hello"`, `hello`)).toBe(`$`);
|
||||
});
|
||||
|
||||
test("caret just before a key still resolves that key", () => {
|
||||
// The character to the left is the object's whitespace, not the property.
|
||||
const doc = `[\n {\n "userId": 1,\n "body": "x"\n }\n]`;
|
||||
expect(pathBefore(doc, `"body"`)).toBe(`$[0].body`);
|
||||
});
|
||||
|
||||
test("caret in a line's indentation resolves that line's member", () => {
|
||||
// Caret in the blank indentation of the `title` line (structurally the
|
||||
// object's whitespace) reads as being on `title`, not a neighbour.
|
||||
const doc = `[\n {\n "userId": 1,\n "id": 1,\n "title": "x"\n }\n]`;
|
||||
const pos = doc.indexOf(`"title"`) - 2; // two spaces into the title line
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0].title`);
|
||||
});
|
||||
|
||||
test("caret at the end of an array element keeps the element index", () => {
|
||||
// The caret sits after `}` where the left token is the object's close brace.
|
||||
const doc = `[\n { "id": 1 },\n { "id": 2 }\n]`;
|
||||
const pos = doc.indexOf(`},`) + 1;
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0]`);
|
||||
});
|
||||
|
||||
test("non-JSON documents return null", () => {
|
||||
const state = EditorState.create({ doc: `<xml/>` });
|
||||
expect(jsonPathSegmentsAt(state, 3)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmentsToJsonPath", () => {
|
||||
test("empty segment list is the root", () => {
|
||||
expect(segmentsToJsonPath([])).toBe(`$`);
|
||||
});
|
||||
|
||||
test("partial path via count", () => {
|
||||
const segs = [
|
||||
{ kind: "key", key: "a" },
|
||||
{ kind: "index", index: 2 },
|
||||
{ kind: "key", key: "b" },
|
||||
] as const;
|
||||
expect(segmentsToJsonPath([...segs], 2)).toBe(`$.a[2]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonPathToSegments", () => {
|
||||
test("round-trips paths built by segmentsToJsonPath", () => {
|
||||
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
|
||||
const segments = jsonPathToSegments(path);
|
||||
expect(segments).not.toBeNull();
|
||||
expect(segmentsToJsonPath(segments ?? [])).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null for paths that don't name a single location", () => {
|
||||
for (const path of [
|
||||
"$..id",
|
||||
"$[*]",
|
||||
"$.items[?(@.id)]",
|
||||
"$[0:2]",
|
||||
"items[0]",
|
||||
"$.a b",
|
||||
'$["\\q"]',
|
||||
'$["unterminated]',
|
||||
'$["line\nbreak"]',
|
||||
"$[01]",
|
||||
"$[9007199254740993]",
|
||||
"$.$",
|
||||
]) {
|
||||
expect(jsonPathToSegments(path)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { SyntaxNode } from "@lezer/common";
|
||||
import type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
|
||||
|
||||
export { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
|
||||
export type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
|
||||
|
||||
// Lezer node names from the JSONC grammar (@shopify/lang-jsonc). A JSON value is
|
||||
// exactly one of these; everything else in the tree is punctuation or a comment.
|
||||
const VALUE_NODES = new Set(["Object", "Array", "String", "Number", "True", "False", "Null"]);
|
||||
// The nodes with children that a caret can land "between" — its whitespace and
|
||||
// punctuation belong to the container, not to any value inside it.
|
||||
const CONTAINER_NODES = new Set(["Object", "Array", "JsoncText"]);
|
||||
|
||||
/** Read a `PropertyName` (a quoted JSON string) back to its raw key. */
|
||||
function keyFromPropertyName(node: SyntaxNode, state: EditorState): string {
|
||||
const raw = state.doc.sliceString(node.from, node.to);
|
||||
try {
|
||||
return JSON.parse(raw) as string;
|
||||
} catch {
|
||||
// A half-typed or malformed key still deserves a best-effort label.
|
||||
return raw.replace(/^"|"$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
/** Index of `child` among the value elements of its parent `Array` node. */
|
||||
function indexInArray(array: SyntaxNode, child: SyntaxNode): number {
|
||||
let idx = 0;
|
||||
for (let c = array.firstChild; c != null; c = c.nextSibling) {
|
||||
if (c.from === child.from && c.to === child.to) return idx;
|
||||
if (VALUE_NODES.has(c.name)) idx++;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path from the document root to the member on the caret's line, or `null`
|
||||
* when the document has no JSON tree (a different language, or empty). An empty
|
||||
* array means the caret sits at the root value itself.
|
||||
*
|
||||
* A caret exactly on a token uses that token. A caret in a line's whitespace
|
||||
* resolves to the enclosing object or array, so it's re-anchored to the first
|
||||
* non-whitespace character on that line — in pretty-printed JSON each member
|
||||
* starts its own line, so that's the member's own token. This tracks "the line
|
||||
* I'm on" without guessing between neighbouring keys.
|
||||
*
|
||||
* From the anchor, each ancestor contributes a segment: a `Property` its key, a
|
||||
* value directly inside an `Array` its index. Collected deepest-first, reversed.
|
||||
*/
|
||||
export function jsonPathSegmentsAt(state: EditorState, pos: number): JsonPathSegment[] | null {
|
||||
const tree = syntaxTree(state);
|
||||
if (tree.topNode.name !== "JsoncText") return null;
|
||||
|
||||
let node: SyntaxNode = tree.resolveInner(pos, -1);
|
||||
if (CONTAINER_NODES.has(node.name)) {
|
||||
const line = state.doc.lineAt(pos);
|
||||
const indent = line.text.length - line.text.trimStart().length;
|
||||
if (indent < line.text.length) {
|
||||
node = tree.resolveInner(line.from + indent, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const segments: JsonPathSegment[] = [];
|
||||
let cur: SyntaxNode | null = node;
|
||||
while (cur != null) {
|
||||
const parent: SyntaxNode | null = cur.parent;
|
||||
if (cur.name === "Property") {
|
||||
const nameNode = cur.getChild("PropertyName");
|
||||
if (nameNode != null) {
|
||||
segments.push({ kind: "key", key: keyFromPropertyName(nameNode, state) });
|
||||
}
|
||||
} else if (parent != null && parent.name === "Array" && VALUE_NODES.has(cur.name)) {
|
||||
segments.push({ kind: "index", index: indexInArray(parent, cur) });
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
|
||||
segments.reverse();
|
||||
return segments;
|
||||
}
|
||||
|
||||
const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
|
||||
/** A single segment as JSONPath, choosing dot vs. bracket form for a key. */
|
||||
function segmentToJsonPath(segment: JsonPathSegment): string {
|
||||
if (segment.kind === "index") return `[${segment.index}]`;
|
||||
// jsonpath-plus interprets a bare `$` as its root operator.
|
||||
if (BARE_KEY.test(segment.key) && segment.key !== "$") return `.${segment.key}`;
|
||||
// Anything with a dot, space, quote, etc. must be a quoted bracket accessor.
|
||||
// JSON.stringify gives correct double-quoting and escaping, matching the
|
||||
// convention the JSONPath filter box already displays.
|
||||
return `[${JSON.stringify(segment.key)}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a JSONPath expression for the first `count` segments (all of them by
|
||||
* default). `segmentsToJsonPath([])` is the root, `$`.
|
||||
*/
|
||||
export function segmentsToJsonPath(segments: JsonPathSegment[], count = segments.length): string {
|
||||
return "$" + segments.slice(0, count).map(segmentToJsonPath).join("");
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { jsonc } from "@shopify/lang-jsonc";
|
||||
import { text } from "./text/extension";
|
||||
import { twig } from "./twig/extension";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
MAX_VISIBLE_LINE_CHARS,
|
||||
} from "./largeValues";
|
||||
import type { SniffedValue } from "./sniffValue";
|
||||
import { text } from "@yaakapp-internal/ui/editor";
|
||||
|
||||
const BIG = "A".repeat(1_000_000);
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import { Editor } from "./LazyEditor";
|
||||
|
||||
/** Renders a ui component's editor as the client's, with its settings and value handling */
|
||||
export function renderEditor({ extensions, ...props }: CodeEditorProps) {
|
||||
return <Editor {...props} extraExtensions={extensions} />;
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { SearchQuery } from "@codemirror/search";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
currentMatch,
|
||||
literalSearch,
|
||||
MAX_COUNT,
|
||||
MatchCounter,
|
||||
normalizeDoc,
|
||||
normalizeSearch,
|
||||
scanNormalized,
|
||||
scanQuery,
|
||||
} from "./searchMatchCount";
|
||||
|
||||
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
|
||||
|
||||
const stateOf = (doc: string) => EditorState.create({ doc });
|
||||
|
||||
/**
|
||||
* The matches the counter finds, having checked them against the search panel's own cursor.
|
||||
*
|
||||
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
|
||||
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
|
||||
*/
|
||||
function matchesOf(doc: string, config: QueryConfig) {
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery(config);
|
||||
const matches = new MatchCounter().matches(state, query);
|
||||
expect(matches).toEqual(scanQuery(state, query));
|
||||
return matches;
|
||||
}
|
||||
|
||||
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
|
||||
|
||||
describe("counting", () => {
|
||||
test("counts every match, whatever the case", () => {
|
||||
expect(countOf("one Two three two", { search: "two" })).toBe(2);
|
||||
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("skips matches overlapping an earlier one", () => {
|
||||
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
|
||||
expect(countOf("ababa", { search: "aba" })).toBe(1);
|
||||
});
|
||||
|
||||
test("treats a query as text, not as a pattern", () => {
|
||||
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
|
||||
});
|
||||
|
||||
test("unquotes escapes unless the query is literal", () => {
|
||||
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
|
||||
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("counts regexp and whole word queries through the cursor", () => {
|
||||
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
|
||||
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
|
||||
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
|
||||
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
|
||||
});
|
||||
|
||||
test("stops counting at the cap", () => {
|
||||
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
|
||||
});
|
||||
|
||||
test("reports where the matches are", () => {
|
||||
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds nothing to match with an empty needle", () => {
|
||||
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalization", () => {
|
||||
test("finds what a character decomposes into", () => {
|
||||
// The é is one character holding an `e`, and the match covers the whole of it
|
||||
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
|
||||
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
|
||||
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
|
||||
expect(countOf("one two", { search: "one two" })).toBe(1);
|
||||
expect(countOf("full width", { search: "full" })).toBe(1);
|
||||
});
|
||||
|
||||
test("matches a decomposed query against composed text, and the reverse", () => {
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps offsets straight after an expansion", () => {
|
||||
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
|
||||
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
|
||||
});
|
||||
|
||||
test("normalizes the query whole, the document by character", () => {
|
||||
expect(normalizeSearch("CAFÉ", false)).toBe("café");
|
||||
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
|
||||
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
|
||||
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
|
||||
});
|
||||
|
||||
test("leaves a document that normalizes to itself untouched", () => {
|
||||
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
|
||||
expect(text).toBe("plain 日本 🎉 text");
|
||||
expect(expansions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("current match", () => {
|
||||
const matches = [
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
{ from: 8, to: 10 },
|
||||
];
|
||||
|
||||
test("counts from one, and reports 0 off a match", () => {
|
||||
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
|
||||
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
|
||||
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
|
||||
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
test("moving the selection doesn't scan again", () => {
|
||||
const state = stateOf("a1 b2 c3");
|
||||
const query = new SearchQuery({ search: "\\d", regexp: true });
|
||||
const counter = new MatchCounter();
|
||||
const found = counter.matches(state, query);
|
||||
|
||||
// The document a selection-only transaction leaves behind is the one already scanned
|
||||
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
|
||||
expect(counter.matches(moved, query)).toBe(found);
|
||||
expect(currentMatch(found, moved.selection.main)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The mapping from normalized offsets back to document offsets is the part of this that can go
|
||||
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
|
||||
*/
|
||||
describe("against the cursor, on awkward text", () => {
|
||||
const ALPHABET = [
|
||||
..."abcABC .\\\n".split(""),
|
||||
"é",
|
||||
"é",
|
||||
"fi",
|
||||
"…",
|
||||
" ",
|
||||
"İ",
|
||||
"Σ",
|
||||
"ς",
|
||||
"日",
|
||||
"🎉",
|
||||
"Ⅻ",
|
||||
"f",
|
||||
"①",
|
||||
"́",
|
||||
];
|
||||
|
||||
/** Seeded, so a failure is the same failure next run */
|
||||
function random(seed: number) {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
for (const caseSensitive of [false, true]) {
|
||||
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
|
||||
const next = random(caseSensitive ? 20260831 : 7);
|
||||
|
||||
for (let round = 0; round < 400; round++) {
|
||||
const doc = Array.from(
|
||||
{ length: 2 + Math.floor(next() * 60) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
|
||||
// Half the queries are lifted out of the document, so matches are actually found
|
||||
const start = Math.floor(next() * doc.length);
|
||||
const search =
|
||||
next() < 0.5
|
||||
? doc.slice(start, start + 1 + Math.floor(next() * 3))
|
||||
: Array.from(
|
||||
{ length: 1 + Math.floor(next() * 2) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
if (search === "") continue;
|
||||
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery({ search, caseSensitive });
|
||||
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
|
||||
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,325 +0,0 @@
|
||||
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||
import type { EditorState, Extension, Text } from "@codemirror/state";
|
||||
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
|
||||
/** Matches are counted no further than this, since an exact total stops being useful long before */
|
||||
export const MAX_COUNT = 9999;
|
||||
|
||||
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
|
||||
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
|
||||
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
|
||||
|
||||
export interface Match {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** A character whose normalized form is a different length, shifting every offset past it */
|
||||
interface Expansion {
|
||||
normFrom: number;
|
||||
normTo: number;
|
||||
docFrom: number;
|
||||
docTo: number;
|
||||
}
|
||||
|
||||
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
|
||||
export interface NormalizedDoc {
|
||||
text: string;
|
||||
expansions: Expansion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
|
||||
* case-sensitive — in a single pass rather than one call per code point.
|
||||
*
|
||||
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
|
||||
* makes counting matches in a large response slow. Doing it a character at a time still matters
|
||||
* for the result, since it keeps NFKD from reordering marks across characters, so the
|
||||
* granularity stays and only the repeated work goes: each distinct character is normalized once
|
||||
* and the answer reused, and ASCII runs never reach ICU at all.
|
||||
*/
|
||||
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
|
||||
const rewritten = new Map<string, string>();
|
||||
const expansions: Expansion[] = [];
|
||||
let shift = 0;
|
||||
|
||||
const normalized = text.replace(
|
||||
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
|
||||
(chunk: string, at: number) => {
|
||||
// An ASCII run only ever folds case, which can't change its length
|
||||
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
|
||||
|
||||
let out = rewritten.get(chunk);
|
||||
if (out === undefined) {
|
||||
out = chunk.normalize("NFKD");
|
||||
if (!caseSensitive) out = out.toLowerCase();
|
||||
rewritten.set(chunk, out);
|
||||
}
|
||||
|
||||
if (out.length !== chunk.length) {
|
||||
expansions.push({
|
||||
normFrom: at + shift,
|
||||
normTo: at + shift + out.length,
|
||||
docFrom: at,
|
||||
docTo: at + chunk.length,
|
||||
});
|
||||
shift += out.length - chunk.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
);
|
||||
|
||||
return { text: normalized, expansions };
|
||||
}
|
||||
|
||||
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
|
||||
export function normalizeSearch(search: string, caseSensitive: boolean): string {
|
||||
const normalized = search.normalize("NFKD");
|
||||
return caseSensitive ? normalized : normalized.toLowerCase();
|
||||
}
|
||||
|
||||
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
|
||||
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
|
||||
const matches: Match[] = [];
|
||||
if (needle === "") return matches;
|
||||
|
||||
const { text } = doc;
|
||||
let pos = text.indexOf(needle);
|
||||
while (pos >= 0) {
|
||||
let end = pos + needle.length;
|
||||
// However the query was cut, a match ends on a whole code point, as the cursor's do
|
||||
if (isLowSurrogate(text.charCodeAt(end))) end++;
|
||||
|
||||
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
|
||||
pos = text.indexOf(needle, resumeAfter(doc, end));
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** The same through the query's own cursor, which handles regexps and whole words */
|
||||
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
|
||||
const matches: Match[] = [];
|
||||
const cursor = query.getCursor(state);
|
||||
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
matches.push({ from: result.value.from, to: result.value.to });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
|
||||
|
||||
/** The last character expansion beginning at or before `offset`, if there is one */
|
||||
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
|
||||
let low = 0;
|
||||
let high = expansions.length - 1;
|
||||
let found: Expansion | null = null;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (expansions[mid]!.normFrom <= offset) {
|
||||
found = expansions[mid]!;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function docStart(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
// A match starting inside a character's expansion starts at the character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docFrom
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where scanning picks up after a match ending at `offset`.
|
||||
*
|
||||
* The cursor moves through the document a character at a time, so once a match ends inside a
|
||||
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
|
||||
* ever counts as one match of ".".
|
||||
*/
|
||||
function resumeAfter(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
|
||||
? expansion.normTo
|
||||
: offset;
|
||||
}
|
||||
|
||||
function docEnd(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
if (offset <= expansion.normFrom) return expansion.docFrom;
|
||||
// A match ending inside a character's expansion covers the whole character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docTo
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
|
||||
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
|
||||
let index = 0;
|
||||
for (const match of matches) {
|
||||
index++;
|
||||
if (match.from <= selection.from && match.to >= selection.to) return index;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The text a plain query looks for, or null when only the cursor can answer it */
|
||||
export function literalSearch(query: SearchQuery): string | null {
|
||||
if (query.regexp || query.wholeWord || query.test != null) return null;
|
||||
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
|
||||
return query.literal
|
||||
? query.search
|
||||
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
|
||||
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the matches for the search panel, keeping the normalized document and the matches it
|
||||
* last found, so neither moving the selection nor typing another character starts over.
|
||||
*/
|
||||
export class MatchCounter {
|
||||
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
|
||||
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
|
||||
|
||||
matches(state: EditorState, query: SearchQuery): Match[] {
|
||||
const last = this.last;
|
||||
if (last != null && last.doc === state.doc && last.query.eq(query)) {
|
||||
return last.matches;
|
||||
}
|
||||
|
||||
const matches = this.scan(state, query);
|
||||
this.last = { doc: state.doc, query, matches };
|
||||
return matches;
|
||||
}
|
||||
|
||||
private scan(state: EditorState, query: SearchQuery): Match[] {
|
||||
const search = literalSearch(query);
|
||||
if (search == null) return scanQuery(state, query);
|
||||
|
||||
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
|
||||
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
|
||||
}
|
||||
|
||||
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
|
||||
const cached = this.doc;
|
||||
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
|
||||
return cached.normalized;
|
||||
}
|
||||
|
||||
const normalized = normalizeDoc(doc.toString(), caseSensitive);
|
||||
this.doc = { doc, caseSensitive, normalized };
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A CodeMirror extension that displays the total number of search matches
|
||||
* inside the built-in search panel.
|
||||
*/
|
||||
export function searchMatchCount(): Extension {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
private countEl: HTMLElement | null = null;
|
||||
private counter = new MatchCounter();
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.updateCount();
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
// Recompute when doc changes, search state changes, or selection moves
|
||||
const query = getSearchQuery(update.state);
|
||||
const prevQuery = getSearchQuery(update.startState);
|
||||
const open = searchPanelOpen(update.state);
|
||||
const prevOpen = searchPanelOpen(update.startState);
|
||||
|
||||
if (update.docChanged || update.selectionSet || !query.eq(prevQuery) || open !== prevOpen) {
|
||||
this.updateCount();
|
||||
}
|
||||
}
|
||||
|
||||
private updateCount() {
|
||||
const state = this.view.state;
|
||||
const open = searchPanelOpen(state);
|
||||
const query = getSearchQuery(state);
|
||||
|
||||
if (!open) {
|
||||
this.removeCountEl();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureCountEl();
|
||||
if (this.countEl == null) return;
|
||||
|
||||
if (!query.search) {
|
||||
this.countEl.textContent = "0/0";
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = this.counter.matches(state, query);
|
||||
if (matches.length > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (matches.length === 0) {
|
||||
this.countEl.textContent = "0/0";
|
||||
} else {
|
||||
const current = currentMatch(matches, state.selection.main);
|
||||
this.countEl.textContent = `${current}/${matches.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureCountEl() {
|
||||
// Find the search panel in the editor DOM
|
||||
const panel = this.view.dom.querySelector(".cm-search");
|
||||
if (!panel) {
|
||||
this.countEl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.countEl && this.countEl.parentElement === panel) {
|
||||
return; // Already attached
|
||||
}
|
||||
|
||||
this.countEl = document.createElement("span");
|
||||
this.countEl.className = "cm-search-match-count";
|
||||
|
||||
// Reorder: insert prev button, then next button, then count after the search input
|
||||
const searchInput = panel.querySelector("input");
|
||||
const prevBtn = panel.querySelector('button[name="prev"]');
|
||||
const nextBtn = panel.querySelector('button[name="next"]');
|
||||
if (searchInput && searchInput.parentElement === panel) {
|
||||
searchInput.after(this.countEl);
|
||||
if (prevBtn) this.countEl.after(prevBtn);
|
||||
if (nextBtn && prevBtn) prevBtn.after(nextBtn);
|
||||
} else {
|
||||
panel.prepend(this.countEl);
|
||||
}
|
||||
}
|
||||
|
||||
private removeCountEl() {
|
||||
if (this.countEl) {
|
||||
this.countEl.remove();
|
||||
this.countEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.removeCountEl();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { Extension, TransactionSpec } from "@codemirror/state";
|
||||
import { EditorSelection, EditorState, Transaction } from "@codemirror/state";
|
||||
|
||||
/**
|
||||
* A CodeMirror extension that forces single-line input by stripping
|
||||
* all newline characters from user input, including pasted content.
|
||||
*
|
||||
* This extension uses a transaction filter to intercept user input,
|
||||
* removes any newline characters, and adjusts the selection to the end
|
||||
* of the inserted text.
|
||||
*
|
||||
* IME composition events are ignored to preserve proper input behavior
|
||||
* for non-Latin languages.
|
||||
*
|
||||
* @returns A CodeMirror extension that enforces single-line editing.
|
||||
*/
|
||||
export function singleLineExtensions(): Extension {
|
||||
return EditorState.transactionFilter.of(
|
||||
(tr: Transaction): TransactionSpec | readonly TransactionSpec[] => {
|
||||
if (!tr.isUserEvent("input") || tr.isUserEvent("input.type.compose")) return tr;
|
||||
|
||||
const changes: { from: number; to: number; insert: string }[] = [];
|
||||
|
||||
tr.changes.iterChanges((_fromA, toA, fromB, _toB, inserted) => {
|
||||
let insert = "";
|
||||
for (const line of inserted.iterLines()) {
|
||||
insert += line.replace(/\n/g, "");
|
||||
}
|
||||
|
||||
if (insert !== inserted.toString()) {
|
||||
changes.push({ from: fromB, to: toA, insert });
|
||||
}
|
||||
});
|
||||
|
||||
const lastChange = changes[changes.length - 1];
|
||||
if (lastChange == null) return tr;
|
||||
|
||||
const selection = EditorSelection.cursor(lastChange.from + lastChange.insert.length);
|
||||
|
||||
return {
|
||||
changes,
|
||||
selection,
|
||||
userEvent: tr.annotation(Transaction.userEvent) ?? undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { LanguageSupport, LRLanguage } from "@codemirror/language";
|
||||
import { parser } from "./text";
|
||||
|
||||
export const textLanguage = LRLanguage.define({
|
||||
name: "text",
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
export function text() {
|
||||
return new LanguageSupport(textLanguage);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@top Template { Text }
|
||||
|
||||
@tokens {
|
||||
Text { ![]+ }
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const Template = 1,
|
||||
Text = 2;
|
||||
@@ -1,16 +0,0 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import { LRParser } from "@lezer/lr";
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "[OQOPOOQOOOOO",
|
||||
stateData: "V~OQPO~O",
|
||||
goto: "QPP",
|
||||
nodeNames: "⚠ Template Text",
|
||||
maxTerm: 3,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 0,
|
||||
tokenData: "p~RRO;'S[;'S;=`j<%lO[~aRQ~O;'S[;'S;=`j<%lO[~mP;=`<%l[",
|
||||
tokenizers: [0],
|
||||
topRules: { Template: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { json } from "@codemirror/lang-json";
|
||||
import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { singleLineExtensions } from "../singleLine";
|
||||
import { twigCompletion, type TwigCompletionOption } from "./completion";
|
||||
import { parser } from "./twig";
|
||||
import { singleLineExtensions } from "@yaakapp-internal/ui/editor";
|
||||
|
||||
const options: TwigCompletionOption[] = [
|
||||
{ name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} },
|
||||
|
||||
@@ -5,11 +5,11 @@ import { parseMixed } from "@lezer/common";
|
||||
import type { WrappedEnvironmentVariable } from "../../../../hooks/useEnvironmentVariables";
|
||||
import type { GenericCompletionConfig } from "../genericCompletion";
|
||||
import { genericCompletion } from "../genericCompletion";
|
||||
import { textLanguage } from "../text/extension";
|
||||
import type { TwigCompletionOption } from "./completion";
|
||||
import { twigCompletion } from "./completion";
|
||||
import { templateTagsPlugin } from "./templateTags";
|
||||
import { parser as twigParser } from "./twig";
|
||||
import { textLanguage } from "@yaakapp-internal/ui/editor";
|
||||
|
||||
export function twig({
|
||||
base,
|
||||
|
||||
@@ -1,91 +1,22 @@
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
HttpMethodTag as BaseHttpMethodTag,
|
||||
HttpMethodTagRaw as BaseHttpMethodTagRaw,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo } from "react";
|
||||
import { type ComponentProps, memo } from "react";
|
||||
|
||||
interface Props {
|
||||
request: HttpRequest | GrpcRequest | WebsocketRequest;
|
||||
className?: string;
|
||||
short?: boolean;
|
||||
noAlias?: boolean;
|
||||
}
|
||||
|
||||
const methodNames: Record<string, string> = {
|
||||
get: "GET",
|
||||
put: "PUT",
|
||||
post: "POST",
|
||||
patch: "PTCH",
|
||||
delete: "DELE",
|
||||
options: "OPTN",
|
||||
head: "HEAD",
|
||||
query: "QURY",
|
||||
graphql: "GQL",
|
||||
grpc: "GRPC",
|
||||
websocket: "WS",
|
||||
};
|
||||
|
||||
export const HttpMethodTag = memo(function HttpMethodTag({
|
||||
request,
|
||||
className,
|
||||
short,
|
||||
noAlias,
|
||||
}: Props) {
|
||||
const method =
|
||||
request.model === "http_request" && request.bodyType === "graphql" && !noAlias
|
||||
? "graphql"
|
||||
: request.model === "grpc_request"
|
||||
? "grpc"
|
||||
: request.model === "websocket_request"
|
||||
? "websocket"
|
||||
: request.method;
|
||||
|
||||
return <HttpMethodTagRaw method={method} className={className} short={short} />;
|
||||
export const HttpMethodTag = memo(function HttpMethodTag(
|
||||
props: Omit<ComponentProps<typeof BaseHttpMethodTag>, "colored">,
|
||||
) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
return <BaseHttpMethodTag {...props} colored={settings.coloredMethods} />;
|
||||
});
|
||||
|
||||
export function HttpMethodTagRaw({
|
||||
className,
|
||||
method,
|
||||
short,
|
||||
forceColor,
|
||||
}: {
|
||||
method: string;
|
||||
className?: string;
|
||||
short?: boolean;
|
||||
forceColor?: boolean;
|
||||
}) {
|
||||
let label = method.toUpperCase();
|
||||
if (short) {
|
||||
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
|
||||
label = label.padEnd(4, " ");
|
||||
}
|
||||
|
||||
const m = method.toUpperCase();
|
||||
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof BaseHttpMethodTagRaw>, "colored"> & { forceColor?: boolean }) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const colored = forceColor || settings.coloredMethods;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
!colored && "text-text-subtle",
|
||||
colored && m === "GRAPHQL" && "text-info",
|
||||
colored && m === "WEBSOCKET" && "text-info",
|
||||
colored && m === "GRPC" && "text-info",
|
||||
colored && m === "QUERY" && "text-text-subtle",
|
||||
colored && m === "OPTIONS" && "text-info",
|
||||
colored && m === "HEAD" && "text-text-subtle",
|
||||
colored && m === "GET" && "text-primary",
|
||||
colored && m === "PUT" && "text-warning",
|
||||
colored && m === "PATCH" && "text-notice",
|
||||
colored && m === "POST" && "text-success",
|
||||
colored && m === "DELETE" && "text-danger",
|
||||
"font-mono shrink-0 whitespace-pre",
|
||||
"pt-[0.15em]", // Fix for monospace font not vertically centering
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return <BaseHttpMethodTagRaw {...props} colored={forceColor || settings.coloredMethods} />;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function HttpResponseDurationTag({ response }: Props) {
|
||||
const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);
|
||||
const timeout = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
|
||||
// Calculate the duration of the response for use when the response hasn't finished yet
|
||||
useEffect(() => {
|
||||
clearInterval(timeout.current);
|
||||
if (response.state === "closed") return;
|
||||
timeout.current = setInterval(() => {
|
||||
setFallbackElapsed(Date.now() - new Date(`${response.createdAt}Z`).getTime());
|
||||
}, 100);
|
||||
return () => clearInterval(timeout.current);
|
||||
}, [response.createdAt, response.state]);
|
||||
|
||||
const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : "--";
|
||||
const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`;
|
||||
|
||||
const elapsed = response.state === "closed" ? response.elapsed : fallbackElapsed;
|
||||
|
||||
return (
|
||||
<span className="font-mono" title={title}>
|
||||
{formatMillis(elapsed)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMillis(ms: number) {
|
||||
if (ms < 1000) {
|
||||
return `${ms} ms`;
|
||||
}
|
||||
if (ms < 60_000) {
|
||||
const seconds = (ms / 1000).toFixed(ms < 10_000 ? 1 : 0);
|
||||
return `${seconds} s`;
|
||||
}
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const seconds = Math.round((ms % 60_000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { HttpResponse, HttpResponseState } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
className?: string;
|
||||
showReason?: boolean;
|
||||
short?: boolean;
|
||||
}
|
||||
|
||||
export function HttpStatusTag({ response, ...props }: Props) {
|
||||
const { status, state, statusReason } = response;
|
||||
return <HttpStatusTagRaw status={status} state={state} statusReason={statusReason} {...props} />;
|
||||
}
|
||||
|
||||
export function HttpStatusTagRaw({
|
||||
status,
|
||||
state,
|
||||
className,
|
||||
showReason,
|
||||
statusReason,
|
||||
short,
|
||||
}: Omit<Props, "response"> & {
|
||||
status: number | string;
|
||||
state?: HttpResponseState;
|
||||
statusReason?: string | null;
|
||||
}) {
|
||||
let colorClass: string;
|
||||
let label = `${status}`;
|
||||
const statusN = typeof status === "number" ? status : parseInt(status, 10);
|
||||
|
||||
if (state === "initialized") {
|
||||
label = short ? "CONN" : "CONNECTING";
|
||||
colorClass = "text-text-subtle";
|
||||
} else if (statusN < 100) {
|
||||
label = short ? "ERR" : "ERROR";
|
||||
colorClass = "text-danger";
|
||||
} else if (statusN < 200) {
|
||||
colorClass = "text-info";
|
||||
} else if (statusN < 300) {
|
||||
colorClass = "text-success";
|
||||
} else if (statusN < 400) {
|
||||
colorClass = "text-primary";
|
||||
} else if (statusN < 500) {
|
||||
colorClass = "text-warning";
|
||||
} else {
|
||||
colorClass = "text-danger";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classNames(className, "font-mono min-w-0", colorClass)}>
|
||||
{label} {showReason && statusReason}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
||||
import type { TooltipProps } from "./Tooltip";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
type Props = Omit<TooltipProps, "children"> & {
|
||||
icon?: IconProps["icon"];
|
||||
iconSize?: IconProps["size"];
|
||||
iconColor?: IconProps["color"];
|
||||
className?: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export function IconTooltip({
|
||||
content,
|
||||
icon = "info",
|
||||
iconColor,
|
||||
iconSize,
|
||||
...tooltipProps
|
||||
}: Props) {
|
||||
return (
|
||||
<Tooltip content={content} {...tooltipProps}>
|
||||
<Icon
|
||||
className="opacity-60 hover:opacity-100"
|
||||
icon={icon}
|
||||
size={iconSize}
|
||||
color={iconColor}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import { HStack, Icon, type IconProps } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
type IconProps,
|
||||
IconTooltip,
|
||||
Input as BaseUiInput,
|
||||
type InputHandle,
|
||||
type InputProps as BaseUiInputProps,
|
||||
useStateWithDeps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { createFastMutation } from "../../hooks/useFastMutation";
|
||||
import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled";
|
||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
import { copyToClipboard } from "../../lib/copy";
|
||||
import {
|
||||
analyzeTemplate,
|
||||
convertTemplateToInsecure,
|
||||
convertTemplateToSecure,
|
||||
} from "../../lib/encryption";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import {
|
||||
setupOrConfigureEncryption,
|
||||
withEncryptionEnabled,
|
||||
@@ -25,69 +30,30 @@ import type { EditorProps } from "./Editor/Editor";
|
||||
import type { GenericCompletion } from "./Editor/genericCompletion";
|
||||
import { Editor } from "./Editor/LazyEditor";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
import { Label } from "./Label";
|
||||
|
||||
export type InputProps = Pick<
|
||||
EditorProps,
|
||||
| "language"
|
||||
| "autocomplete"
|
||||
| "forcedEnvironmentId"
|
||||
| "forceUpdateKey"
|
||||
| "disabled"
|
||||
| "autoFocus"
|
||||
| "autoSelect"
|
||||
| "autocompleteVariables"
|
||||
| "autocompleteFunctions"
|
||||
| "onKeyDown"
|
||||
| "readOnly"
|
||||
> & {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
inputWrapperClassName?: string;
|
||||
defaultValue?: string | null;
|
||||
disableObscureToggle?: boolean;
|
||||
fullHeight?: boolean;
|
||||
hideLabel?: boolean;
|
||||
help?: ReactNode;
|
||||
label: ReactNode;
|
||||
labelClassName?: string;
|
||||
labelPosition?: "top" | "left";
|
||||
leftSlot?: ReactNode;
|
||||
multiLine?: boolean;
|
||||
name?: string;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onPaste?: (value: string) => void;
|
||||
onPasteOverwrite?: EditorProps["onPasteOverwrite"];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
rightSlot?: ReactNode;
|
||||
/**
|
||||
* Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked
|
||||
* without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without
|
||||
* wanting the affordance (the URL bar completes from history, and a chevron there would be
|
||||
* noise). Renders nothing when there are no options to show.
|
||||
*/
|
||||
showOptionsPicker?: boolean;
|
||||
size?: "2xs" | "xs" | "sm" | "md" | "auto";
|
||||
stateKey: EditorProps["stateKey"];
|
||||
extraExtensions?: EditorProps["extraExtensions"];
|
||||
tint?: Color;
|
||||
type?: "text" | "password";
|
||||
validate?: boolean | ((v: string) => boolean);
|
||||
wrapLines?: boolean;
|
||||
setRef?: (h: InputHandle | null) => void;
|
||||
};
|
||||
export type { InputHandle };
|
||||
|
||||
export interface InputHandle {
|
||||
focus: () => void;
|
||||
isFocused: () => boolean;
|
||||
value: () => string;
|
||||
selectAll: () => void;
|
||||
dispatch: EditorView["dispatch"];
|
||||
}
|
||||
export type InputProps = Omit<
|
||||
BaseUiInputProps,
|
||||
"language" | "extensions" | "renderEditor" | "renderOptionsPicker"
|
||||
> &
|
||||
Pick<
|
||||
EditorProps,
|
||||
| "language"
|
||||
| "autocomplete"
|
||||
| "forcedEnvironmentId"
|
||||
| "autocompleteVariables"
|
||||
| "autocompleteFunctions"
|
||||
| "extraExtensions"
|
||||
> & {
|
||||
/**
|
||||
* Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked
|
||||
* without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without
|
||||
* wanting the affordance (the URL bar completes from history, and a chevron there would be
|
||||
* noise). Renders nothing when there are no options to show.
|
||||
*/
|
||||
showOptionsPicker?: boolean;
|
||||
};
|
||||
|
||||
export function Input({ type, ...props }: InputProps) {
|
||||
// If it's a password and template functions are supported (ie. secure(...)) then
|
||||
@@ -99,283 +65,47 @@ export function Input({ type, ...props }: InputProps) {
|
||||
}
|
||||
|
||||
function BaseInput({
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
disableObscureToggle,
|
||||
disabled,
|
||||
forceUpdateKey,
|
||||
fullHeight,
|
||||
help,
|
||||
hideLabel,
|
||||
inputWrapperClassName,
|
||||
label,
|
||||
labelClassName,
|
||||
labelPosition = "top",
|
||||
leftSlot,
|
||||
multiLine,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
onPaste,
|
||||
onPasteOverwrite,
|
||||
placeholder,
|
||||
readOnly,
|
||||
required,
|
||||
rightSlot,
|
||||
autocomplete,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
extraExtensions,
|
||||
forcedEnvironmentId,
|
||||
language,
|
||||
showOptionsPicker,
|
||||
size = "md",
|
||||
stateKey,
|
||||
tint,
|
||||
type = "text",
|
||||
validate,
|
||||
wrapLines,
|
||||
setRef,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
const skipNextFocus = useRef<boolean>(false);
|
||||
|
||||
const handle = useMemo<InputHandle>(
|
||||
() => ({
|
||||
focus: () => {
|
||||
if (editorRef.current == null) return;
|
||||
const anchor = editorRef.current.state.doc.length;
|
||||
skipNextFocus.current = true;
|
||||
editorRef.current.focus();
|
||||
editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true });
|
||||
},
|
||||
isFocused: () => editorRef.current?.hasFocus ?? false,
|
||||
value: () => editorRef.current?.state.doc.toString() ?? "",
|
||||
dispatch: (...args) => {
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
editorRef.current?.dispatch(...(args as any));
|
||||
},
|
||||
selectAll() {
|
||||
if (editorRef.current == null) return;
|
||||
editorRef.current.focus();
|
||||
editorRef.current.dispatch({
|
||||
selection: { anchor: 0, head: editorRef.current.state.doc.length },
|
||||
});
|
||||
},
|
||||
}),
|
||||
[],
|
||||
const renderEditor = useCallback(
|
||||
(editorProps: CodeEditorProps) => (
|
||||
<Editor
|
||||
{...editorProps}
|
||||
language={language}
|
||||
autocomplete={autocomplete}
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
extraExtensions={extraExtensions}
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
/>
|
||||
),
|
||||
[
|
||||
language,
|
||||
autocomplete,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
extraExtensions,
|
||||
forcedEnvironmentId,
|
||||
],
|
||||
);
|
||||
|
||||
// Replace the whole value with a picked option. Dispatching runs it through the editor's
|
||||
// onChange, same as typing, so callers see one consistent change path.
|
||||
const insertOption = useCallback((value: string) => {
|
||||
const view = editorRef.current;
|
||||
if (view == null) return;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
|
||||
view.focus();
|
||||
}, []);
|
||||
|
||||
const pickerOptions = showOptionsPicker && !disabled ? (props.autocomplete?.options ?? []) : [];
|
||||
|
||||
const setEditorRef = useCallback(
|
||||
(h: EditorView | null) => {
|
||||
editorRef.current = h;
|
||||
setRef?.(handle);
|
||||
},
|
||||
[handle, setRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
skipNextFocus.current = true;
|
||||
};
|
||||
window.addEventListener("focus", fn);
|
||||
return () => {
|
||||
window.removeEventListener("focus", fn);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
|
||||
if (!skipNextFocus.current) {
|
||||
editorRef.current?.dispatch({
|
||||
selection: { anchor: 0, head: editorRef.current.state.doc.length },
|
||||
});
|
||||
}
|
||||
|
||||
setFocused(true);
|
||||
onFocus?.();
|
||||
skipNextFocus.current = false;
|
||||
}, [onFocus, readOnly]);
|
||||
|
||||
const handleBlur = useCallback(async () => {
|
||||
setFocused(false);
|
||||
// Move selection to the end on blur
|
||||
const anchor = editorRef.current?.state.doc.length ?? 0;
|
||||
editorRef.current?.dispatch({
|
||||
selection: { anchor, head: anchor },
|
||||
});
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
const editorClassName = classNames(
|
||||
className,
|
||||
"bg-transparent! min-w-0 h-auto w-full focus:outline-hidden placeholder:text-placeholder",
|
||||
);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (required && !validateRequire(defaultValue ?? "")) return false;
|
||||
if (typeof validate === "boolean") return validate;
|
||||
if (typeof validate === "function" && !validate(defaultValue ?? "")) return false;
|
||||
return true;
|
||||
}, [required, defaultValue, validate]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange?.(value);
|
||||
setHasChanged(true);
|
||||
},
|
||||
[onChange, setHasChanged],
|
||||
);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Submit the nearest form on Enter key press
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter") return;
|
||||
|
||||
const form = wrapperRef.current?.closest("form");
|
||||
if (!isValid || form == null) return;
|
||||
|
||||
form?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }));
|
||||
},
|
||||
[isValid],
|
||||
);
|
||||
const pickerOptions = showOptionsPicker && !props.disabled ? (autocomplete?.options ?? []) : [];
|
||||
const renderOptionsPicker =
|
||||
pickerOptions.length > 0
|
||||
? (insert: (value: string) => void) => (
|
||||
<OptionsPickerDropdown options={pickerOptions} onSelect={insert} />
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={classNames(
|
||||
"pointer-events-auto", // Just in case we're placing in disabled parent
|
||||
"w-full",
|
||||
fullHeight && "h-full",
|
||||
labelPosition === "left" && "flex items-center gap-2",
|
||||
labelPosition === "top" && "flex-row gap-0.5",
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
help={help}
|
||||
required={required}
|
||||
visuallyHidden={hideLabel}
|
||||
className={classNames(labelClassName)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<HStack
|
||||
alignItems="stretch"
|
||||
className={classNames(
|
||||
containerClassName,
|
||||
fullHeight && "h-full",
|
||||
"x-theme-input",
|
||||
"relative w-full rounded-md text overflow-hidden",
|
||||
"border",
|
||||
focused && !disabled ? "border-border-focus" : "border-border",
|
||||
disabled && "border-dotted",
|
||||
!isValid && hasChanged && "border-danger!",
|
||||
size === "md" && "min-h-md",
|
||||
size === "sm" && "min-h-sm",
|
||||
size === "xs" && "min-h-xs",
|
||||
size === "2xs" && "min-h-2xs",
|
||||
)}
|
||||
>
|
||||
{tint != null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"absolute inset-0 opacity-5 pointer-events-none",
|
||||
tint === "primary" && "bg-primary",
|
||||
tint === "secondary" && "bg-secondary",
|
||||
tint === "info" && "bg-info",
|
||||
tint === "success" && "bg-success",
|
||||
tint === "notice" && "bg-notice",
|
||||
tint === "warning" && "bg-warning",
|
||||
tint === "danger" && "bg-danger",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{leftSlot}
|
||||
<HStack
|
||||
className={classNames(
|
||||
inputWrapperClassName,
|
||||
"flex-1 min-w-0 px-2",
|
||||
fullHeight && "h-full",
|
||||
leftSlot ? "pl-0" : null,
|
||||
rightSlot ? "pr-0" : null,
|
||||
)}
|
||||
>
|
||||
<Editor
|
||||
setRef={setEditorRef}
|
||||
id={id.current}
|
||||
hideGutter
|
||||
singleLine={!multiLine}
|
||||
containerOnly
|
||||
stateKey={stateKey}
|
||||
wrapLines={wrapLines}
|
||||
heightMode="auto"
|
||||
onKeyDown={handleKeyDown}
|
||||
type={type === "password" && !obscured ? "text" : type}
|
||||
defaultValue={defaultValue}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
onPaste={onPaste}
|
||||
onPasteOverwrite={onPasteOverwrite}
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
editorClassName,
|
||||
multiLine && size === "md" && "py-1.5",
|
||||
multiLine && size === "sm" && "py-1",
|
||||
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
readOnly={readOnly}
|
||||
{...props}
|
||||
/>
|
||||
</HStack>
|
||||
{type === "password" && !disableObscureToggle && (
|
||||
<IconButton
|
||||
title={
|
||||
obscured
|
||||
? `Show ${typeof label === "string" ? label : "field"}`
|
||||
: `Obscure ${typeof label === "string" ? label : "field"}`
|
||||
}
|
||||
size="xs"
|
||||
className={classNames("mr-0.5 h-auto! my-0.5", disabled && "opacity-disabled")}
|
||||
color={tint}
|
||||
// iconClassName={classNames(
|
||||
// tint === 'primary' && 'text-primary',
|
||||
// tint === 'secondary' && 'text-secondary',
|
||||
// tint === 'info' && 'text-info',
|
||||
// tint === 'success' && 'text-success',
|
||||
// tint === 'notice' && 'text-notice',
|
||||
// tint === 'warning' && 'text-warning',
|
||||
// tint === 'danger' && 'text-danger',
|
||||
// )}
|
||||
iconSize="sm"
|
||||
icon={obscured ? "eye" : "eye_closed"}
|
||||
onClick={() => setObscured((o) => !o)}
|
||||
/>
|
||||
)}
|
||||
{pickerOptions.length > 0 && (
|
||||
<OptionsPickerDropdown options={pickerOptions} onSelect={insertOption} />
|
||||
)}
|
||||
{rightSlot}
|
||||
</HStack>
|
||||
</div>
|
||||
<BaseUiInput {...props} renderEditor={renderEditor} renderOptionsPicker={renderOptionsPicker} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -419,10 +149,6 @@ function OptionsPickerDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function validateRequire(v: string) {
|
||||
return v.length > 0;
|
||||
}
|
||||
|
||||
type PasswordFieldType = "text" | "encrypted";
|
||||
|
||||
function EncryptionInput({
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
depth?: number;
|
||||
// oxlint-disable-next-line no-explicit-any -- none
|
||||
attrValue: any;
|
||||
attrKey?: string | number;
|
||||
attrKeyJsonPath?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const JsonAttributeTree = ({
|
||||
depth = 0,
|
||||
attrKey,
|
||||
attrValue,
|
||||
attrKeyJsonPath,
|
||||
className,
|
||||
}: Props) => {
|
||||
attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const toggleExpanded = () => setIsExpanded((v) => !v);
|
||||
|
||||
const { isExpandable, children, label, labelClassName } = useMemo<{
|
||||
isExpandable: boolean;
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
labelClassName?: string;
|
||||
}>(() => {
|
||||
const jsonType = Object.prototype.toString.call(attrValue);
|
||||
if (jsonType === "[object Object]") {
|
||||
return {
|
||||
children: isExpanded
|
||||
? Object.keys(attrValue)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.flatMap((k) => (
|
||||
<JsonAttributeTree
|
||||
key={k}
|
||||
depth={depth + 1}
|
||||
attrValue={attrValue[k]}
|
||||
attrKey={k}
|
||||
attrKeyJsonPath={joinObjectKey(attrKeyJsonPath, k)}
|
||||
/>
|
||||
))
|
||||
: null,
|
||||
isExpandable: Object.keys(attrValue).length > 0,
|
||||
label: isExpanded ? `{${Object.keys(attrValue).length || " "}}` : "{⋯}",
|
||||
labelClassName: "text-text-subtlest",
|
||||
};
|
||||
}
|
||||
if (jsonType === "[object Array]") {
|
||||
return {
|
||||
children: isExpanded
|
||||
? // oxlint-disable-next-line no-explicit-any -- none
|
||||
attrValue.flatMap((v: any, i: number) => (
|
||||
<JsonAttributeTree
|
||||
// oxlint-disable-next-line no-array-index-key -- none
|
||||
key={i}
|
||||
depth={depth + 1}
|
||||
attrValue={v}
|
||||
attrKey={i}
|
||||
attrKeyJsonPath={joinArrayKey(attrKeyJsonPath, i)}
|
||||
/>
|
||||
))
|
||||
: null,
|
||||
isExpandable: attrValue.length > 0,
|
||||
label: isExpanded ? `[${attrValue.length || " "}]` : "[⋯]",
|
||||
labelClassName: "text-text-subtlest",
|
||||
};
|
||||
}
|
||||
return {
|
||||
children: null,
|
||||
isExpandable: false,
|
||||
label: jsonType === "[object String]" ? `"${attrValue}"` : `${attrValue}`,
|
||||
labelClassName: classNames(
|
||||
jsonType === "[object Boolean]" && "text-primary",
|
||||
jsonType === "[object Number]" && "text-info",
|
||||
jsonType === "[object String]" && "text-notice",
|
||||
jsonType === "[object Null]" && "text-danger",
|
||||
),
|
||||
};
|
||||
}, [attrValue, attrKeyJsonPath, isExpanded, depth]);
|
||||
|
||||
const labelEl = (
|
||||
<span
|
||||
className={classNames(labelClassName, "cursor-text select-text group-hover:text-text-subtle")}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
/*depth === 0 && '-ml-4',*/ "font-mono text-xs",
|
||||
depth === 0 && "h-full overflow-y-auto pb-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{isExpandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="group relative flex items-center pl-4 w-full"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
<Icon
|
||||
size="xs"
|
||||
icon="chevron_right"
|
||||
className={classNames(
|
||||
"left-0 absolute transition-transform flex items-center",
|
||||
"group-hover:text-text-subtle",
|
||||
isExpanded ? "rotate-90" : "",
|
||||
)}
|
||||
/>
|
||||
<span className="text-primary group-hover:text-primary mr-1.5 whitespace-nowrap">
|
||||
{attrKey === undefined ? "$" : attrKey}:
|
||||
</span>
|
||||
{labelEl}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-primary mr-1.5 pl-4 whitespace-nowrap cursor-text select-text">
|
||||
{attrKey}:
|
||||
</span>
|
||||
{labelEl}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{children && <div className="ml-4 whitespace-nowrap">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function joinObjectKey(baseKey: string | undefined, key: string): string {
|
||||
const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\`${key}\``;
|
||||
|
||||
if (baseKey == null) return quotedKey;
|
||||
return `${baseKey}.${quotedKey}`;
|
||||
}
|
||||
|
||||
function joinArrayKey(baseKey: string | undefined, index: number): string {
|
||||
return `${baseKey ?? ""}[${index}]`;
|
||||
}
|
||||
@@ -1,106 +1,10 @@
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { KeyValueRow as BaseKeyValueRow, type KeyValueRowProps } from "@yaakapp-internal/ui";
|
||||
import { copyToClipboard } from "../../lib/copy";
|
||||
|
||||
interface Props {
|
||||
children:
|
||||
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
||||
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
||||
selectable?: boolean;
|
||||
export function KeyValueRow(props: Omit<KeyValueRowProps, "copy">) {
|
||||
return <BaseKeyValueRow {...props} copy={copy} />;
|
||||
}
|
||||
|
||||
export function KeyValueRows({ children, selectable }: Props) {
|
||||
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
||||
return (
|
||||
<table
|
||||
className={classNames(
|
||||
"text-editor font-mono min-w-0 w-full mb-auto",
|
||||
selectable &&
|
||||
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
|
||||
)}
|
||||
>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{childArray.map((child, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<tr key={i}>{child}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeyValueRowProps {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
leftSlot?: ReactNode;
|
||||
align?: "top" | "middle";
|
||||
labelClassName?: string;
|
||||
labelColor?: "secondary" | "primary" | "info";
|
||||
enableCopy?: boolean;
|
||||
copyText?: string;
|
||||
}
|
||||
|
||||
export function KeyValueRow({
|
||||
label,
|
||||
children,
|
||||
rightSlot,
|
||||
leftSlot,
|
||||
align = "top",
|
||||
labelColor = "secondary",
|
||||
labelClassName,
|
||||
enableCopy,
|
||||
copyText,
|
||||
}: KeyValueRowProps) {
|
||||
const textToCopy =
|
||||
copyText ??
|
||||
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
|
||||
const copyTitle =
|
||||
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
|
||||
const resolvedRightSlot =
|
||||
rightSlot ??
|
||||
(enableCopy && textToCopy != null ? (
|
||||
<CopyIconButton
|
||||
text={textToCopy}
|
||||
className="text-text-subtle"
|
||||
size="2xs"
|
||||
title={copyTitle}
|
||||
iconSize="sm"
|
||||
/>
|
||||
) : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
className={classNames(
|
||||
"select-none py-0.5 pr-2 h-full max-w-40",
|
||||
align === "top" && "align-top",
|
||||
align === "middle" && "align-middle",
|
||||
labelClassName,
|
||||
labelColor === "primary" && "text-primary",
|
||||
labelColor === "secondary" && "text-text-subtle",
|
||||
labelColor === "info" && "text-info",
|
||||
)}
|
||||
>
|
||||
<span className="select-text cursor-text">{label}</span>
|
||||
</td>
|
||||
<td
|
||||
className={classNames(
|
||||
"select-none py-0.5 break-all max-w-60",
|
||||
align === "top" && "align-top",
|
||||
align === "middle" && "align-middle",
|
||||
)}
|
||||
>
|
||||
<div className="select-text cursor-text max-h-48 overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
|
||||
{leftSlot ?? <span aria-hidden />}
|
||||
{children}
|
||||
{resolvedRightSlot ? (
|
||||
<div className="ml-1.5">{resolvedRightSlot}</div>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
function copy(text: string) {
|
||||
copyToClipboard(text, { disableToast: true });
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
|
||||
export function Label({
|
||||
htmlFor,
|
||||
className,
|
||||
children,
|
||||
visuallyHidden,
|
||||
tags = [],
|
||||
required,
|
||||
rightSlot,
|
||||
help,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLLabelElement> & {
|
||||
htmlFor: string | null;
|
||||
required?: boolean;
|
||||
tags?: string[];
|
||||
visuallyHidden?: boolean;
|
||||
rightSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
help?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor ?? undefined}
|
||||
className={classNames(
|
||||
className,
|
||||
visuallyHidden && "sr-only",
|
||||
"shrink-0 text-sm",
|
||||
"text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span>
|
||||
{children}
|
||||
{required === true && <span className="text-text-subtlest">*</span>}
|
||||
</span>
|
||||
{tags.map((tag, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<span key={i} className="text-xs text-text-subtlest">
|
||||
({tag})
|
||||
</span>
|
||||
))}
|
||||
{help && <IconTooltip tabIndex={-1} content={help} />}
|
||||
{rightSlot && <div className="ml-auto">{rightSlot}</div>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,8 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
|
||||
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
|
||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||
import { useToggle } from "../../hooks/useToggle";
|
||||
import { languageFromContentType } from "../../lib/contentType";
|
||||
import { showDialog } from "../../lib/dialog";
|
||||
import { computeSideForDragMove, DropMarker } from "@yaakapp-internal/ui";
|
||||
import { computeSideForDragMove, DropMarker, languageFromContentType } from "@yaakapp-internal/ui";
|
||||
import { showPrompt } from "../../lib/prompt";
|
||||
import { SelectFile } from "../SelectFile";
|
||||
import { Button } from "./Button";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import type { Pair, PairWithId } from "./PairEditor";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
|
||||
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
|
||||
import {
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { IconButton } from "./IconButton";
|
||||
import type { InputProps } from "./Input";
|
||||
import { Label } from "./Label";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
export type PlainInputProps = Omit<
|
||||
InputProps,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
|
||||
import type { FormEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { DynamicForm } from "../DynamicForm";
|
||||
import { DialogFooter } from "./Dialog";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
export interface PromptProps {
|
||||
inputs: FormInput[];
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { IconProps } from "@yaakapp-internal/ui";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
import { Label } from "./Label";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
interface Props<T extends string> {
|
||||
options: { value: T; label: string; icon?: IconProps["icon"] }[];
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { Button } from "./Button";
|
||||
import { Label } from "./Label";
|
||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
||||
import { RadioDropdown } from "./RadioDropdown";
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
contentLength: number;
|
||||
contentLengthCompressed?: number | null;
|
||||
}
|
||||
|
||||
export function SizeTag({ className, contentLength, contentLengthCompressed }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={classNames("font-mono", className)}
|
||||
title={
|
||||
`${contentLength} bytes` +
|
||||
(contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : "")
|
||||
}
|
||||
>
|
||||
{formatSize(contentLength)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,27 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
Icon,
|
||||
type StoredValue,
|
||||
TabContent as BaseTabContent,
|
||||
type TabContentProps,
|
||||
type TabItem as BaseTabItem,
|
||||
Tabs as BaseTabs,
|
||||
type TabsProps as BaseTabsProps,
|
||||
type TabsRef,
|
||||
type TabsStorage,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { forwardRef, memo, useMemo } from "react";
|
||||
import { useKeyValue } from "../../../hooks/useKeyValue";
|
||||
import { computeSideForDragMove, DropMarker } from "@yaakapp-internal/ui";
|
||||
import { fireAndForget } from "../../../lib/fireAndForget";
|
||||
import { ErrorBoundary } from "../../ErrorBoundary";
|
||||
import type { ButtonProps } from "../Button";
|
||||
import { Button } from "../Button";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import type { RadioDropdownProps } from "../RadioDropdown";
|
||||
import { RadioDropdown } from "../RadioDropdown";
|
||||
|
||||
export type { TabsRef };
|
||||
|
||||
export type TabItem =
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
hidden?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
| Extract<BaseTabItem, { label: string }>
|
||||
| {
|
||||
value: string;
|
||||
options: Omit<RadioDropdownProps, "children">;
|
||||
@@ -46,445 +29,34 @@ export type TabItem =
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
interface TabsStorage {
|
||||
order: string[];
|
||||
activeTabs: Record<string, string>;
|
||||
}
|
||||
type Props = Omit<BaseTabsProps, "tabs" | "useStoredValue"> & { tabs: TabItem[] };
|
||||
|
||||
export interface TabsRef {
|
||||
/** Programmatically set the active tab */
|
||||
setActiveTab: (value: string) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Default tab value. If not provided, defaults to first tab. */
|
||||
defaultValue?: string;
|
||||
/** Called when active tab changes */
|
||||
onChangeValue?: (value: string) => void;
|
||||
tabs: TabItem[];
|
||||
tabListClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
addBorders?: boolean;
|
||||
layout?: "horizontal" | "vertical";
|
||||
/** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */
|
||||
storageKey?: string | string[];
|
||||
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
|
||||
activeTabKey?: string;
|
||||
}
|
||||
|
||||
export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
{
|
||||
defaultValue,
|
||||
onChangeValue: onChangeValueProp,
|
||||
label,
|
||||
children,
|
||||
tabs: originalTabs,
|
||||
className,
|
||||
tabListClassName,
|
||||
addBorders,
|
||||
layout = "vertical",
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
}: Props,
|
||||
forwardedRef: Ref<TabsRef>,
|
||||
) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const reorderable = !!storageKey;
|
||||
|
||||
// Use key-value storage for persistence if storageKey is provided
|
||||
// Handle migration from old format (string[]) to new format (TabsStorage)
|
||||
const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({
|
||||
namespace: "no_sync",
|
||||
key: storageKey ?? ["tabs", "default"],
|
||||
fallback: { order: [], activeTabs: {} },
|
||||
});
|
||||
|
||||
// Migrate old format (string[]) to new format (TabsStorage)
|
||||
const storage: TabsStorage = Array.isArray(rawStorage)
|
||||
? { order: rawStorage, activeTabs: {} }
|
||||
: (rawStorage ?? { order: [], activeTabs: {} });
|
||||
|
||||
const savedOrder = storage.order;
|
||||
|
||||
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
|
||||
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
|
||||
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
|
||||
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
||||
|
||||
// Helper to normalize storage (handle migration from old format)
|
||||
const normalizeStorage = useCallback(
|
||||
(s: TabsStorage | string[]): TabsStorage =>
|
||||
Array.isArray(s) ? { order: s, activeTabs: {} } : s,
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle tab change - update internal state, storage if we have a key, and call prop callback
|
||||
const onChangeValue = useCallback(
|
||||
async (newValue: string) => {
|
||||
setInternalValue(newValue);
|
||||
if (storageKey && activeTabKey) {
|
||||
await setStorage((s) => {
|
||||
const normalized = normalizeStorage(s);
|
||||
return {
|
||||
...normalized,
|
||||
activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue },
|
||||
};
|
||||
});
|
||||
}
|
||||
onChangeValueProp?.(newValue);
|
||||
},
|
||||
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
|
||||
);
|
||||
|
||||
// Expose imperative methods via ref
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
setActiveTab: (value: string) => {
|
||||
fireAndForget(onChangeValue(value));
|
||||
},
|
||||
}),
|
||||
[onChangeValue],
|
||||
);
|
||||
|
||||
// Helper to save order
|
||||
const setSavedOrder = useCallback(
|
||||
async (order: string[]) => {
|
||||
await setStorage((s) => {
|
||||
const normalized = normalizeStorage(s);
|
||||
return { ...normalized, order };
|
||||
});
|
||||
},
|
||||
[setStorage, normalizeStorage],
|
||||
);
|
||||
|
||||
// State for ordered tabs
|
||||
const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);
|
||||
const [isDragging, setIsDragging] = useState<TabItem | null>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
// Reorder tabs based on saved order when tabs or savedOrder changes
|
||||
useEffect(() => {
|
||||
if (!storageKey || savedOrder == null || savedOrder.length === 0) {
|
||||
setOrderedTabs(originalTabs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a map of tab values to tab items
|
||||
const tabMap = new Map(originalTabs.map((tab) => [tab.value, tab]));
|
||||
|
||||
// Reorder based on saved order, adding any new tabs at the end
|
||||
const reordered: TabItem[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
// Add tabs in saved order
|
||||
for (const value of savedOrder) {
|
||||
const tab = tabMap.get(value);
|
||||
if (tab) {
|
||||
reordered.push(tab);
|
||||
seenValues.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new tabs that weren't in the saved order
|
||||
for (const tab of originalTabs) {
|
||||
if (!seenValues.has(tab.value)) {
|
||||
reordered.push(tab);
|
||||
}
|
||||
}
|
||||
|
||||
setOrderedTabs(reordered);
|
||||
}, [originalTabs, savedOrder, storageKey]);
|
||||
|
||||
const tabs = storageKey ? orderedTabs : originalTabs;
|
||||
|
||||
// Update tabs when value changes
|
||||
useEffect(() => {
|
||||
const tabs = ref.current?.querySelectorAll<HTMLDivElement>("[data-tab]");
|
||||
for (const tab of tabs ?? []) {
|
||||
const v = tab.getAttribute("data-tab");
|
||||
const parent = tab.closest(".tabs-container");
|
||||
if (parent !== ref.current) {
|
||||
// Tab is part of a nested tab container, so ignore it
|
||||
} else if (v === value) {
|
||||
tab.setAttribute("data-state", "active");
|
||||
tab.setAttribute("aria-hidden", "false");
|
||||
tab.style.display = "block";
|
||||
} else {
|
||||
tab.setAttribute("data-state", "inactive");
|
||||
tab.setAttribute("aria-hidden", "true");
|
||||
tab.style.display = "none";
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Drag and drop handlers
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(e: DragStartEvent) => {
|
||||
const tab = tabs.find((t) => t.value === e.active.id);
|
||||
setIsDragging(tab ?? null);
|
||||
},
|
||||
[tabs],
|
||||
);
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(e: DragMoveEvent) => {
|
||||
const overId = e.over?.id as string | undefined;
|
||||
if (!overId) return setHoveredIndex(null);
|
||||
|
||||
const overTab = tabs.find((t) => t.value === overId);
|
||||
if (overTab == null) return setHoveredIndex(null);
|
||||
|
||||
// For vertical layout, tabs are arranged horizontally (side-by-side)
|
||||
const orientation = layout === "vertical" ? "horizontal" : "vertical";
|
||||
const side = computeSideForDragMove(overTab.value, e, orientation);
|
||||
|
||||
// If computeSideForDragMove returns null (shouldn't happen but be safe), default to null
|
||||
if (side === null) return setHoveredIndex(null);
|
||||
|
||||
const overIndex = tabs.findIndex((t) => t.value === overId);
|
||||
const hoveredIndex = overIndex + (side === "before" ? 0 : 1);
|
||||
|
||||
setHoveredIndex(hoveredIndex);
|
||||
},
|
||||
[tabs, layout],
|
||||
);
|
||||
|
||||
const onDragCancel = useCallback(() => {
|
||||
setIsDragging(null);
|
||||
setHoveredIndex(null);
|
||||
}, []);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
setIsDragging(null);
|
||||
setHoveredIndex(null);
|
||||
|
||||
const activeId = e.active.id as string | undefined;
|
||||
const overId = e.over?.id as string | undefined;
|
||||
if (!activeId || !overId || activeId === overId) return;
|
||||
|
||||
const from = tabs.findIndex((t) => t.value === activeId);
|
||||
const baseTo = tabs.findIndex((t) => t.value === overId);
|
||||
const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
|
||||
|
||||
if (from !== -1 && to !== -1 && from !== to) {
|
||||
const newTabs = [...tabs];
|
||||
const [moved] = newTabs.splice(from, 1);
|
||||
if (moved === undefined) return;
|
||||
newTabs.splice(to > from ? to - 1 : to, 0, moved);
|
||||
|
||||
setOrderedTabs(newTabs);
|
||||
|
||||
// Save order to storage
|
||||
setSavedOrder(newTabs.map((t) => t.value)).catch(console.error);
|
||||
}
|
||||
},
|
||||
[tabs, hoveredIndex, setSavedOrder],
|
||||
);
|
||||
|
||||
const tabButtons = useMemo(() => {
|
||||
const items: ReactNode[] = [];
|
||||
tabs.forEach((t, i) => {
|
||||
if ("hidden" in t && t.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = t.value === value;
|
||||
const showDropMarkerBefore = hoveredIndex === i;
|
||||
|
||||
if (showDropMarkerBefore) {
|
||||
items.push(
|
||||
<div
|
||||
key={`marker-${t.value}`}
|
||||
className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}
|
||||
>
|
||||
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
<TabButton
|
||||
key={t.value}
|
||||
tab={t}
|
||||
isActive={isActive}
|
||||
addBorders={addBorders}
|
||||
layout={layout}
|
||||
reorderable={reorderable}
|
||||
isDragging={isDragging?.value === t.value}
|
||||
onChangeValue={onChangeValue}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return items;
|
||||
}, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]);
|
||||
|
||||
const tabList = (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className={classNames(
|
||||
tabListClassName,
|
||||
addBorders && layout === "horizontal" && "pl-3 -ml-1",
|
||||
addBorders && layout === "vertical" && "ml-0 mb-2",
|
||||
"flex items-center hide-scrollbars",
|
||||
layout === "horizontal" && "h-full overflow-auto p-2",
|
||||
layout === "vertical" && "overflow-x-auto overflow-y-visible ",
|
||||
// Give space for button focus states within overflow boundary.
|
||||
!addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
layout === "horizontal" && "flex flex-col w-full pb-3 mb-auto",
|
||||
layout === "vertical" && "flex flex-row shrink-0 w-full",
|
||||
)}
|
||||
>
|
||||
{tabButtons}
|
||||
{hoveredIndex === tabs.length && (
|
||||
<div className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}>
|
||||
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
className,
|
||||
"tabs-container",
|
||||
"h-full grid",
|
||||
layout === "horizontal" && "grid-rows-1 grid-cols-[auto_minmax(0,1fr)]",
|
||||
layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{reorderable ? (
|
||||
<DndContext
|
||||
autoScroll
|
||||
sensors={sensors}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
{tabList}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{isDragging && (
|
||||
<TabButton
|
||||
tab={isDragging}
|
||||
isActive={isDragging.value === value}
|
||||
addBorders={addBorders}
|
||||
layout={layout}
|
||||
reorderable={false}
|
||||
isDragging={false}
|
||||
onChangeValue={onChangeValue}
|
||||
overlay
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
) : (
|
||||
tabList
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export const Tabs = forwardRef<TabsRef, Props>(function Tabs({ tabs, ...props }, ref) {
|
||||
const baseTabs = useMemo(() => tabs.map(toBaseTab), [tabs]);
|
||||
return <BaseTabs ref={ref} tabs={baseTabs} useStoredValue={useTabsStoredValue} {...props} />;
|
||||
});
|
||||
|
||||
interface TabButtonProps {
|
||||
tab: TabItem;
|
||||
isActive: boolean;
|
||||
addBorders?: boolean;
|
||||
layout: "horizontal" | "vertical";
|
||||
reorderable: boolean;
|
||||
isDragging: boolean;
|
||||
onChangeValue?: (value: string) => void;
|
||||
overlay?: boolean;
|
||||
}
|
||||
export const TabContent = memo(function TabContent(props: Omit<TabContentProps, "ErrorBoundary">) {
|
||||
return <BaseTabContent {...props} ErrorBoundary={ErrorBoundary} />;
|
||||
});
|
||||
|
||||
function TabButton({
|
||||
tab,
|
||||
isActive,
|
||||
addBorders,
|
||||
layout,
|
||||
reorderable,
|
||||
isDragging,
|
||||
onChangeValue,
|
||||
overlay = false,
|
||||
}: TabButtonProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
} = useDraggable({
|
||||
id: tab.value,
|
||||
disabled: !reorderable,
|
||||
// The button inside handles focus
|
||||
attributes: { tabIndex: -1 },
|
||||
});
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({
|
||||
id: tab.value,
|
||||
disabled: !reorderable,
|
||||
});
|
||||
|
||||
const handleSetWrapperRef = useCallback(
|
||||
(n: HTMLDivElement | null) => {
|
||||
if (reorderable) {
|
||||
setDraggableRef(n);
|
||||
setDroppableRef(n);
|
||||
}
|
||||
},
|
||||
[reorderable, setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
const btnProps: Partial<ButtonProps> = {
|
||||
color: "custom",
|
||||
justify: layout === "horizontal" ? "start" : "center",
|
||||
onClick: isActive
|
||||
? undefined
|
||||
: (e: React.MouseEvent) => {
|
||||
e.preventDefault(); // Prevent dropdown from opening on first click
|
||||
onChangeValue?.(tab.value);
|
||||
},
|
||||
className: classNames(
|
||||
"flex items-center rounded-sm whitespace-nowrap",
|
||||
"px-2! ml-px",
|
||||
"outline-hidden",
|
||||
"ring-none",
|
||||
"focus-visible-or-class:outline-2",
|
||||
addBorders && "border focus-visible:bg-surface-highlight",
|
||||
isActive ? "text-text" : "text-text-subtle",
|
||||
isActive && addBorders
|
||||
? "border-surface-active bg-surface-active"
|
||||
: layout === "vertical"
|
||||
? "border-border-subtle"
|
||||
: "border-transparent",
|
||||
layout === "horizontal" && "min-w-40",
|
||||
isDragging && "opacity-50",
|
||||
overlay && "opacity-80",
|
||||
),
|
||||
};
|
||||
|
||||
const buttonContent = (() => {
|
||||
if ("options" in tab) {
|
||||
const option = tab.options.items.find((i) => "value" in i && i.value === tab.options.value);
|
||||
function toBaseTab(tab: TabItem): BaseTabItem {
|
||||
if (!("options" in tab)) return tab;
|
||||
const { options } = tab;
|
||||
return {
|
||||
value: tab.value,
|
||||
leftSlot: tab.leftSlot,
|
||||
rightSlot: tab.rightSlot,
|
||||
renderButton: ({ buttonProps, isActive }) => {
|
||||
const option = options.items.find((i) => "value" in i && i.value === options.value);
|
||||
return (
|
||||
<RadioDropdown
|
||||
key={tab.value}
|
||||
items={tab.options.items}
|
||||
itemsAfter={tab.options.itemsAfter}
|
||||
itemsBefore={tab.options.itemsBefore}
|
||||
value={tab.options.value}
|
||||
onChange={tab.options.onChange}
|
||||
items={options.items}
|
||||
itemsAfter={options.itemsAfter}
|
||||
itemsBefore={options.itemsBefore}
|
||||
value={options.value}
|
||||
onChange={options.onChange}
|
||||
>
|
||||
<Button
|
||||
leftSlot={tab.leftSlot}
|
||||
@@ -501,7 +73,7 @@ function TabButton({
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{...btnProps}
|
||||
{...buttonProps}
|
||||
>
|
||||
{option && "shortLabel" in option && option.shortLabel
|
||||
? option.shortLabel
|
||||
@@ -509,52 +81,19 @@ function TabButton({
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
|
||||
{"label" in tab && tab.label ? tab.label : tab.value}
|
||||
</Button>
|
||||
);
|
||||
})();
|
||||
|
||||
// Apply drag handlers to wrapper, not button
|
||||
const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={handleSetWrapperRef}
|
||||
className={classNames("relative", layout === "vertical" && "mr-2")}
|
||||
{...wrapperProps}
|
||||
>
|
||||
{buttonContent}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface TabContentProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
function useTabsStoredValue<T extends StoredValue>(key: string | string[] | null, fallback: T) {
|
||||
const { value, set } = useKeyValue<T>({
|
||||
namespace: "no_sync",
|
||||
key: key ?? ["tabs", "default"],
|
||||
fallback,
|
||||
});
|
||||
return [value ?? fallback, set] as const;
|
||||
}
|
||||
|
||||
export const TabContent = memo(function TabContent({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
}: TabContentProps) {
|
||||
return (
|
||||
<ErrorBoundary name={`Tab ${value}`}>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
data-tab={value}
|
||||
className={classNames(className, "tab-content", "hidden w-full h-full pt-2")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.
|
||||
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { Portal } from "@yaakapp-internal/ui";
|
||||
|
||||
export interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
tabIndex?: number;
|
||||
size?: "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const hiddenStyles: CSSProperties = {
|
||||
left: -99999,
|
||||
top: -99999,
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
type TooltipPosition = "top" | "bottom";
|
||||
|
||||
interface TooltipOpenState {
|
||||
styles: CSSProperties;
|
||||
position: TooltipPosition;
|
||||
}
|
||||
|
||||
export function Tooltip({ children, className, content, tabIndex, size = "md" }: TooltipProps) {
|
||||
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const handleOpenImmediate = () => {
|
||||
if (triggerRef.current == null || tooltipRef.current == null) return;
|
||||
clearTimeout(showTimeout.current);
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
|
||||
const margin = 8;
|
||||
const spaceAbove = Math.max(0, triggerRect.top - margin);
|
||||
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);
|
||||
const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;
|
||||
const position: TooltipPosition = preferBottom ? "bottom" : "top";
|
||||
|
||||
const styles: CSSProperties = {
|
||||
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
|
||||
maxHeight: position === "top" ? spaceAbove : spaceBelow,
|
||||
...(position === "top"
|
||||
? { bottom: viewportHeight - triggerRect.top }
|
||||
: { top: triggerRect.bottom }),
|
||||
};
|
||||
|
||||
setOpenState({ styles, position });
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
clearTimeout(showTimeout.current);
|
||||
showTimeout.current = setTimeout(handleOpenImmediate, 500);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
clearTimeout(showTimeout.current);
|
||||
setOpenState(null);
|
||||
};
|
||||
|
||||
const handleToggleImmediate = () => {
|
||||
if (openState) handleClose();
|
||||
else handleOpenImmediate();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (openState && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const id = useRef(`tooltip-${generateId()}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Portal name="tooltip">
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={openState?.styles ?? hiddenStyles}
|
||||
id={id.current}
|
||||
role="tooltip"
|
||||
aria-hidden={openState == null}
|
||||
onMouseEnter={handleOpenImmediate}
|
||||
onMouseLeave={handleClose}
|
||||
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto",
|
||||
size === "md" && "max-w-sm",
|
||||
size === "lg" && "max-w-md",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<Triangle
|
||||
className="text-border"
|
||||
position={openState?.position === "bottom" ? "top" : "bottom"}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
{/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons */}
|
||||
<span
|
||||
ref={triggerRef}
|
||||
role="button"
|
||||
aria-describedby={openState ? id.current : undefined}
|
||||
tabIndex={tabIndex ?? -1}
|
||||
className={classNames(className, "grow-0 flex items-center")}
|
||||
onClick={handleToggleImmediate}
|
||||
onMouseEnter={handleOpen}
|
||||
onMouseLeave={handleClose}
|
||||
onFocus={handleOpenImmediate}
|
||||
onBlur={handleClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Triangle({ className, position }: { className?: string; position: "top" | "bottom" }) {
|
||||
const isBottom = position === "bottom";
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 30 10"
|
||||
preserveAspectRatio="none"
|
||||
shapeRendering="crispEdges"
|
||||
className={classNames(
|
||||
className,
|
||||
"absolute z-50 left-[calc(50%-0.4rem)] h-2 w-[0.8rem]",
|
||||
isBottom
|
||||
? "border-t-2 border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2"
|
||||
: "border-b-2 border-surface-highlight -top-[calc(0.5rem-3px)] mt-2",
|
||||
)}
|
||||
>
|
||||
<title>Triangle</title>
|
||||
<polygon
|
||||
className="fill-surface-highlight"
|
||||
points={isBottom ? "0,0 30,0 15,10" : "0,10 30,10 15,0"}
|
||||
/>
|
||||
<path
|
||||
d={isBottom ? "M0 0 L15 9 L30 0" : "M0 10 L15 1 L30 10"}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinejoin="miter"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -24,11 +24,9 @@ import { useAtomValue } from "jotai";
|
||||
import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from "react";
|
||||
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useClickOutside } from "../../hooks/useClickOutside";
|
||||
import { Banner, useContainerSize } from "@yaakapp-internal/ui";
|
||||
import { Banner, useContainerSize, useStateWithDeps, CountBadge } from "@yaakapp-internal/ui";
|
||||
import { Icon, useDebouncedValue } from "@yaakapp-internal/ui";
|
||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
import { jotaiStore } from "../../lib/jotai";
|
||||
import { CountBadge } from "../core/CountBadge";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { Markdown } from "../Markdown";
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
import { showDialog } from "../../lib/dialog";
|
||||
import { Button } from "../core/Button";
|
||||
import type { DropdownItem } from "../core/Dropdown";
|
||||
@@ -15,7 +14,7 @@ import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||
import { RadioDropdown } from "../core/RadioDropdown";
|
||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||
import { Banner, FormattedError, Icon, useStateWithDeps } from "@yaakapp-internal/ui";
|
||||
import { Separator } from "../core/Separator";
|
||||
import { tryFormatGraphql } from "../../lib/formatters";
|
||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
/** A URL for the body the host already stored. */
|
||||
bodyUrl?: string;
|
||||
data?: Uint8Array;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyUrl) {
|
||||
setSrc(bodyUrl);
|
||||
} else if (data) {
|
||||
// The type matters here in a way it doesn't for an image: a media element goes by what
|
||||
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyUrl, data, mimeType]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <audio className="w-full" controls src={src} />;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { CsvViewerInner } from "./CsvViewer";
|
||||
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
|
||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
|
||||
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
|
||||
}));
|
||||
|
||||
describe("CsvViewer", () => {
|
||||
test("renders columns that extend beyond the first row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CsvViewerInner
|
||||
text={[
|
||||
"startDate,2026-02-03T00:00-03:00",
|
||||
"endDate,2026-02-03T23:59:59-03:00",
|
||||
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
|
||||
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
|
||||
].join("\n")}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("ID de usuario");
|
||||
expect(markup).toContain("42041");
|
||||
expect(markup.match(/<td>/g)).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import Papa from "papaparse";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
text: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CsvViewer({ text, className }: Props) {
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<CsvViewerInner text={text} className={className} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||
const parsed = useMemo(() => {
|
||||
if (text == null) return null;
|
||||
return Papa.parse<string[]>(text, { skipEmptyLines: true });
|
||||
}, [text]);
|
||||
|
||||
if (parsed === null) return null;
|
||||
|
||||
const header = parsed.data[0] ?? [];
|
||||
const rows = parsed.data.slice(1);
|
||||
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
|
||||
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<Table className={classNames(className, "text-sm")}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columnIndexes.map((columnIndex) => (
|
||||
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableRow key={i}>
|
||||
{row.map((cell, columnIndex) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableCell key={columnIndex}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
|
||||
import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, SplitLayout, VStack, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
@@ -23,7 +23,6 @@ import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { EventDetailHeader, EventViewer } from "../core/EventViewer";
|
||||
import { EventViewerRow } from "../core/EventViewerRow";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { IconTooltip } from "../core/IconTooltip";
|
||||
import { Input } from "../core/Input";
|
||||
import { Select } from "../core/Select";
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { languageFromContentType, type TextFilterApi, WebPageViewer } from "@yaakapp-internal/ui";
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import { useCallback } from "react";
|
||||
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
||||
import { useRecentFilters } from "../../hooks/useRecentFilters";
|
||||
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
|
||||
import { useResponseFilter } from "../../hooks/useResponseFilter";
|
||||
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
||||
import { languageFromContentType } from "../../lib/contentType";
|
||||
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { EmptyStateText } from "../EmptyStateText";
|
||||
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||
import { TextViewer } from "./TextViewer";
|
||||
import { WebPageViewer } from "./WebPageViewer";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
@@ -48,22 +48,36 @@ export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Prop
|
||||
interface HttpTextViewerProps {
|
||||
response: HttpResponse;
|
||||
text: string;
|
||||
language: EditorProps["language"];
|
||||
language: CodeEditorProps["language"];
|
||||
pretty: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const filter = useResponseFilter({
|
||||
stateKey: `response.body.${response.requestId}`,
|
||||
// Shares the display query's cache entry, so the verdict costs no extra RPC
|
||||
runFilter: useCallback(
|
||||
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
|
||||
[queryClient, response],
|
||||
const filterStateKey = `response.body.${response.requestId}`;
|
||||
// Shares the response body query's cache, so applying and showing a filter cost one RPC
|
||||
const filterText = useCallback(
|
||||
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
|
||||
[queryClient, response],
|
||||
);
|
||||
const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } =
|
||||
useRecentFilters(filterStateKey);
|
||||
const filterLeftSlot = useCallback(
|
||||
(filter: TextFilterApi) => (
|
||||
<div className="py-0.5 flex">
|
||||
<RecentFiltersDropdown
|
||||
recentFilters={recentFilters}
|
||||
activeFilter={filter.appliedFilter}
|
||||
onSelect={filter.replaceFilter}
|
||||
onRemove={removeFilter}
|
||||
onTogglePin={togglePin}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter });
|
||||
[recentFilters, removeFilter, togglePin, clearFilters],
|
||||
);
|
||||
const saveResponse = useSaveResponse(response);
|
||||
const copyResponse = useCopyHttpResponse(response);
|
||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||
@@ -95,12 +109,10 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
]}
|
||||
filter={filter}
|
||||
filterResult={{
|
||||
data: filteredBody.data,
|
||||
isPending: filteredBody.isPending,
|
||||
error: !!filteredBody.error,
|
||||
}}
|
||||
filterText={filterText}
|
||||
filterStateKey={filterStateKey}
|
||||
onFilterApplied={addFilter}
|
||||
filterLeftSlot={filterLeftSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = { className?: string; mimeType?: string } & (
|
||||
| {
|
||||
/** A URL for the body the host already stored. */
|
||||
bodyUrl: string;
|
||||
}
|
||||
| {
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
);
|
||||
|
||||
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
|
||||
const data = "data" in props ? props.data : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyUrl != null) {
|
||||
setSrc(bodyUrl);
|
||||
} else if (data != null) {
|
||||
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyUrl, data, mimeType]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="Response preview"
|
||||
className={classNames(className, "max-w-full max-h-full")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import classNames from "classnames";
|
||||
import { JsonAttributeTree } from "../core/JsonAttributeTree";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function JsonViewer({ text, className }: Props) {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
// Nothing yet
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(className, "overflow-x-auto h-full")}>
|
||||
<JsonAttributeTree attrValue={parsed} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +1,34 @@
|
||||
import { type MultipartPart, parseMultipart } from "@mjackson/multipart-parser";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { languageFromContentType } from "../../lib/contentType";
|
||||
import { Banner, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
||||
import { AudioViewer } from "./AudioViewer";
|
||||
import { CsvViewer } from "./CsvViewer";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { SvgViewer } from "./SvgViewer";
|
||||
import { TextViewer } from "./TextViewer";
|
||||
import { VideoViewer } from "./VideoViewer";
|
||||
import { WebPageViewer } from "./WebPageViewer";
|
||||
import {
|
||||
LoadingIcon,
|
||||
MultipartViewer as BaseMultipartViewer,
|
||||
type MultipartViewerProps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { renderEditor } from "../core/Editor/renderEditor";
|
||||
import { ErrorBoundary } from "../ErrorBoundary";
|
||||
import { formatText, textViewerExtensions } from "./TextViewer";
|
||||
|
||||
const PdfViewer = lazy(() => import("./PdfViewer").then((m) => ({ default: m.PdfViewer })));
|
||||
|
||||
interface Props {
|
||||
data: Uint8Array;
|
||||
boundary: string;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
export function MultipartViewer({ data, boundary, idPrefix = "multipart" }: Props) {
|
||||
const parseResult = useMemo(() => {
|
||||
try {
|
||||
const maxFileSize = 1024 * 1024 * 10; // 10MB
|
||||
const parsed = parseMultipart(data, { boundary, maxFileSize });
|
||||
const parts = Array.from(parsed);
|
||||
return { parts, error: null };
|
||||
} catch (err) {
|
||||
return { parts: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}, [data, boundary]);
|
||||
|
||||
const { parts, error } = parseResult;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Banner color="danger" className="m-3">
|
||||
Failed to parse multipart data: {error}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return (
|
||||
<Banner color="info" className="m-3">
|
||||
No multipart parts found
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
const textViewerProps = { extensions: textViewerExtensions, formatText, renderEditor };
|
||||
|
||||
export function MultipartViewer(
|
||||
props: Omit<MultipartViewerProps, "renderPdf" | "textViewerProps" | "ErrorBoundary">,
|
||||
) {
|
||||
return (
|
||||
<Tabs
|
||||
addBorders
|
||||
label="Multipart"
|
||||
layout="horizontal"
|
||||
tabListClassName="border-r border-r-border -ml-3"
|
||||
tabs={parts.map((part, i) => ({
|
||||
label: part.name ?? "",
|
||||
value: tabValue(part, i),
|
||||
rightSlot:
|
||||
part.filename && part.headers.contentType.mediaType?.startsWith("image/") ? (
|
||||
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
|
||||
<ImageViewer
|
||||
data={part.arrayBuffer}
|
||||
className="ml-auto w-auto rounded-sm overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
) : part.filename ? (
|
||||
<Icon icon="file" />
|
||||
) : null,
|
||||
}))}
|
||||
>
|
||||
{parts.map((part, i) => (
|
||||
<TabContent
|
||||
// oxlint-disable-next-line react/no-array-index-key -- Nothing else to key on
|
||||
key={idPrefix + part.name + i}
|
||||
value={tabValue(part, i)}
|
||||
className="pl-3 pt-0!"
|
||||
>
|
||||
<Part part={part} />
|
||||
</TabContent>
|
||||
))}
|
||||
</Tabs>
|
||||
<BaseMultipartViewer
|
||||
{...props}
|
||||
renderPdf={renderPdf}
|
||||
textViewerProps={textViewerProps}
|
||||
ErrorBoundary={ErrorBoundary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Part({ part }: { part: MultipartPart }) {
|
||||
const mimeType = part.headers.contentType.mediaType ?? null;
|
||||
const contentTypeHeader = part.headers.get("content-type");
|
||||
|
||||
const { uint8Array, content, detectedLanguage } = useMemo(() => {
|
||||
const uint8Array = new Uint8Array(part.arrayBuffer);
|
||||
const content = new TextDecoder().decode(part.arrayBuffer);
|
||||
const detectedLanguage = languageFromContentType(contentTypeHeader, content);
|
||||
return { uint8Array, content, detectedLanguage };
|
||||
}, [part, contentTypeHeader]);
|
||||
|
||||
if (mimeType?.match(/^image\/svg/i)) {
|
||||
return <SvgViewer text={content} className="pb-2" />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image/i)) {
|
||||
return <ImageViewer data={part.arrayBuffer} className="pb-2" />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^audio/i)) {
|
||||
return <AudioViewer data={uint8Array} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^video/i)) {
|
||||
return <VideoViewer data={uint8Array} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
||||
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^text\/html/i) || detectedLanguage === "html") {
|
||||
return <WebPageViewer html={content} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/pdf/i)) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={uint8Array} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <TextViewer text={content} language={detectedLanguage} stateKey={null} />;
|
||||
}
|
||||
|
||||
function tabValue(part: MultipartPart, i: number) {
|
||||
return `${part.name ?? ""}::${i}`;
|
||||
function renderPdf(data: Uint8Array) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={data} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Tooltip } from "../core/Tooltip";
|
||||
|
||||
interface Props {
|
||||
segments: JsonPathSegment[];
|
||||
appliedFilter: string | null;
|
||||
/**
|
||||
* How many leading segments make up the applied filter. `null` when unfiltered, or
|
||||
* when the filter isn't a plain path and is shown as raw text instead.
|
||||
*/
|
||||
appliedDepth: number | null;
|
||||
filterError: boolean;
|
||||
/** Called with the number of leading segments to filter to. `0` clears the filter. */
|
||||
onSelect: (count: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows where the cursor sits inside a response (`$ > sort > [0] > id`). An applied
|
||||
* plain-path filter is the start of the trail, so any crumb moves it. While filtered,
|
||||
* the bar is tinted so a lingering filter is hard to miss.
|
||||
*/
|
||||
export function ResponseBreadcrumbBar({
|
||||
segments,
|
||||
appliedFilter,
|
||||
appliedDepth,
|
||||
filterError,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [overflow, setOverflow] = useState({ left: false, right: false });
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||
setOverflow({
|
||||
left: el.scrollLeft > 1,
|
||||
right: el.scrollLeft < maxScroll - 1,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keep the deepest crumb (where the cursor is) in view as the path changes.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
measure();
|
||||
}, [segments, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [measure]);
|
||||
|
||||
const scrollBy = useCallback((direction: -1 | 1) => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
el.scrollBy({ left: direction * Math.max(120, el.clientWidth * 0.6), behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const isFiltered = appliedFilter != null;
|
||||
|
||||
return (
|
||||
<div className="rounded bg-surface shadow pointer-events-auto">
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-stretch h-6 rounded border overflow-hidden font-mono text-xs text-text-subtle select-none",
|
||||
!isFiltered && "border-border-subtle",
|
||||
isFiltered && !filterError && "border-primary/40 bg-primary/10",
|
||||
filterError && "border-danger/40 bg-danger/10",
|
||||
)}
|
||||
>
|
||||
{isFiltered && appliedDepth == null ? (
|
||||
<span
|
||||
className={classNames(
|
||||
"flex items-center min-w-0 truncate px-2",
|
||||
filterError ? "text-danger" : "text-primary",
|
||||
)}
|
||||
>
|
||||
{appliedFilter}
|
||||
</span>
|
||||
) : (
|
||||
<Crumb
|
||||
label="$"
|
||||
tooltip="Clear filter"
|
||||
isApplied={isFiltered}
|
||||
onClick={appliedDepth != null && appliedDepth > 0 ? () => onSelect(0) : null}
|
||||
/>
|
||||
)}
|
||||
{overflow.left && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="chevron_left"
|
||||
title="Scroll breadcrumbs left"
|
||||
iconColor="secondary"
|
||||
onClick={() => scrollBy(-1)}
|
||||
className="shrink-0 h-auto!"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={measure}
|
||||
className="flex items-stretch min-w-0 overflow-x-auto hide-scrollbars whitespace-nowrap"
|
||||
>
|
||||
{segments.map((segment, i) => {
|
||||
const count = i + 1;
|
||||
const isApplied = appliedDepth != null && count <= appliedDepth;
|
||||
const isClickable = (!isFiltered || appliedDepth != null) && count !== appliedDepth;
|
||||
return (
|
||||
<div key={i} className="flex items-stretch shrink-0">
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
size="xs"
|
||||
className="text-text-subtlest shrink-0 self-center"
|
||||
/>
|
||||
<Crumb
|
||||
label={segment.kind === "index" ? `[${segment.index}]` : segment.key}
|
||||
tooltip={
|
||||
segment.kind === "index"
|
||||
? `Filter to element ${segment.index}`
|
||||
: `Filter to ${segment.key}`
|
||||
}
|
||||
isApplied={isApplied}
|
||||
onClick={isClickable ? () => onSelect(count) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{overflow.right && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="chevron_right"
|
||||
title="Scroll breadcrumbs right"
|
||||
iconColor="secondary"
|
||||
onClick={() => scrollBy(1)}
|
||||
className="shrink-0 h-auto!"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Crumb({
|
||||
label,
|
||||
tooltip,
|
||||
isApplied,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
tooltip: string;
|
||||
isApplied: boolean;
|
||||
onClick: (() => void) | null;
|
||||
}) {
|
||||
const outerClassName = classNames("flex items-center px-0.5 py-0.5", isApplied && "text-primary");
|
||||
const innerClassName = "flex items-center h-full px-1 rounded-sm";
|
||||
if (onClick == null) {
|
||||
return (
|
||||
<span className={classNames(outerClassName, "shrink-0")}>
|
||||
<span className={innerClassName}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} className="shrink-0 items-stretch!">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={classNames(outerClassName, "group/crumb focus-visible:outline-none")}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
innerClassName,
|
||||
"transition-colors group-hover/crumb:text-text group-hover/crumb:bg-surface-highlight",
|
||||
"group-focus-visible/crumb:ring-1 group-focus-visible/crumb:ring-border-focus",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SvgViewer({ text, className }: Props) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!text) {
|
||||
return setSrc(null);
|
||||
}
|
||||
|
||||
const blob = new Blob([text], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [text]);
|
||||
|
||||
if (src == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img src={src} alt="Response preview" className={className ?? "max-w-full max-h-full pb-2"} />
|
||||
);
|
||||
}
|
||||
@@ -1,222 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, useCallback, useMemo, useState } from "react";
|
||||
import { useFormatText } from "../../hooks/useFormatText";
|
||||
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { jsonBreadcrumbExtension } from "../core/Editor/json/breadcrumbExtension";
|
||||
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
|
||||
import { jsonPathToSegments, segmentsToJsonPath } from "../core/Editor/json/jsonPath";
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import { TextViewer as BaseTextViewer, type TextViewerProps } from "@yaakapp-internal/ui";
|
||||
import { tryFormatJson, tryFormatXml } from "../../lib/formatters";
|
||||
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Input } from "../core/Input";
|
||||
import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar";
|
||||
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||
import { renderEditor } from "../core/Editor/renderEditor";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
language: EditorProps["language"];
|
||||
stateKey: string | null;
|
||||
pretty?: boolean;
|
||||
className?: string;
|
||||
footerActions?: ReactNode;
|
||||
filter?: ResponseFilterApi;
|
||||
filterResult?: {
|
||||
data: string | null | undefined;
|
||||
isPending: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function TextViewer({
|
||||
language,
|
||||
text,
|
||||
stateKey,
|
||||
pretty,
|
||||
className,
|
||||
footerActions,
|
||||
filter,
|
||||
filterResult,
|
||||
}: Props) {
|
||||
// Track the JSON path under the cursor to drive the breadcrumb bar. Selection
|
||||
// works even in this read-only editor, so it updates as the user clicks around.
|
||||
const [breadcrumbSegments, setBreadcrumbSegments] = useState<JsonPathSegment[]>([]);
|
||||
const handleBreadcrumbUpdate = useCallback(
|
||||
({ segments }: { segments: JsonPathSegment[] | null }) =>
|
||||
setBreadcrumbSegments((prev) => {
|
||||
const next = segments ?? [];
|
||||
return segmentsToJsonPath(prev) === segmentsToJsonPath(next) ? prev : next;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const extraExtensions = useMemo(
|
||||
() =>
|
||||
language === "json"
|
||||
? [hyperlink, jsonBreadcrumbExtension(handleBreadcrumbUpdate)]
|
||||
: [hyperlink],
|
||||
[language, handleBreadcrumbUpdate],
|
||||
);
|
||||
|
||||
const canFilter =
|
||||
filter != null && (language === "json" || language === "xml" || language === "html");
|
||||
const isSearching = filter?.isSearching ?? false;
|
||||
const appliedFilter = filter?.appliedFilter ?? null;
|
||||
const resultError = filterResult?.error ?? false;
|
||||
|
||||
// Filter output is always an array of matches, so under a plain-path filter the
|
||||
// cursor's leading index is that single match and the rest extends the filter.
|
||||
const appliedSegments =
|
||||
appliedFilter != null && language === "json" ? jsonPathToSegments(appliedFilter) : null;
|
||||
const pathSegments =
|
||||
appliedSegments != null
|
||||
? [...appliedSegments, ...breadcrumbSegments.slice(1)]
|
||||
: breadcrumbSegments;
|
||||
const showBreadcrumbs =
|
||||
filter != null &&
|
||||
(appliedFilter != null || (language === "json" && breadcrumbSegments.length > 0));
|
||||
|
||||
const handleFilterKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (filter == null) return;
|
||||
if (e.key === "Escape") {
|
||||
filter.toggleSearch();
|
||||
} else if (e.key === "Enter" && filter.filterText != null) {
|
||||
filter.applyFilter(filter.filterText);
|
||||
}
|
||||
},
|
||||
[filter],
|
||||
);
|
||||
|
||||
const actions = useMemo<ReactNode[]>(() => {
|
||||
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
||||
|
||||
if (!canFilter) return nodes;
|
||||
|
||||
if (isSearching) {
|
||||
nodes.push(
|
||||
<div key="input" className="w-full opacity-100!">
|
||||
<Input
|
||||
key={filter.stateKey ?? "filter"}
|
||||
validate={!resultError}
|
||||
hideLabel
|
||||
autoFocus
|
||||
containerClassName="bg-surface"
|
||||
size="sm"
|
||||
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
||||
label="Filter expression"
|
||||
name="filter"
|
||||
defaultValue={filter.filterText}
|
||||
forceUpdateKey={filter.filterUpdateKey}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
onChange={filter.setFilterText}
|
||||
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
|
||||
leftSlot={
|
||||
<div className="py-0.5 flex">
|
||||
<RecentFiltersDropdown
|
||||
recentFilters={filter.recentFilters}
|
||||
activeFilter={filter.appliedFilter}
|
||||
onSelect={filter.replaceFilter}
|
||||
onRemove={filter.removeRecentFilter}
|
||||
onTogglePin={filter.togglePinRecentFilter}
|
||||
onClear={filter.clearRecentFilters}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rightSlot={
|
||||
<div className="py-0.5 flex">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
title="Close filter"
|
||||
iconColor="secondary"
|
||||
onClick={filter.toggleSearch}
|
||||
className="w-8 mr-0.5 h-auto!"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
nodes.push(
|
||||
<IconButton
|
||||
key="icon"
|
||||
size="sm"
|
||||
isLoading={filterResult?.isPending ?? false}
|
||||
icon="filter"
|
||||
title="Filter response"
|
||||
onClick={filter.toggleSearch}
|
||||
className="border border-border-subtle!"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}, [
|
||||
canFilter,
|
||||
footerActions,
|
||||
filter,
|
||||
filterResult?.isPending,
|
||||
resultError,
|
||||
isSearching,
|
||||
language,
|
||||
handleFilterKeyDown,
|
||||
]);
|
||||
|
||||
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
||||
if (formattedBody == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let body: string;
|
||||
if (appliedFilter) {
|
||||
if (resultError) {
|
||||
body = "";
|
||||
} else {
|
||||
body = filterResult?.data != null ? filterResult.data : "";
|
||||
}
|
||||
} else {
|
||||
body = formattedBody;
|
||||
}
|
||||
|
||||
// Decode unicode sequences in the text to readable characters
|
||||
if (language === "json" && pretty) {
|
||||
body = decodeUnicodeLiterals(body);
|
||||
body = body.replace(/\\\//g, "/"); // Hide unnecessary escaping of '/' by some older frameworks
|
||||
}
|
||||
export const textViewerExtensions = [hyperlink];
|
||||
|
||||
export function TextViewer(
|
||||
props: Omit<TextViewerProps, "extensions" | "formatText" | "renderEditor">,
|
||||
) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<Editor
|
||||
readOnly
|
||||
className={className}
|
||||
defaultValue={body}
|
||||
language={language}
|
||||
actions={actions}
|
||||
extraExtensions={extraExtensions}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
{showBreadcrumbs && (
|
||||
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
|
||||
<ResponseBreadcrumbBar
|
||||
segments={language === "json" ? pathSegments : []}
|
||||
appliedFilter={appliedFilter}
|
||||
appliedDepth={appliedSegments?.length ?? null}
|
||||
filterError={resultError}
|
||||
onSelect={(count) =>
|
||||
filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<BaseTextViewer
|
||||
{...props}
|
||||
extensions={textViewerExtensions}
|
||||
formatText={formatText}
|
||||
renderEditor={renderEditor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert \uXXXX to actual Unicode characters */
|
||||
function decodeUnicodeLiterals(text: string): string {
|
||||
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
||||
const charCode = Number.parseInt(hex, 16);
|
||||
return String.fromCharCode(charCode);
|
||||
});
|
||||
export async function formatText(
|
||||
text: string,
|
||||
language: CodeEditorProps["language"],
|
||||
): Promise<string> {
|
||||
if (language === "json") {
|
||||
return tryFormatJson(text);
|
||||
}
|
||||
if (language === "xml" || language === "html") {
|
||||
return tryFormatXml(text);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
/** A URL for the body the host already stored. */
|
||||
bodyUrl?: string;
|
||||
data?: Uint8Array;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyUrl) {
|
||||
setSrc(bodyUrl);
|
||||
} else if (data) {
|
||||
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyUrl, data, mimeType]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <video className="w-full" controls src={src} />;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
html: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export function WebPageViewer({ html, baseUrl }: Props) {
|
||||
const contentForIframe: string | undefined = useMemo(() => {
|
||||
if (baseUrl && html.includes("<head>")) {
|
||||
return html.replace(/<head>/gi, `<head><base href="${baseUrl}"/>`);
|
||||
}
|
||||
return html;
|
||||
}, [baseUrl, html]);
|
||||
|
||||
return (
|
||||
<div className="h-full pb-3">
|
||||
<iframe
|
||||
key={html ? "has-body" : "no-body"}
|
||||
title="Yaak response preview"
|
||||
srcDoc={contentForIframe}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full rounded-lg border border-border-subtle"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Folder } from "@yaakapp-internal/models";
|
||||
import { modelTypeLabel, patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import { useMemo } from "react";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||
import { IconTooltip } from "../components/core/IconTooltip";
|
||||
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
|
||||
import type { TabItem } from "../components/core/Tabs/Tabs";
|
||||
import { capitalize } from "../lib/capitalize";
|
||||
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user