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 { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { showPrompt } from "../lib/prompt";
|
import { showPrompt } from "../lib/prompt";
|
||||||
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
|
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
export const createFolder = createFastMutation<
|
export const createFolder = createFastMutation<
|
||||||
string | null,
|
string | null,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-intern
|
|||||||
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
|
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
export const moveToWorkspace = createFastMutation({
|
export const moveToWorkspace = createFastMutation({
|
||||||
mutationKey: ["move_workspace"],
|
mutationKey: ["move_workspace"],
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
import { patchModel } 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 type { ReactNode } from "react";
|
||||||
import { useToggle } from "../hooks/useToggle";
|
import { useToggle } from "../hooks/useToggle";
|
||||||
import { showConfirm } from "../lib/confirm";
|
import { showConfirm } from "../lib/confirm";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
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 { type ReactNode, useMemo } from "react";
|
||||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||||
import { useToggle } from "../hooks/useToggle";
|
import { useToggle } from "../hooks/useToggle";
|
||||||
@@ -8,7 +8,6 @@ import { getContentTypeFromHeaders } from "../lib/model_util";
|
|||||||
import { getResponseBodyText } from "../lib/responseBody";
|
import { getResponseBodyText } from "../lib/responseBody";
|
||||||
import { CopyButton } from "./CopyButton";
|
import { CopyButton } from "./CopyButton";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
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 { type ReactNode, useMemo } from "react";
|
||||||
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
|
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
|
||||||
import { useToggle } from "../hooks/useToggle";
|
import { useToggle } from "../hooks/useToggle";
|
||||||
@@ -7,7 +7,6 @@ import { isProbablyTextContentType } from "../lib/contentType";
|
|||||||
import { getContentTypeFromHeaders } from "../lib/model_util";
|
import { getContentTypeFromHeaders } from "../lib/model_util";
|
||||||
import { CopyButton } from "./CopyButton";
|
import { CopyButton } from "./CopyButton";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ import {
|
|||||||
TableHeaderCell,
|
TableHeaderCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
TruncatedWideTableCell,
|
TruncatedWideTableCell,
|
||||||
|
KeyValueRows,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { EventDetailHeader } from "./core/EventViewer";
|
import { EventDetailHeader } from "./core/EventViewer";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
import { KeyValueRow } from "./core/KeyValueRow";
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { Select } from "./core/Select";
|
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 { copyToClipboard } from "../lib/copy";
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
|
|
||||||
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
|
export function CopyIconButton(props: Omit<CopyIconButtonProps, "copy" | "onCopyFailed">) {
|
||||||
text: string | (() => Promise<string | null>);
|
return <BaseCopyIconButton {...props} copy={copy} onCopyFailed={showCopyFailed} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyIconButton({ text, ...props }: Props) {
|
function copy(text: string) {
|
||||||
const [copied, setCopied] = useTimedBoolean();
|
copyToClipboard(text, { disableToast: true });
|
||||||
return (
|
}
|
||||||
<IconButton
|
|
||||||
{...props}
|
function showCopyFailed() {
|
||||||
icon={copied ? "check" : "copy"}
|
showToast({
|
||||||
showConfirm
|
id: "failed-to-copy",
|
||||||
onClick={async () => {
|
color: "danger",
|
||||||
const content = typeof text === "function" ? await text() : text;
|
message: "Failed to copy",
|
||||||
if (content == null) {
|
});
|
||||||
showToast({
|
|
||||||
id: "failed-to-copy",
|
|
||||||
color: "danger",
|
|
||||||
message: "Failed to copy",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
copyToClipboard(content, { disableToast: true });
|
|
||||||
setCopied();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,8 @@ import { ColorIndicator } from "./ColorIndicator";
|
|||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
||||||
import { DialogFooter } from "./core/Dialog";
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { Label } from "./core/Label";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
import { Label } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onCreate: (id: string) => void;
|
onCreate: (id: string) => void;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { gitMutations } from "@yaakapp-internal/git";
|
import { gitMutations } from "@yaakapp-internal/git";
|
||||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||||
import { createGlobalModel, updateModel } 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 { useId, useState } from "react";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||||
@@ -9,7 +9,6 @@ import { rpc } from "../lib/rpc";
|
|||||||
import { showErrorToast } from "../lib/toast";
|
import { showErrorToast } from "../lib/toast";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { DialogFooter } from "./core/Dialog";
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { Label } from "./core/Label";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { EncryptionHelp } from "./EncryptionHelp";
|
import { EncryptionHelp } from "./EncryptionHelp";
|
||||||
import { gitCallbacks } from "./git/callbacks";
|
import { gitCallbacks } from "./git/callbacks";
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
FormInputText,
|
FormInputText,
|
||||||
JsonPrimitive,
|
JsonPrimitive,
|
||||||
} from "@yaakapp-internal/plugins";
|
} from "@yaakapp-internal/plugins";
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
import { Banner, VStack, Label } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
@@ -26,7 +26,6 @@ import { Editor } from "./core/Editor/LazyEditor";
|
|||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import type { InputProps } from "./core/Input";
|
import type { InputProps } from "./core/Input";
|
||||||
import { Input } from "./core/Input";
|
import { Input } from "./core/Input";
|
||||||
import { Label } from "./core/Label";
|
|
||||||
import type { Pair } from "./core/PairEditor";
|
import type { Pair } from "./core/PairEditor";
|
||||||
import { PairEditor } from "./core/PairEditor";
|
import { PairEditor } from "./core/PairEditor";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Environment, Workspace } from "@yaakapp-internal/models";
|
import type { Environment, Workspace } from "@yaakapp-internal/models";
|
||||||
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
||||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
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 { atom, useAtomValue } from "jotai";
|
||||||
import { atomFamily } from "jotai-family";
|
import { atomFamily } from "jotai-family";
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +30,6 @@ import { showColorPicker } from "../lib/showColorPicker";
|
|||||||
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
||||||
import { ContextMenu } from "./core/Dropdown";
|
import { ContextMenu } from "./core/Dropdown";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
import type { PairEditorHandle } from "./core/PairEditor";
|
import type { PairEditorHandle } from "./core/PairEditor";
|
||||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
||||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
import { EnvironmentEditor } from "./EnvironmentEditor";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IconTooltip } from "./core/IconTooltip";
|
import { IconTooltip } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
export function EnvironmentSharableTooltip() {
|
export function EnvironmentSharableTooltip() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useAtomValue } from "jotai";
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
@@ -13,6 +12,7 @@ import { DetailsBanner } from "./core/DetailsBanner";
|
|||||||
import { DialogFooter } from "./core/Dialog";
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
||||||
import { foldersAtom } 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 classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
@@ -13,11 +21,8 @@ import { showDialog } from "../lib/dialog";
|
|||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { Separator } from "./core/Separator";
|
import { Separator } from "./core/Separator";
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
import { HttpResponsePane } from "./HttpResponsePane";
|
import { HttpResponsePane } from "./HttpResponsePane";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
|
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 { useAtomValue } from "jotai";
|
||||||
import { Fragment, useMemo } from "react";
|
import { Fragment, useMemo } from "react";
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
@@ -11,7 +11,6 @@ import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
|||||||
import { hideDialog } from "../lib/dialog";
|
import { hideDialog } from "../lib/dialog";
|
||||||
import { CopyIconButton } from "./CopyIconButton";
|
import { CopyIconButton } from "./CopyIconButton";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { Input } from "./core/Input";
|
import { Input } from "./core/Input";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import type { TabItem } from "./core/Tabs/Tabs";
|
import type { TabItem } from "./core/Tabs/Tabs";
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ import { showConfirm } from "../lib/confirm";
|
|||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
||||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { queryClient } from "../lib/queryClient";
|
import { queryClient } from "../lib/queryClient";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Dropdown } from "./core/Dropdown";
|
import { Dropdown } from "./core/Dropdown";
|
||||||
import type { EditorProps } from "./core/Editor/Editor";
|
import type { EditorProps } from "./core/Editor/Editor";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
|
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
|
||||||
services: ReflectResponseService[] | null;
|
services: ReflectResponseService[] | null;
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||||
import { useGrpc } from "../hooks/useGrpc";
|
import { useGrpc } from "../hooks/useGrpc";
|
||||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { DialogFooter } from "./core/Dialog";
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { PathList } from "./core/PathList";
|
import { PathList } from "./core/PathList";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
patchModel,
|
patchModel,
|
||||||
patchModelDebounced,
|
patchModelDebounced,
|
||||||
} from "@yaakapp-internal/models";
|
} 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 classNames from "classnames";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
@@ -15,7 +15,6 @@ import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
|||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { RadioDropdown } from "./core/RadioDropdown";
|
import { RadioDropdown } from "./core/RadioDropdown";
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
|
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 { useAtomValue, useSetAtom } from "jotai";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -9,13 +17,12 @@ import {
|
|||||||
pinnedGrpcConnectionIdAtom,
|
pinnedGrpcConnectionIdAtom,
|
||||||
useGrpcEvents,
|
useGrpcEvents,
|
||||||
} from "../hooks/usePinnedGrpcConnection";
|
} from "../hooks/usePinnedGrpcConnection";
|
||||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
|
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||||
import { EventViewerRow } from "./core/EventViewerRow";
|
import { EventViewerRow } from "./core/EventViewerRow";
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
import { HotkeyList } from "./core/HotkeyList";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
import { KeyValueRow } from "./core/KeyValueRow";
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
|
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
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 { acceptLanguages } from "../lib/data/acceptLanguages";
|
||||||
import { cacheControlDirectives } from "../lib/data/cacheControl";
|
import { cacheControlDirectives } from "../lib/data/cacheControl";
|
||||||
import { charsets } from "../lib/data/charsets";
|
import { charsets } from "../lib/data/charsets";
|
||||||
@@ -10,7 +10,6 @@ import { headerNames } from "../lib/data/headerNames";
|
|||||||
import { mimeTypes } from "../lib/data/mimetypes";
|
import { mimeTypes } from "../lib/data/mimetypes";
|
||||||
import { userAgents } from "../lib/data/userAgents";
|
import { userAgents } from "../lib/data/userAgents";
|
||||||
import { validateHttpHeader } from "../lib/validateHttpHeader";
|
import { validateHttpHeader } from "../lib/validateHttpHeader";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
import type { InputProps } from "./core/Input";
|
import type { InputProps } from "./core/Input";
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
|
||||||
import { generateId } from "../lib/generateId";
|
|
||||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||||
import {
|
import {
|
||||||
@@ -35,11 +33,10 @@ import { resolvedModelName } from "../lib/resolvedModelName";
|
|||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
import { BinaryFileEditor } from "./BinaryFileEditor";
|
import { BinaryFileEditor } from "./BinaryFileEditor";
|
||||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
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 { PlainInput } from "./core/PlainInput";
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||||
import { setActiveTab, TabContent, Tabs } 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 { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||||
import { UrlBar } from "./UrlBar";
|
import { UrlBar } from "./UrlBar";
|
||||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||||
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
|
||||||
const GraphQLEditor = lazy(() =>
|
const GraphQLEditor = lazy(() =>
|
||||||
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
|
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
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 classNames from "classnames";
|
||||||
import type { ComponentType, CSSProperties } from "react";
|
import type { ComponentType, CSSProperties } from "react";
|
||||||
import { lazy, Suspense, useMemo } from "react";
|
import { lazy, Suspense, useMemo } from "react";
|
||||||
@@ -17,15 +33,10 @@ import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
|
|||||||
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
|
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
|
||||||
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
|
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
import { HotkeyList } from "./core/HotkeyList";
|
||||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
|
||||||
import { PillButton } from "./core/PillButton";
|
import { PillButton } from "./core/PillButton";
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
import type { TabItem } from "./core/Tabs/Tabs";
|
import type { TabItem } from "./core/Tabs/Tabs";
|
||||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||||
import { Tooltip } from "./core/Tooltip";
|
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||||
@@ -33,14 +44,9 @@ import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
|||||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||||
import { ResponseCookies } from "./ResponseCookies";
|
import { ResponseCookies } from "./ResponseCookies";
|
||||||
import { ResponseHeaders } from "./ResponseHeaders";
|
import { ResponseHeaders } from "./ResponseHeaders";
|
||||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
|
||||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
|
||||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
||||||
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
|
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
|
||||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
|
||||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
|
||||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
|
||||||
|
|
||||||
const PdfViewer = lazy(() =>
|
const PdfViewer = lazy(() =>
|
||||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import { resolvedModelName } from "../lib/resolvedModelName";
|
|||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||||
import { EventViewerRow } from "./core/EventViewerRow";
|
import { EventViewerRow } from "./core/EventViewerRow";
|
||||||
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
|
import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui";
|
||||||
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
import { KeyValueRow } from "./core/KeyValueRow";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
|
||||||
import type { TimelineViewMode } from "./HttpResponsePane";
|
import type { TimelineViewMode } from "./HttpResponsePane";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import {
|
|||||||
type ImportSource,
|
type ImportSource,
|
||||||
type Workspace,
|
type Workspace,
|
||||||
} from "@yaakapp-internal/models";
|
} 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 classNames from "classnames";
|
||||||
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { errorMessage } from "../lib/errorMessage";
|
import { errorMessage } from "../lib/errorMessage";
|
||||||
import { pluralize } from "../lib/pluralize";
|
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import {
|
import {
|
||||||
ImportSourceList,
|
ImportSourceList,
|
||||||
@@ -22,9 +21,9 @@ import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
|||||||
import { CheckboxTree } from "./core/CheckboxTree";
|
import { CheckboxTree } from "./core/CheckboxTree";
|
||||||
import { Chip } from "./core/Chip";
|
import { Chip } from "./core/Chip";
|
||||||
import { DialogFooter } from "./core/Dialog";
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
import { Select } from "./core/Select";
|
import { Select } from "./core/Select";
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
import { SegmentedControl } from "./core/SegmentedControl";
|
||||||
|
import { pluralize } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentWorkspace: Workspace | null;
|
currentWorkspace: Workspace | null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { linter } from "@codemirror/lint";
|
import { linter } from "@codemirror/lint";
|
||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
import { patchModel } 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 { useCallback, useMemo } from "react";
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
import { useKeyValue } from "../hooks/useKeyValue";
|
||||||
import { fireAndForget } from "../lib/fireAndForget";
|
import { fireAndForget } from "../lib/fireAndForget";
|
||||||
@@ -12,7 +12,6 @@ import type { EditorProps } from "./core/Editor/Editor";
|
|||||||
import { jsonParseLinter } from "./core/Editor/json-lint";
|
import { jsonParseLinter } from "./core/Editor/json-lint";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
forceUpdateKey: string;
|
forceUpdateKey: string;
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
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 type { ReactNode } from "react";
|
||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import type { SniffedValue } from "./core/Editor/sniffValue";
|
import type { SniffedValue } from "./core/Editor/sniffValue";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import { decodeValue, largeValueActions } from "../lib/largeValue";
|
import { decodeValue, largeValueActions } from "../lib/largeValue";
|
||||||
import { Dropdown } from "./core/Dropdown";
|
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(() =>
|
const PdfViewer = lazy(() =>
|
||||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
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 { InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { showToast } from "../lib/toast";
|
import { showToast } from "../lib/toast";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Select } from "./core/Select";
|
import { Select } from "./core/Select";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
activeWorkspaceId: string;
|
activeWorkspaceId: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { GrpcConnection } from "@yaakapp-internal/models";
|
import type { GrpcConnection } from "@yaakapp-internal/models";
|
||||||
import { deleteModel } 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 {
|
import {
|
||||||
differenceInHours,
|
differenceInHours,
|
||||||
differenceInMinutes,
|
differenceInMinutes,
|
||||||
@@ -9,10 +9,9 @@ import {
|
|||||||
isYesterday,
|
isYesterday,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
connections: GrpcConnection[];
|
connections: GrpcConnection[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { deleteModel } 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 {
|
import {
|
||||||
differenceInHours,
|
differenceInHours,
|
||||||
differenceInMinutes,
|
differenceInMinutes,
|
||||||
@@ -12,10 +12,7 @@ import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
|||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
import { useKeyValue } from "../hooks/useKeyValue";
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { SizeTag } from "./core/SizeTag";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
responses: HttpResponse[];
|
responses: HttpResponse[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
||||||
import { deleteModel, getModel } 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 {
|
import {
|
||||||
differenceInHours,
|
differenceInHours,
|
||||||
differenceInMinutes,
|
differenceInMinutes,
|
||||||
@@ -9,10 +9,9 @@ import {
|
|||||||
isYesterday,
|
isYesterday,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
|
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
connections: WebsocketConnection[];
|
connections: WebsocketConnection[];
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
|
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
|
||||||
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
|
import { getMimeTypeFromContentType } from "../lib/contentType";
|
||||||
import { LoadingIcon } from "@yaakapp-internal/ui";
|
import {
|
||||||
|
LoadingIcon,
|
||||||
|
languageFromContentType,
|
||||||
|
AudioViewer,
|
||||||
|
CsvViewer,
|
||||||
|
ImageViewer,
|
||||||
|
SvgViewer,
|
||||||
|
VideoViewer,
|
||||||
|
WebPageViewer,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
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 { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
|
||||||
import { TextViewer } from "./responseViewers/TextViewer";
|
import { TextViewer } from "./responseViewers/TextViewer";
|
||||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
|
||||||
import { WebPageViewer } from "./responseViewers/WebPageViewer";
|
|
||||||
|
|
||||||
const PdfViewer = lazy(() =>
|
const PdfViewer = lazy(() =>
|
||||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import classNames from "classnames";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { JSX } from "react/jsx-runtime";
|
import type { JSX } from "react/jsx-runtime";
|
||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
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 {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
|
|||||||
@@ -1,31 +1,16 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
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 { 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 {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResponseHeaders({ response }: Props) {
|
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 (
|
return (
|
||||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
<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>}>
|
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
|
||||||
@@ -58,53 +43,21 @@ export function ResponseHeaders({ response }: Props) {
|
|||||||
</KeyValueRow>
|
</KeyValueRow>
|
||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
</DetailsBanner>
|
</DetailsBanner>
|
||||||
<DetailsBanner
|
<HeadersTable
|
||||||
|
title="Request Headers"
|
||||||
|
headers={response.requestHeaders}
|
||||||
|
labelColor="primary"
|
||||||
storageKey={`${response.requestId}.request_headers`}
|
storageKey={`${response.requestId}.request_headers`}
|
||||||
summary={
|
useStoredValue={useDetailsBannerStoredValue}
|
||||||
<h2 className="flex items-center">
|
/>
|
||||||
Request Headers <CountBadge showZero count={requestHeaders.length} />
|
<HeadersTable
|
||||||
</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
|
|
||||||
defaultOpen
|
defaultOpen
|
||||||
|
title="Response Headers"
|
||||||
|
headers={response.headers}
|
||||||
|
labelColor="info"
|
||||||
storageKey={`${response.requestId}.response_headers`}
|
storageKey={`${response.requestId}.response_headers`}
|
||||||
summary={
|
useStoredValue={useDetailsBannerStoredValue}
|
||||||
<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>
|
|
||||||
</div>
|
</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 type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
import { KeyValueRow } from "./core/KeyValueRow";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { KeyValueRows } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
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 classNames from "classnames";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -7,8 +7,6 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import type { ButtonProps } from "./core/Button";
|
import type { ButtonProps } from "./core/Button";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
import { Label } from "./core/Label";
|
|
||||||
|
|
||||||
type Props = Omit<ButtonProps, "type"> & {
|
type Props = Omit<ButtonProps, "type"> & {
|
||||||
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
|
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
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 classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useKeyPressEvent } from "react-use";
|
import { useKeyPressEvent } from "react-use";
|
||||||
import { appInfo } from "../../lib/appInfo";
|
import { appInfo } from "../../lib/appInfo";
|
||||||
import { capitalize } from "../../lib/capitalize";
|
import { capitalize } from "../../lib/capitalize";
|
||||||
import { CountBadge } from "../core/CountBadge";
|
|
||||||
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
|
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
|
||||||
import { SettingsCertificates } from "./SettingsCertificates";
|
import { SettingsCertificates } from "./SettingsCertificates";
|
||||||
import { SettingsGeneral } from "./SettingsGeneral";
|
import { SettingsGeneral } from "./SettingsGeneral";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { differenceInDays } from "date-fns";
|
|||||||
import { formatDate } from "date-fns/format";
|
import { formatDate } from "date-fns/format";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useToggle } from "../../hooks/useToggle";
|
import { useToggle } from "../../hooks/useToggle";
|
||||||
import { pluralizeCount } from "../../lib/pluralize";
|
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
import { pricingUrl } from "../../lib/pricingUrl";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
@@ -12,6 +11,7 @@ import { Link } from "../core/Link";
|
|||||||
import { PlainInput } from "../core/PlainInput";
|
import { PlainInput } from "../core/PlainInput";
|
||||||
import { Separator } from "../core/Separator";
|
import { Separator } from "../core/Separator";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
export function SettingsLicense() {
|
export function SettingsLicense() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
TableHeaderCell,
|
TableHeaderCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
useDebouncedValue,
|
useDebouncedValue,
|
||||||
|
CountBadge,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -31,7 +32,6 @@ import { showConfirmDelete } from "../../lib/confirm";
|
|||||||
import { minPromiseMillis } from "../../lib/minPromiseMillis";
|
import { minPromiseMillis } from "../../lib/minPromiseMillis";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
import { CountBadge } from "../core/CountBadge";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { Link } from "../core/Link";
|
import { Link } from "../core/Link";
|
||||||
import { PlainInput } from "../core/PlainInput";
|
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 { evaluate, parseQuery } from "./core/Editor/filter/query";
|
||||||
import { formatFieldFilter } from "./core/Editor/filter/format";
|
import { formatFieldFilter } from "./core/Editor/filter/format";
|
||||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
import { HttpMethodTag } from "./core/HttpMethodTag";
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
|
||||||
import {
|
import {
|
||||||
Icon,
|
Icon,
|
||||||
LoadingIcon,
|
LoadingIcon,
|
||||||
@@ -77,6 +76,7 @@ import {
|
|||||||
isSelectedFamily,
|
isSelectedFamily,
|
||||||
selectedIdsFamily,
|
selectedIdsFamily,
|
||||||
InlineCode,
|
InlineCode,
|
||||||
|
HttpStatusTag,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
|
import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
|
|||||||
@@ -22,11 +22,9 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|||||||
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
|
||||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
@@ -40,6 +38,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
|
|||||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||||
import { UrlBar } from "./UrlBar";
|
import { UrlBar } from "./UrlBar";
|
||||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||||
|
import { languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
style: CSSProperties;
|
style: CSSProperties;
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
|
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 { hexy } from "hexy";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -10,8 +17,6 @@ import {
|
|||||||
setPinnedWebsocketConnectionId,
|
setPinnedWebsocketConnectionId,
|
||||||
useWebsocketEvents,
|
useWebsocketEvents,
|
||||||
} from "../hooks/usePinnedWebsocketConnection";
|
} from "../hooks/usePinnedWebsocketConnection";
|
||||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||||
|
|||||||
@@ -5,20 +5,17 @@ import {
|
|||||||
setWorkspaceKey,
|
setWorkspaceKey,
|
||||||
} from "@yaakapp-internal/crypto";
|
} from "@yaakapp-internal/crypto";
|
||||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
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 classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
|
||||||
import { showConfirm } from "../lib/confirm";
|
import { showConfirm } from "../lib/confirm";
|
||||||
import { CopyIconButton } from "./CopyIconButton";
|
import { CopyIconButton } from "./CopyIconButton";
|
||||||
import type { ButtonProps } from "./core/Button";
|
import type { ButtonProps } from "./core/Button";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
import { Label } from "./core/Label";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { SettingRow } from "./core/SettingRow";
|
import { SettingRow } from "./core/SettingRow";
|
||||||
import { EncryptionHelp } from "./EncryptionHelp";
|
import { EncryptionHelp } from "./EncryptionHelp";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
|
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 { useAtomValue } from "jotai";
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||||
@@ -9,7 +9,6 @@ import { showDialog } from "../lib/dialog";
|
|||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { CopyIconButton } from "./CopyIconButton";
|
import { CopyIconButton } from "./CopyIconButton";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { SettingsList, SettingsSection } from "./core/SettingRow";
|
import { SettingsList, SettingsSection } from "./core/SettingRow";
|
||||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import { Editor } from "./Editor/LazyEditor";
|
import { Editor } from "./Editor/LazyEditor";
|
||||||
import type { Pair, PairEditorProps, PairWithId } from "./PairEditor";
|
import type { Pair, PairEditorProps, PairWithId } from "./PairEditor";
|
||||||
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
|
||||||
type Props = PairEditorProps;
|
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 classNames from "classnames";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { IconTooltip } from "./IconTooltip";
|
|
||||||
|
|
||||||
export interface CheckboxProps {
|
export interface CheckboxProps {
|
||||||
checked: boolean | "indeterminate";
|
checked: boolean | "indeterminate";
|
||||||
|
|||||||
@@ -1,60 +1,27 @@
|
|||||||
import classNames from "classnames";
|
import {
|
||||||
import { atom, useAtom } from "jotai";
|
DetailsBanner as BaseDetailsBanner,
|
||||||
import type { HTMLAttributes, ReactNode } from "react";
|
type DetailsBannerProps,
|
||||||
import { useMemo } from "react";
|
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 { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage";
|
||||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
|
||||||
import { Banner } from "@yaakapp-internal/ui";
|
|
||||||
|
|
||||||
interface Props extends HTMLAttributes<HTMLDetailsElement> {
|
export function DetailsBanner(props: Omit<DetailsBannerProps, "useStoredValue">) {
|
||||||
summary: ReactNode;
|
return <BaseDetailsBanner {...props} useStoredValue={useDetailsBannerStoredValue} />;
|
||||||
color?: BannerProps["color"];
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
storageKey?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsBanner({
|
export function useDetailsBannerStoredValue<T extends StoredValue>(
|
||||||
className,
|
key: string | string[] | null,
|
||||||
color,
|
fallback: T,
|
||||||
summary,
|
) {
|
||||||
children,
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when the key changes
|
||||||
defaultOpen,
|
const valueAtom = useMemo<WritableAtom<T, [SetStateAction<T>], void>>(
|
||||||
storageKey,
|
|
||||||
...extraProps
|
|
||||||
}: Props) {
|
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes
|
|
||||||
const openAtom = useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
storageKey
|
key != null
|
||||||
? atomWithKVStorage<boolean>(["details_banner", storageKey], defaultOpen ?? false)
|
? atomWithKVStorage<T>(["details_banner", ...[key].flat()], fallback)
|
||||||
: atom(defaultOpen ?? false),
|
: atom(fallback),
|
||||||
[storageKey],
|
[key],
|
||||||
);
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
|
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 classNames from "classnames";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import * as m from "motion/react-m";
|
import * as m from "motion/react-m";
|
||||||
@@ -28,8 +36,6 @@ import { useKey, useWindowSize } from "react-use";
|
|||||||
import { useClickOutside } from "../../hooks/useClickOutside";
|
import { useClickOutside } from "../../hooks/useClickOutside";
|
||||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
import type { HotkeyAction } from "../../hooks/useHotKey";
|
||||||
import { useHotKey } from "../../hooks/useHotKey";
|
import { useHotKey } from "../../hooks/useHotKey";
|
||||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|
||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import { getNodeText } from "../../lib/getNodeText";
|
import { getNodeText } from "../../lib/getNodeText";
|
||||||
import { jotaiStore } from "../../lib/jotai";
|
import { jotaiStore } from "../../lib/jotai";
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
import { fireAndForget } from "../../lib/fireAndForget";
|
||||||
@@ -39,6 +45,7 @@ import { Hotkey } from "./Hotkey";
|
|||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
import type { SeparatorAction } from "./Separator";
|
import type { SeparatorAction } from "./Separator";
|
||||||
import { Separator } from "./Separator";
|
import { Separator } from "./Separator";
|
||||||
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
|
||||||
export type DropdownItemSeparator = {
|
export type DropdownItemSeparator = {
|
||||||
type: "separator";
|
type: "separator";
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { EditorView } from "@codemirror/view";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import "./DiffViewer.css";
|
import "./DiffViewer.css";
|
||||||
import { readonlyExtensions, syntaxHighlightStyle } from "./extensions";
|
import { syntaxHighlightStyle } from "@yaakapp-internal/ui/editor";
|
||||||
|
import { readonlyExtensions } from "./extensions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Original/previous version (left side) */
|
/** Original/previous version (left side) */
|
||||||
|
|||||||
@@ -1,147 +1,53 @@
|
|||||||
import { startCompletion } from "@codemirror/autocomplete";
|
import { startCompletion } from "@codemirror/autocomplete";
|
||||||
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
|
import type { Extension } from "@codemirror/state";
|
||||||
import { foldState, forceParsing } from "@codemirror/language";
|
import type { EditorView } from "@codemirror/view";
|
||||||
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 { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { CodeEditor, type CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||||
import classNames from "classnames";
|
|
||||||
import type { GraphQLSchema } from "graphql";
|
import type { GraphQLSchema } from "graphql";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type { ReactNode, RefObject } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import {
|
|
||||||
Children,
|
|
||||||
cloneElement,
|
|
||||||
isValidElement,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment";
|
import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment";
|
||||||
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
|
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
|
||||||
import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables";
|
import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables";
|
||||||
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
|
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
|
||||||
import { useRequestEditor } from "../../../hooks/useRequestEditor";
|
import { useRequestEditor } from "../../../hooks/useRequestEditor";
|
||||||
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
|
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
|
||||||
import { docFingerprint } from "../../../lib/docFingerprint";
|
|
||||||
import { editEnvironment } from "../../../lib/editEnvironment";
|
import { editEnvironment } from "../../../lib/editEnvironment";
|
||||||
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
|
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
|
||||||
import { jotaiStore } from "../../../lib/jotai";
|
import { jotaiStore } from "../../../lib/jotai";
|
||||||
import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption";
|
import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption";
|
||||||
import { TemplateFunctionDialog } from "../../TemplateFunctionDialog";
|
import { TemplateFunctionDialog } from "../../TemplateFunctionDialog";
|
||||||
import { IconButton } from "../IconButton";
|
import { getLanguageExtension } from "./extensions";
|
||||||
import "./Editor.css";
|
|
||||||
import {
|
|
||||||
baseExtensions,
|
|
||||||
editableExtensions,
|
|
||||||
getLanguageExtension,
|
|
||||||
multiLineExtensions,
|
|
||||||
readonlyExtensions,
|
|
||||||
} from "./extensions";
|
|
||||||
import type { GenericCompletionConfig } from "./genericCompletion";
|
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.
|
export type EditorProps = Omit<CodeEditorProps, "language" | "extensions" | "keymap"> & {
|
||||||
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;
|
|
||||||
autocomplete?: GenericCompletionConfig;
|
autocomplete?: GenericCompletionConfig;
|
||||||
autocompleteFunctions?: boolean;
|
autocompleteFunctions?: boolean;
|
||||||
autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
|
autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
|
||||||
className?: string;
|
|
||||||
defaultValue?: string | null;
|
|
||||||
disableTabIndent?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
extraExtensions?: Extension[] | Extension;
|
extraExtensions?: Extension[] | Extension;
|
||||||
forcedEnvironmentId?: string;
|
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;
|
graphQLSchema?: GraphQLSchema | null;
|
||||||
onBlur?: () => void;
|
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
|
||||||
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 };
|
|
||||||
|
|
||||||
const emptyVariables: WrappedEnvironmentVariable[] = [];
|
const emptyVariables: WrappedEnvironmentVariable[] = [];
|
||||||
const emptyExtension: Extension = [];
|
|
||||||
|
|
||||||
export function Editor(props: EditorProps) {
|
export function Editor({
|
||||||
return <EditorInner key={props.stateKey} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorInner({
|
|
||||||
actions,
|
|
||||||
autoFocus,
|
|
||||||
autoSelect,
|
|
||||||
autocomplete,
|
autocomplete,
|
||||||
autocompleteFunctions,
|
autocompleteFunctions,
|
||||||
autocompleteVariables,
|
autocompleteVariables,
|
||||||
className,
|
|
||||||
defaultValue,
|
|
||||||
disableTabIndent,
|
|
||||||
disabled,
|
|
||||||
extraExtensions,
|
extraExtensions,
|
||||||
forcedEnvironmentId,
|
forcedEnvironmentId,
|
||||||
forceUpdateKey,
|
|
||||||
format,
|
format,
|
||||||
heightMode,
|
|
||||||
hideGutter,
|
|
||||||
graphQLSchema,
|
graphQLSchema,
|
||||||
|
hideGutter,
|
||||||
language,
|
language,
|
||||||
lintExtension,
|
lintExtension,
|
||||||
onBlur,
|
|
||||||
onChange,
|
|
||||||
onFocus,
|
|
||||||
onKeyDown,
|
|
||||||
onPaste,
|
|
||||||
onPasteOverwrite,
|
|
||||||
placeholder,
|
|
||||||
readOnly,
|
readOnly,
|
||||||
singleLine,
|
|
||||||
containerOnly,
|
|
||||||
stateKey,
|
|
||||||
type,
|
|
||||||
wrapLines,
|
wrapLines,
|
||||||
setRef,
|
...props
|
||||||
}: EditorProps) {
|
}: EditorProps) {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
|
||||||
@@ -158,20 +64,10 @@ function EditorInner({
|
|||||||
wrapLines = settings.editorSoftWrap;
|
wrapLines = settings.editorSoftWrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disabled) {
|
if (props.disabled) {
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
singleLine ||
|
|
||||||
language == null ||
|
|
||||||
language === "text" ||
|
|
||||||
language === "url" ||
|
|
||||||
language === "pairs"
|
|
||||||
) {
|
|
||||||
disableTabIndent = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (format == null && !readOnly) {
|
if (format == null && !readOnly) {
|
||||||
format =
|
format =
|
||||||
language === "json"
|
language === "json"
|
||||||
@@ -181,130 +77,22 @@ function EditorInner({
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
|
// Template dialogs are opened against the editor the tag was clicked in
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
// Use ref so we can update the handler without re-initializing the editor
|
const { setRef } = props;
|
||||||
const handleChange = useRef<EditorProps["onChange"]>(onChange);
|
const handleSetRef = useCallback(
|
||||||
useEffect(() => {
|
(view: EditorView | null) => {
|
||||||
handleChange.current = onChange;
|
viewRef.current = view;
|
||||||
}, [onChange]);
|
setRef?.(view);
|
||||||
|
|
||||||
// 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 });
|
|
||||||
},
|
},
|
||||||
[placeholder],
|
[setRef],
|
||||||
);
|
|
||||||
|
|
||||||
// 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],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onClickFunction = useCallback(
|
const onClickFunction = useCallback(
|
||||||
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
||||||
const show = () => {
|
const show = () => {
|
||||||
if (cm.current === null) return;
|
if (viewRef.current === null) return;
|
||||||
TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
|
TemplateFunctionDialog.show(fn, tagValue, startPos, viewRef.current);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fn.name === "secure") {
|
if (fn.name === "secure") {
|
||||||
@@ -344,455 +132,64 @@ function EditorInner({
|
|||||||
!!autocompleteFunctions,
|
!!autocompleteFunctions,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the language extension when the language changes
|
const languageExtension = useMemo(
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps
|
() =>
|
||||||
useEffect(() => {
|
getLanguageExtension({
|
||||||
if (cm.current === null) return;
|
useTemplating,
|
||||||
const { view, languageCompartment } = cm.current;
|
language,
|
||||||
const ext = getLanguageExtension({
|
lintExtension,
|
||||||
useTemplating,
|
hideGutter,
|
||||||
|
environmentVariables,
|
||||||
|
autocomplete,
|
||||||
|
completionOptions,
|
||||||
|
onClickVariable,
|
||||||
|
onClickMissingVariable,
|
||||||
|
onClickPathParameter,
|
||||||
|
graphQLSchema: graphQLSchema ?? null,
|
||||||
|
}),
|
||||||
|
[
|
||||||
language,
|
language,
|
||||||
lintExtension,
|
lintExtension,
|
||||||
hideGutter,
|
|
||||||
environmentVariables,
|
|
||||||
autocomplete,
|
autocomplete,
|
||||||
completionOptions,
|
environmentVariables,
|
||||||
onClickVariable,
|
onClickVariable,
|
||||||
onClickMissingVariable,
|
onClickMissingVariable,
|
||||||
onClickPathParameter,
|
onClickPathParameter,
|
||||||
graphQLSchema: graphQLSchema ?? null,
|
completionOptions,
|
||||||
});
|
useTemplating,
|
||||||
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
|
graphQLSchema,
|
||||||
}, [
|
hideGutter,
|
||||||
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],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<div className="group relative h-full w-full x-theme-editor bg-surface">
|
<CodeEditor
|
||||||
{cmContainer}
|
{...props}
|
||||||
{decoratedActions && (
|
editableExtensions={mediaValues}
|
||||||
<HStack
|
extensions={extraExtensions}
|
||||||
space={1}
|
format={format}
|
||||||
justifyContent="end"
|
hideGutter={hideGutter}
|
||||||
className={classNames(
|
keyDownHandler={startCompletionOnHotkey}
|
||||||
"absolute bottom-2 left-0 right-0",
|
keymap={settings.editorKeymap}
|
||||||
"pointer-events-none", // No pointer events, so we don't block the editor
|
language={codeEditorLanguage(language)}
|
||||||
)}
|
languageExtension={languageExtension}
|
||||||
>
|
lintExtension={lintExtension}
|
||||||
{decoratedActions}
|
readOnly={readOnly}
|
||||||
</HStack>
|
readOnlyExtensions={largeValues}
|
||||||
)}
|
setRef={handleSetRef}
|
||||||
</div>
|
wrapLines={wrapLines}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExtensions({
|
/** The client's own languages bring their extension along, so CodeEditor needn't know them */
|
||||||
stateKey,
|
function codeEditorLanguage(language: EditorProps["language"]): CodeEditorProps["language"] {
|
||||||
container,
|
return language === "url" || language === "pairs" || language === "timeline" ? null : language;
|
||||||
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);
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholderElFromText = (text: string | undefined) => {
|
function startCompletionOnHotkey(e: KeyboardEvent, view: EditorView): boolean {
|
||||||
const el = document.createElement("div");
|
if (!eventMatchesHotkey(e, "editor.autocomplete")) return false;
|
||||||
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
|
e.preventDefault();
|
||||||
// to do with how Yaak "hacks" it with CSS for single line input.
|
startCompletion(view);
|
||||||
el.innerHTML = text ? text.replaceAll("\n", "<br/>") : " ";
|
return true;
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,141 +1,30 @@
|
|||||||
import {
|
import { linter } from "@codemirror/lint";
|
||||||
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 type { Extension } from "@codemirror/state";
|
import type { Extension } from "@codemirror/state";
|
||||||
import { EditorState } from "@codemirror/state";
|
|
||||||
import {
|
import {
|
||||||
crosshairCursor,
|
closeBracketsExtensions,
|
||||||
drawSelection,
|
getLanguageExtension as getBaseLanguageExtension,
|
||||||
dropCursor,
|
type LanguageSupports,
|
||||||
EditorView,
|
readonlyExtensions as baseReadonlyExtensions,
|
||||||
highlightActiveLineGutter,
|
syntaxExtensions,
|
||||||
highlightSpecialChars,
|
} from "@yaakapp-internal/ui/editor";
|
||||||
keymap,
|
|
||||||
lineNumbers,
|
|
||||||
rectangularSelection,
|
|
||||||
} from "@codemirror/view";
|
|
||||||
import { tags as t } from "@lezer/highlight";
|
|
||||||
import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc";
|
|
||||||
import { graphql } from "cm6-graphql";
|
import { graphql } from "cm6-graphql";
|
||||||
import type { GraphQLSchema } from "graphql";
|
import type { GraphQLSchema } from "graphql";
|
||||||
import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId";
|
import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId";
|
||||||
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
|
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
|
||||||
import { jotaiStore } from "../../../lib/jotai";
|
import { jotaiStore } from "../../../lib/jotai";
|
||||||
import { renderMarkdown } from "../../../lib/markdown";
|
import { renderMarkdown } from "../../../lib/markdown";
|
||||||
import { pluralizeCount } from "../../../lib/pluralize";
|
|
||||||
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
||||||
import type { EditorProps } from "./Editor";
|
import type { EditorProps } from "./Editor";
|
||||||
import { jsonParseLinter } from "./json-lint";
|
import { jsonParseLinter } from "./json-lint";
|
||||||
import { largeValues, mediaValues } from "./largeValues";
|
import { largeValues } from "./largeValues";
|
||||||
import { pairs } from "./pairs/extension";
|
import { pairs } from "./pairs/extension";
|
||||||
import { searchMatchCount } from "./searchMatchCount";
|
|
||||||
import { text } from "./text/extension";
|
|
||||||
import { timeline } from "./timeline/extension";
|
import { timeline } from "./timeline/extension";
|
||||||
import type { TwigCompletionOption } from "./twig/completion";
|
import type { TwigCompletionOption } from "./twig/completion";
|
||||||
import { twig } from "./twig/extension";
|
import { twig } from "./twig/extension";
|
||||||
import { pathParametersPlugin } from "./twig/pathParameters";
|
import { pathParametersPlugin } from "./twig/pathParameters";
|
||||||
import { url } from "./url/extension";
|
import { url } from "./url/extension";
|
||||||
|
|
||||||
export const syntaxHighlightStyle = HighlightStyle.define([
|
const languages: LanguageSupports = { ...syntaxExtensions, url, pairs, timeline };
|
||||||
{
|
|
||||||
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"];
|
|
||||||
|
|
||||||
export function getLanguageExtension({
|
export function getLanguageExtension({
|
||||||
useTemplating,
|
useTemplating,
|
||||||
@@ -157,18 +46,7 @@ export function getLanguageExtension({
|
|||||||
onClickPathParameter: (name: string) => void;
|
onClickPathParameter: (name: string) => void;
|
||||||
completionOptions: TwigCompletionOption[];
|
completionOptions: TwigCompletionOption[];
|
||||||
graphQLSchema: GraphQLSchema | null;
|
graphQLSchema: GraphQLSchema | null;
|
||||||
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">) {
|
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">): Extension {
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GraphQL is a special exception
|
// GraphQL is a special exception
|
||||||
if (language === "graphql") {
|
if (language === "graphql") {
|
||||||
return [
|
return [
|
||||||
@@ -189,155 +67,31 @@ export function getLanguageExtension({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
extraExtensions,
|
[closeBracketsExtensions],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (language === "json") {
|
return getBaseLanguageExtension({
|
||||||
extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
|
language,
|
||||||
extraExtensions.push(
|
hideGutter,
|
||||||
jsoncLanguage.data.of({
|
languages,
|
||||||
commentTokens: { line: "//", block: { open: "/*", close: "*/" } },
|
lintExtension: language === "json" ? (lintExtension ?? linter(jsonParseLinter())) : undefined,
|
||||||
}),
|
extraExtensions: language === "url" ? [pathParametersPlugin(onClickPathParameter)] : [],
|
||||||
);
|
wrap: useTemplating
|
||||||
if (!hideGutter) {
|
? (base, extraExtensions) =>
|
||||||
extraExtensions.push(lintGutter());
|
twig({
|
||||||
}
|
base,
|
||||||
}
|
environmentVariables,
|
||||||
|
completionOptions,
|
||||||
const maybeBase = language ? syntaxExtensions[language] : null;
|
autocomplete,
|
||||||
const base = typeof maybeBase === "function" ? maybeBase() : null;
|
onClickVariable,
|
||||||
if (base == null) {
|
onClickMissingVariable,
|
||||||
return [];
|
onClickPathParameter,
|
||||||
}
|
extraExtensions,
|
||||||
|
})
|
||||||
if (!useTemplating) {
|
: undefined,
|
||||||
return [base, extraExtensions];
|
|
||||||
}
|
|
||||||
|
|
||||||
return twig({
|
|
||||||
base,
|
|
||||||
environmentVariables,
|
|
||||||
completionOptions,
|
|
||||||
autocomplete,
|
|
||||||
onClickVariable,
|
|
||||||
onClickMissingVariable,
|
|
||||||
onClickPathParameter,
|
|
||||||
extraExtensions,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys.
|
// Nobody is editing this, so every rule applies
|
||||||
// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings.
|
export const readonlyExtensions = [...baseReadonlyExtensions, largeValues];
|
||||||
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]),
|
|
||||||
];
|
|
||||||
@@ -1,59 +1,15 @@
|
|||||||
import type { Diagnostic } from "@codemirror/lint";
|
import {
|
||||||
import type { EditorView } from "@codemirror/view";
|
type JsonLintOptions,
|
||||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
jsonParseLinter as baseJsonParseLinter,
|
||||||
|
} from "@yaakapp-internal/ui/editor";
|
||||||
import { replaceTemplateTags } from "../../../lib/templateTags";
|
import { replaceTemplateTags } from "../../../lib/templateTags";
|
||||||
|
|
||||||
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
export function jsonParseLinter(options?: Omit<JsonLintOptions, "escape">) {
|
||||||
const MESSAGES: Record<string, string> = {
|
return baseJsonParseLinter({ ...options, escape: escapeTemplateTags });
|
||||||
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?: JsonLintOptions) {
|
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||||
return (view: EditorView): Diagnostic[] => {
|
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||||
const doc = view.state.doc.toString();
|
function escapeTemplateTags(doc: string): string {
|
||||||
if (doc.trim() === "") return [];
|
return replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start));
|
||||||
|
|
||||||
// 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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { EditorState } from "@codemirror/state";
|
import { EditorState } from "@codemirror/state";
|
||||||
import { jsonc } from "@shopify/lang-jsonc";
|
import { jsonc } from "@shopify/lang-jsonc";
|
||||||
import { text } from "./text/extension";
|
|
||||||
import { twig } from "./twig/extension";
|
import { twig } from "./twig/extension";
|
||||||
import { describe, expect, test } from "vite-plus/test";
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
MAX_VISIBLE_LINE_CHARS,
|
MAX_VISIBLE_LINE_CHARS,
|
||||||
} from "./largeValues";
|
} from "./largeValues";
|
||||||
import type { SniffedValue } from "./sniffValue";
|
import type { SniffedValue } from "./sniffValue";
|
||||||
|
import { text } from "@yaakapp-internal/ui/editor";
|
||||||
|
|
||||||
const BIG = "A".repeat(1_000_000);
|
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} />;
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import { json } from "@codemirror/lang-json";
|
|||||||
import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
|
import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
|
||||||
import type { EditorView } from "@codemirror/view";
|
import type { EditorView } from "@codemirror/view";
|
||||||
import { describe, expect, test } from "vite-plus/test";
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
import { singleLineExtensions } from "../singleLine";
|
|
||||||
import { twigCompletion, type TwigCompletionOption } from "./completion";
|
import { twigCompletion, type TwigCompletionOption } from "./completion";
|
||||||
import { parser } from "./twig";
|
import { parser } from "./twig";
|
||||||
|
import { singleLineExtensions } from "@yaakapp-internal/ui/editor";
|
||||||
|
|
||||||
const options: TwigCompletionOption[] = [
|
const options: TwigCompletionOption[] = [
|
||||||
{ name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} },
|
{ 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 { WrappedEnvironmentVariable } from "../../../../hooks/useEnvironmentVariables";
|
||||||
import type { GenericCompletionConfig } from "../genericCompletion";
|
import type { GenericCompletionConfig } from "../genericCompletion";
|
||||||
import { genericCompletion } from "../genericCompletion";
|
import { genericCompletion } from "../genericCompletion";
|
||||||
import { textLanguage } from "../text/extension";
|
|
||||||
import type { TwigCompletionOption } from "./completion";
|
import type { TwigCompletionOption } from "./completion";
|
||||||
import { twigCompletion } from "./completion";
|
import { twigCompletion } from "./completion";
|
||||||
import { templateTagsPlugin } from "./templateTags";
|
import { templateTagsPlugin } from "./templateTags";
|
||||||
import { parser as twigParser } from "./twig";
|
import { parser as twigParser } from "./twig";
|
||||||
|
import { textLanguage } from "@yaakapp-internal/ui/editor";
|
||||||
|
|
||||||
export function twig({
|
export function twig({
|
||||||
base,
|
base,
|
||||||
|
|||||||
@@ -1,91 +1,22 @@
|
|||||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
|
||||||
import { settingsAtom } 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 { useAtomValue } from "jotai";
|
||||||
import { memo } from "react";
|
import { type ComponentProps, memo } from "react";
|
||||||
|
|
||||||
interface Props {
|
export const HttpMethodTag = memo(function HttpMethodTag(
|
||||||
request: HttpRequest | GrpcRequest | WebsocketRequest;
|
props: Omit<ComponentProps<typeof BaseHttpMethodTag>, "colored">,
|
||||||
className?: string;
|
) {
|
||||||
short?: boolean;
|
const settings = useAtomValue(settingsAtom);
|
||||||
noAlias?: boolean;
|
return <BaseHttpMethodTag {...props} colored={settings.coloredMethods} />;
|
||||||
}
|
|
||||||
|
|
||||||
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 function HttpMethodTagRaw({
|
export function HttpMethodTagRaw({
|
||||||
className,
|
|
||||||
method,
|
|
||||||
short,
|
|
||||||
forceColor,
|
forceColor,
|
||||||
}: {
|
...props
|
||||||
method: string;
|
}: Omit<ComponentProps<typeof BaseHttpMethodTagRaw>, "colored"> & { forceColor?: boolean }) {
|
||||||
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();
|
|
||||||
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const colored = forceColor || settings.coloredMethods;
|
return <BaseHttpMethodTagRaw {...props} 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
import type { EditorView } from "@codemirror/view";
|
import {
|
||||||
import type { Color } from "@yaakapp-internal/plugins";
|
HStack,
|
||||||
import { HStack, Icon, type IconProps } from "@yaakapp-internal/ui";
|
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 classNames from "classnames";
|
||||||
import type { ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { createFastMutation } from "../../hooks/useFastMutation";
|
import { createFastMutation } from "../../hooks/useFastMutation";
|
||||||
import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled";
|
import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled";
|
||||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|
||||||
import { copyToClipboard } from "../../lib/copy";
|
import { copyToClipboard } from "../../lib/copy";
|
||||||
import {
|
import {
|
||||||
analyzeTemplate,
|
analyzeTemplate,
|
||||||
convertTemplateToInsecure,
|
convertTemplateToInsecure,
|
||||||
convertTemplateToSecure,
|
convertTemplateToSecure,
|
||||||
} from "../../lib/encryption";
|
} from "../../lib/encryption";
|
||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import {
|
import {
|
||||||
setupOrConfigureEncryption,
|
setupOrConfigureEncryption,
|
||||||
withEncryptionEnabled,
|
withEncryptionEnabled,
|
||||||
@@ -25,69 +30,30 @@ import type { EditorProps } from "./Editor/Editor";
|
|||||||
import type { GenericCompletion } from "./Editor/genericCompletion";
|
import type { GenericCompletion } from "./Editor/genericCompletion";
|
||||||
import { Editor } from "./Editor/LazyEditor";
|
import { Editor } from "./Editor/LazyEditor";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
import { IconTooltip } from "./IconTooltip";
|
|
||||||
import { Label } from "./Label";
|
|
||||||
|
|
||||||
export type InputProps = Pick<
|
export type { InputHandle };
|
||||||
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 interface InputHandle {
|
export type InputProps = Omit<
|
||||||
focus: () => void;
|
BaseUiInputProps,
|
||||||
isFocused: () => boolean;
|
"language" | "extensions" | "renderEditor" | "renderOptionsPicker"
|
||||||
value: () => string;
|
> &
|
||||||
selectAll: () => void;
|
Pick<
|
||||||
dispatch: EditorView["dispatch"];
|
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) {
|
export function Input({ type, ...props }: InputProps) {
|
||||||
// If it's a password and template functions are supported (ie. secure(...)) then
|
// 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({
|
function BaseInput({
|
||||||
className,
|
autocomplete,
|
||||||
containerClassName,
|
autocompleteFunctions,
|
||||||
defaultValue,
|
autocompleteVariables,
|
||||||
disableObscureToggle,
|
extraExtensions,
|
||||||
disabled,
|
forcedEnvironmentId,
|
||||||
forceUpdateKey,
|
language,
|
||||||
fullHeight,
|
|
||||||
help,
|
|
||||||
hideLabel,
|
|
||||||
inputWrapperClassName,
|
|
||||||
label,
|
|
||||||
labelClassName,
|
|
||||||
labelPosition = "top",
|
|
||||||
leftSlot,
|
|
||||||
multiLine,
|
|
||||||
onBlur,
|
|
||||||
onChange,
|
|
||||||
onFocus,
|
|
||||||
onPaste,
|
|
||||||
onPasteOverwrite,
|
|
||||||
placeholder,
|
|
||||||
readOnly,
|
|
||||||
required,
|
|
||||||
rightSlot,
|
|
||||||
showOptionsPicker,
|
showOptionsPicker,
|
||||||
size = "md",
|
|
||||||
stateKey,
|
|
||||||
tint,
|
|
||||||
type = "text",
|
|
||||||
validate,
|
|
||||||
wrapLines,
|
|
||||||
setRef,
|
|
||||||
...props
|
...props
|
||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
const [focused, setFocused] = useState(false);
|
const renderEditor = useCallback(
|
||||||
const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
|
(editorProps: CodeEditorProps) => (
|
||||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
<Editor
|
||||||
const editorRef = useRef<EditorView | null>(null);
|
{...editorProps}
|
||||||
const skipNextFocus = useRef<boolean>(false);
|
language={language}
|
||||||
|
autocomplete={autocomplete}
|
||||||
const handle = useMemo<InputHandle>(
|
autocompleteFunctions={autocompleteFunctions}
|
||||||
() => ({
|
autocompleteVariables={autocompleteVariables}
|
||||||
focus: () => {
|
extraExtensions={extraExtensions}
|
||||||
if (editorRef.current == null) return;
|
forcedEnvironmentId={forcedEnvironmentId}
|
||||||
const anchor = editorRef.current.state.doc.length;
|
/>
|
||||||
skipNextFocus.current = true;
|
),
|
||||||
editorRef.current.focus();
|
[
|
||||||
editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true });
|
language,
|
||||||
},
|
autocomplete,
|
||||||
isFocused: () => editorRef.current?.hasFocus ?? false,
|
autocompleteFunctions,
|
||||||
value: () => editorRef.current?.state.doc.toString() ?? "",
|
autocompleteVariables,
|
||||||
dispatch: (...args) => {
|
extraExtensions,
|
||||||
// oxlint-disable-next-line no-explicit-any
|
forcedEnvironmentId,
|
||||||
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 },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Replace the whole value with a picked option. Dispatching runs it through the editor's
|
const pickerOptions = showOptionsPicker && !props.disabled ? (autocomplete?.options ?? []) : [];
|
||||||
// onChange, same as typing, so callers see one consistent change path.
|
const renderOptionsPicker =
|
||||||
const insertOption = useCallback((value: string) => {
|
pickerOptions.length > 0
|
||||||
const view = editorRef.current;
|
? (insert: (value: string) => void) => (
|
||||||
if (view == null) return;
|
<OptionsPickerDropdown options={pickerOptions} onSelect={insert} />
|
||||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
|
)
|
||||||
view.focus();
|
: undefined;
|
||||||
}, []);
|
|
||||||
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<BaseUiInput {...props} renderEditor={renderEditor} renderOptionsPicker={renderOptionsPicker} />
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,10 +149,6 @@ function OptionsPickerDropdown({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRequire(v: string) {
|
|
||||||
return v.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PasswordFieldType = "text" | "encrypted";
|
type PasswordFieldType = "text" | "encrypted";
|
||||||
|
|
||||||
function EncryptionInput({
|
function EncryptionInput({
|
||||||
|
|||||||
@@ -1,106 +1,10 @@
|
|||||||
import classNames from "classnames";
|
import { KeyValueRow as BaseKeyValueRow, type KeyValueRowProps } from "@yaakapp-internal/ui";
|
||||||
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
import { copyToClipboard } from "../../lib/copy";
|
||||||
import { CopyIconButton } from "../CopyIconButton";
|
|
||||||
|
|
||||||
interface Props {
|
export function KeyValueRow(props: Omit<KeyValueRowProps, "copy">) {
|
||||||
children:
|
return <BaseKeyValueRow {...props} copy={copy} />;
|
||||||
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
|
||||||
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
|
||||||
selectable?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyValueRows({ children, selectable }: Props) {
|
function copy(text: string) {
|
||||||
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
copyToClipboard(text, { disableToast: true });
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -14,9 +14,8 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
|
|||||||
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
|
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
|
||||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||||
import { useToggle } from "../../hooks/useToggle";
|
import { useToggle } from "../../hooks/useToggle";
|
||||||
import { languageFromContentType } from "../../lib/contentType";
|
|
||||||
import { showDialog } from "../../lib/dialog";
|
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 { showPrompt } from "../../lib/prompt";
|
||||||
import { SelectFile } from "../SelectFile";
|
import { SelectFile } from "../SelectFile";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import type { Pair, PairWithId } from "./PairEditor";
|
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)
|
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
|
||||||
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
|
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 classNames from "classnames";
|
||||||
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
|
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -10,11 +10,9 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|
||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
import type { InputProps } from "./Input";
|
import type { InputProps } from "./Input";
|
||||||
import { Label } from "./Label";
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
|
||||||
export type PlainInputProps = Omit<
|
export type PlainInputProps = Omit<
|
||||||
InputProps,
|
InputProps,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
|
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import { DynamicForm } from "../DynamicForm";
|
import { DynamicForm } from "../DynamicForm";
|
||||||
import { DialogFooter } from "./Dialog";
|
import { DialogFooter } from "./Dialog";
|
||||||
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
|
||||||
export interface PromptProps {
|
export interface PromptProps {
|
||||||
inputs: FormInput[];
|
inputs: FormInput[];
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import type { IconProps } from "@yaakapp-internal/ui";
|
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 classNames from "classnames";
|
||||||
import { type ReactNode, useRef } from "react";
|
import { type ReactNode, useRef } from "react";
|
||||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|
||||||
import { generateId } from "../../lib/generateId";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||||
import { IconTooltip } from "./IconTooltip";
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
import { Label } from "./Label";
|
|
||||||
|
|
||||||
interface Props<T extends string> {
|
interface Props<T extends string> {
|
||||||
options: { value: T; label: string; icon?: IconProps["icon"] }[];
|
options: { value: T; label: string; icon?: IconProps["icon"] }[];
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { HStack, Label } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ButtonProps } from "./Button";
|
import type { ButtonProps } from "./Button";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Label } from "./Label";
|
|
||||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
import type { RadioDropdownItem } from "./RadioDropdown";
|
||||||
import { RadioDropdown } from "./RadioDropdown";
|
import { RadioDropdown } from "./RadioDropdown";
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,27 @@
|
|||||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
|
|
||||||
import {
|
import {
|
||||||
closestCenter,
|
Icon,
|
||||||
DndContext,
|
type StoredValue,
|
||||||
DragOverlay,
|
TabContent as BaseTabContent,
|
||||||
PointerSensor,
|
type TabContentProps,
|
||||||
useDraggable,
|
type TabItem as BaseTabItem,
|
||||||
useDroppable,
|
Tabs as BaseTabs,
|
||||||
useSensor,
|
type TabsProps as BaseTabsProps,
|
||||||
useSensors,
|
type TabsRef,
|
||||||
} from "@dnd-kit/core";
|
type TabsStorage,
|
||||||
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode } from "react";
|
||||||
import {
|
import { forwardRef, memo, useMemo } from "react";
|
||||||
forwardRef,
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useKeyValue } from "../../../hooks/useKeyValue";
|
import { useKeyValue } from "../../../hooks/useKeyValue";
|
||||||
import { computeSideForDragMove, DropMarker } from "@yaakapp-internal/ui";
|
|
||||||
import { fireAndForget } from "../../../lib/fireAndForget";
|
|
||||||
import { ErrorBoundary } from "../../ErrorBoundary";
|
import { ErrorBoundary } from "../../ErrorBoundary";
|
||||||
import type { ButtonProps } from "../Button";
|
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import type { RadioDropdownProps } from "../RadioDropdown";
|
import type { RadioDropdownProps } from "../RadioDropdown";
|
||||||
import { RadioDropdown } from "../RadioDropdown";
|
import { RadioDropdown } from "../RadioDropdown";
|
||||||
|
|
||||||
|
export type { TabsRef };
|
||||||
|
|
||||||
export type TabItem =
|
export type TabItem =
|
||||||
| {
|
| Extract<BaseTabItem, { label: string }>
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
hidden?: boolean;
|
|
||||||
leftSlot?: ReactNode;
|
|
||||||
rightSlot?: ReactNode;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
value: string;
|
value: string;
|
||||||
options: Omit<RadioDropdownProps, "children">;
|
options: Omit<RadioDropdownProps, "children">;
|
||||||
@@ -46,445 +29,34 @@ export type TabItem =
|
|||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TabsStorage {
|
type Props = Omit<BaseTabsProps, "tabs" | "useStoredValue"> & { tabs: TabItem[] };
|
||||||
order: string[];
|
|
||||||
activeTabs: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TabsRef {
|
export const Tabs = forwardRef<TabsRef, Props>(function Tabs({ tabs, ...props }, ref) {
|
||||||
/** Programmatically set the active tab */
|
const baseTabs = useMemo(() => tabs.map(toBaseTab), [tabs]);
|
||||||
setActiveTab: (value: string) => void;
|
return <BaseTabs ref={ref} tabs={baseTabs} useStoredValue={useTabsStoredValue} {...props} />;
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TabButtonProps {
|
export const TabContent = memo(function TabContent(props: Omit<TabContentProps, "ErrorBoundary">) {
|
||||||
tab: TabItem;
|
return <BaseTabContent {...props} ErrorBoundary={ErrorBoundary} />;
|
||||||
isActive: boolean;
|
});
|
||||||
addBorders?: boolean;
|
|
||||||
layout: "horizontal" | "vertical";
|
|
||||||
reorderable: boolean;
|
|
||||||
isDragging: boolean;
|
|
||||||
onChangeValue?: (value: string) => void;
|
|
||||||
overlay?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({
|
function toBaseTab(tab: TabItem): BaseTabItem {
|
||||||
tab,
|
if (!("options" in tab)) return tab;
|
||||||
isActive,
|
const { options } = tab;
|
||||||
addBorders,
|
return {
|
||||||
layout,
|
value: tab.value,
|
||||||
reorderable,
|
leftSlot: tab.leftSlot,
|
||||||
isDragging,
|
rightSlot: tab.rightSlot,
|
||||||
onChangeValue,
|
renderButton: ({ buttonProps, isActive }) => {
|
||||||
overlay = false,
|
const option = options.items.find((i) => "value" in i && i.value === options.value);
|
||||||
}: 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);
|
|
||||||
return (
|
return (
|
||||||
<RadioDropdown
|
<RadioDropdown
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
items={tab.options.items}
|
items={options.items}
|
||||||
itemsAfter={tab.options.itemsAfter}
|
itemsAfter={options.itemsAfter}
|
||||||
itemsBefore={tab.options.itemsBefore}
|
itemsBefore={options.itemsBefore}
|
||||||
value={tab.options.value}
|
value={options.value}
|
||||||
onChange={tab.options.onChange}
|
onChange={options.onChange}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
leftSlot={tab.leftSlot}
|
leftSlot={tab.leftSlot}
|
||||||
@@ -501,7 +73,7 @@ function TabButton({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{...btnProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
{option && "shortLabel" in option && option.shortLabel
|
{option && "shortLabel" in option && option.shortLabel
|
||||||
? option.shortLabel
|
? option.shortLabel
|
||||||
@@ -509,52 +81,19 @@ function TabButton({
|
|||||||
</Button>
|
</Button>
|
||||||
</RadioDropdown>
|
</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 {
|
function useTabsStoredValue<T extends StoredValue>(key: string | string[] | null, fallback: T) {
|
||||||
value: string;
|
const { value, set } = useKeyValue<T>({
|
||||||
children: ReactNode;
|
namespace: "no_sync",
|
||||||
className?: string;
|
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.
|
* 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).
|
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
|
||||||
|
|||||||
@@ -24,11 +24,9 @@ import { useAtomValue } from "jotai";
|
|||||||
import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from "react";
|
import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from "react";
|
||||||
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useClickOutside } from "../../hooks/useClickOutside";
|
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 { Icon, useDebouncedValue } from "@yaakapp-internal/ui";
|
||||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|
||||||
import { jotaiStore } from "../../lib/jotai";
|
import { jotaiStore } from "../../lib/jotai";
|
||||||
import { CountBadge } from "../core/CountBadge";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { PlainInput } from "../core/PlainInput";
|
import { PlainInput } from "../core/PlainInput";
|
||||||
import { Markdown } from "../Markdown";
|
import { Markdown } from "../Markdown";
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useAtom } from "jotai";
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
||||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|
||||||
import { showDialog } from "../../lib/dialog";
|
import { showDialog } from "../../lib/dialog";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import type { DropdownItem } from "../core/Dropdown";
|
import type { DropdownItem } from "../core/Dropdown";
|
||||||
@@ -15,7 +14,7 @@ import { Editor } from "../core/Editor/LazyEditor";
|
|||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||||
import { RadioDropdown } 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 { Separator } from "../core/Separator";
|
||||||
import { tryFormatGraphql } from "../../lib/formatters";
|
import { tryFormatGraphql } from "../../lib/formatters";
|
||||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
|
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 classNames from "classnames";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { Fragment, useMemo, useState } 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 { EventDetailHeader, EventViewer } from "../core/EventViewer";
|
||||||
import { EventViewerRow } from "../core/EventViewerRow";
|
import { EventViewerRow } from "../core/EventViewerRow";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { IconTooltip } from "../core/IconTooltip";
|
|
||||||
import { Input } from "../core/Input";
|
import { Input } from "../core/Input";
|
||||||
import { Select } from "../core/Select";
|
import { Select } from "../core/Select";
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
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 { useCallback } from "react";
|
||||||
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
||||||
|
import { useRecentFilters } from "../../hooks/useRecentFilters";
|
||||||
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
|
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
|
||||||
import { useResponseFilter } from "../../hooks/useResponseFilter";
|
|
||||||
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
||||||
import { languageFromContentType } from "../../lib/contentType";
|
|
||||||
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
import { EmptyStateText } from "../EmptyStateText";
|
||||||
|
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||||
import { TextViewer } from "./TextViewer";
|
import { TextViewer } from "./TextViewer";
|
||||||
import { WebPageViewer } from "./WebPageViewer";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -48,22 +48,36 @@ export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Prop
|
|||||||
interface HttpTextViewerProps {
|
interface HttpTextViewerProps {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
text: string;
|
text: string;
|
||||||
language: EditorProps["language"];
|
language: CodeEditorProps["language"];
|
||||||
pretty: boolean;
|
pretty: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const filter = useResponseFilter({
|
const filterStateKey = `response.body.${response.requestId}`;
|
||||||
stateKey: `response.body.${response.requestId}`,
|
// Shares the response body query's cache, so applying and showing a filter cost one RPC
|
||||||
// Shares the display query's cache entry, so the verdict costs no extra RPC
|
const filterText = useCallback(
|
||||||
runFilter: useCallback(
|
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
|
||||||
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
|
[queryClient, response],
|
||||||
[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>
|
||||||
),
|
),
|
||||||
});
|
[recentFilters, removeFilter, togglePin, clearFilters],
|
||||||
const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter });
|
);
|
||||||
const saveResponse = useSaveResponse(response);
|
const saveResponse = useSaveResponse(response);
|
||||||
const copyResponse = useCopyHttpResponse(response);
|
const copyResponse = useCopyHttpResponse(response);
|
||||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
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"
|
className="border !border-border-subtle"
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
filter={filter}
|
filterText={filterText}
|
||||||
filterResult={{
|
filterStateKey={filterStateKey}
|
||||||
data: filteredBody.data,
|
onFilterApplied={addFilter}
|
||||||
isPending: filteredBody.isPending,
|
filterLeftSlot={filterLeftSlot}
|
||||||
error: !!filteredBody.error,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,136 +1,34 @@
|
|||||||
import { type MultipartPart, parseMultipart } from "@mjackson/multipart-parser";
|
import {
|
||||||
import { lazy, Suspense, useMemo } from "react";
|
LoadingIcon,
|
||||||
import { languageFromContentType } from "../../lib/contentType";
|
MultipartViewer as BaseMultipartViewer,
|
||||||
import { Banner, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
type MultipartViewerProps,
|
||||||
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
} from "@yaakapp-internal/ui";
|
||||||
import { AudioViewer } from "./AudioViewer";
|
import { lazy, Suspense } from "react";
|
||||||
import { CsvViewer } from "./CsvViewer";
|
import { renderEditor } from "../core/Editor/renderEditor";
|
||||||
import { ImageViewer } from "./ImageViewer";
|
import { ErrorBoundary } from "../ErrorBoundary";
|
||||||
import { SvgViewer } from "./SvgViewer";
|
import { formatText, textViewerExtensions } from "./TextViewer";
|
||||||
import { TextViewer } from "./TextViewer";
|
|
||||||
import { VideoViewer } from "./VideoViewer";
|
|
||||||
import { WebPageViewer } from "./WebPageViewer";
|
|
||||||
|
|
||||||
const PdfViewer = lazy(() => import("./PdfViewer").then((m) => ({ default: m.PdfViewer })));
|
const PdfViewer = lazy(() => import("./PdfViewer").then((m) => ({ default: m.PdfViewer })));
|
||||||
|
|
||||||
interface Props {
|
const textViewerProps = { extensions: textViewerExtensions, formatText, renderEditor };
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function MultipartViewer(
|
||||||
|
props: Omit<MultipartViewerProps, "renderPdf" | "textViewerProps" | "ErrorBoundary">,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<BaseMultipartViewer
|
||||||
addBorders
|
{...props}
|
||||||
label="Multipart"
|
renderPdf={renderPdf}
|
||||||
layout="horizontal"
|
textViewerProps={textViewerProps}
|
||||||
tabListClassName="border-r border-r-border -ml-3"
|
ErrorBoundary={ErrorBoundary}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Part({ part }: { part: MultipartPart }) {
|
function renderPdf(data: Uint8Array) {
|
||||||
const mimeType = part.headers.contentType.mediaType ?? null;
|
return (
|
||||||
const contentTypeHeader = part.headers.get("content-type");
|
<Suspense fallback={<LoadingIcon />}>
|
||||||
|
<PdfViewer data={data} />
|
||||||
const { uint8Array, content, detectedLanguage } = useMemo(() => {
|
</Suspense>
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
@@ -1,222 +1,33 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||||
import { Children, useCallback, useMemo, useState } from "react";
|
import { TextViewer as BaseTextViewer, type TextViewerProps } from "@yaakapp-internal/ui";
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
import { tryFormatJson, tryFormatXml } from "../../lib/formatters";
|
||||||
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 { hyperlink } from "../core/Editor/hyperlink/extension";
|
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { renderEditor } from "../core/Editor/renderEditor";
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { Input } from "../core/Input";
|
|
||||||
import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar";
|
|
||||||
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
|
||||||
|
|
||||||
interface Props {
|
export const textViewerExtensions = [hyperlink];
|
||||||
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 function TextViewer(
|
||||||
|
props: Omit<TextViewerProps, "extensions" | "formatText" | "renderEditor">,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<BaseTextViewer
|
||||||
<Editor
|
{...props}
|
||||||
readOnly
|
extensions={textViewerExtensions}
|
||||||
className={className}
|
formatText={formatText}
|
||||||
defaultValue={body}
|
renderEditor={renderEditor}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert \uXXXX to actual Unicode characters */
|
export async function formatText(
|
||||||
function decodeUnicodeLiterals(text: string): string {
|
text: string,
|
||||||
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
language: CodeEditorProps["language"],
|
||||||
const charCode = Number.parseInt(hex, 16);
|
): Promise<string> {
|
||||||
return String.fromCharCode(charCode);
|
if (language === "json") {
|
||||||
});
|
return tryFormatJson(text);
|
||||||
|
}
|
||||||
|
if (language === "xml" || language === "html") {
|
||||||
|
return tryFormatXml(text);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { Folder } from "@yaakapp-internal/models";
|
import type { Folder } from "@yaakapp-internal/models";
|
||||||
import { modelTypeLabel, patchModel } 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 { useMemo } from "react";
|
||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||||
import { IconTooltip } from "../components/core/IconTooltip";
|
|
||||||
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
|
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
|
||||||
import type { TabItem } from "../components/core/Tabs/Tabs";
|
import type { TabItem } from "../components/core/Tabs/Tabs";
|
||||||
import { capitalize } from "../lib/capitalize";
|
import { capitalize } from "../lib/capitalize";
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { createFolder } from "../commands/commands";
|
|||||||
import type { DropdownItem } from "../components/core/Dropdown";
|
import type { DropdownItem } from "../components/core/Dropdown";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
||||||
import { generateId } from "../lib/generateId";
|
|
||||||
import { BODY_TYPE_GRAPHQL } from "../lib/model_util";
|
import { BODY_TYPE_GRAPHQL } from "../lib/model_util";
|
||||||
import { activeRequestAtom } from "./useActiveRequest";
|
import { activeRequestAtom } from "./useActiveRequest";
|
||||||
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
|
||||||
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
|
||||||
export function useCreateDropdownItems({
|
export function useCreateDropdownItems({
|
||||||
hideFolder,
|
hideFolder,
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { useAtomValue } from "jotai";
|
|||||||
import { showAlert } from "../lib/alert";
|
import { showAlert } from "../lib/alert";
|
||||||
import { showConfirmDelete } from "../lib/confirm";
|
import { showConfirmDelete } from "../lib/confirm";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
|
||||||
import { useFastMutation } from "./useFastMutation";
|
import { useFastMutation } from "./useFastMutation";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
|
||||||
export function useDeleteSendHistory() {
|
export function useDeleteSendHistory() {
|
||||||
const httpResponses = useAtomValue(httpResponsesAtom);
|
const httpResponses = useAtomValue(httpResponsesAtom);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { CountBadge } from "../components/core/CountBadge";
|
|
||||||
import type { TabItem } from "../components/core/Tabs/Tabs";
|
import type { TabItem } from "../components/core/Tabs/Tabs";
|
||||||
import type { HeaderModel } from "./useInheritedHeaders";
|
import type { HeaderModel } from "./useInheritedHeaders";
|
||||||
import { useInheritedHeaders } from "./useInheritedHeaders";
|
import { useInheritedHeaders } from "./useInheritedHeaders";
|
||||||
|
import { CountBadge } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
export function useHeadersTab<T extends string>(
|
export function useHeadersTab<T extends string>(
|
||||||
tabValue: T,
|
tabValue: T,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { generateId } from "../lib/generateId";
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
|
||||||
export function useRandomKey(initialValue?: string) {
|
export function useRandomKey(initialValue?: string) {
|
||||||
const [value, setValue] = useState<string>(initialValue ?? generateId());
|
const [value, setValue] = useState<string>(initialValue ?? generateId());
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import type { ModelPayload } from "@yaakapp-internal/models";
|
import type { ModelPayload } from "@yaakapp-internal/models";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { generateId } from "../lib/generateId";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
|
||||||
const requestUpdateKeyAtom = atom<Record<string, string>>({});
|
const requestUpdateKeyAtom = atom<Record<string, string>>({});
|
||||||
|
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { createGlobalState } from "react-use";
|
|
||||||
import type { RecentFilter } from "./useRecentFilters";
|
|
||||||
import { useRecentFilters } from "./useRecentFilters";
|
|
||||||
|
|
||||||
/** What's typed in the filter box. `null` means the filter box is closed */
|
|
||||||
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
|
|
||||||
|
|
||||||
/** What's actually applied to the response. Only changes on an explicit apply */
|
|
||||||
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
|
|
||||||
|
|
||||||
export interface ResponseFilterApi {
|
|
||||||
stateKey: string | null;
|
|
||||||
/** Draft text in the filter box, or `null` when the box is closed */
|
|
||||||
filterText: string | null;
|
|
||||||
/** The expression currently filtering the response */
|
|
||||||
appliedFilter: string | null;
|
|
||||||
isSearching: boolean;
|
|
||||||
/** The box holds an expression that isn't the one currently applied */
|
|
||||||
isDirty: boolean;
|
|
||||||
/** Bumped when the (uncontrolled) filter input must re-read its defaultValue */
|
|
||||||
filterUpdateKey: number;
|
|
||||||
setFilterText: (value: string | null) => void;
|
|
||||||
/** Apply the expression to the response, recording it if the filter accepts it */
|
|
||||||
applyFilter: (value: string) => void;
|
|
||||||
/** Like applyFilter, but also replaces what's shown in the filter box */
|
|
||||||
replaceFilter: (value: string) => void;
|
|
||||||
toggleSearch: () => void;
|
|
||||||
recentFilters: RecentFilter[];
|
|
||||||
removeRecentFilter: (value: string) => void;
|
|
||||||
togglePinRecentFilter: (value: string) => void;
|
|
||||||
clearRecentFilters: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draft/applied state and history for a response filter (JSONPath/XPath).
|
|
||||||
*
|
|
||||||
* History records at most one entry per apply gesture, and only after `runFilter`
|
|
||||||
* confirms the plugin accepts the expression — the plugin is the sole judge of
|
|
||||||
* validity. Because nothing but the gesture ever writes, refetches can't resurrect
|
|
||||||
* deleted entries and a gesture can't record into another request's history.
|
|
||||||
*/
|
|
||||||
export function useResponseFilter({
|
|
||||||
stateKey,
|
|
||||||
runFilter,
|
|
||||||
}: {
|
|
||||||
stateKey: string | null;
|
|
||||||
/** Evaluate an expression, rejecting if the filter plugin reports an error */
|
|
||||||
runFilter: (filter: string) => Promise<unknown>;
|
|
||||||
}): ResponseFilterApi {
|
|
||||||
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
|
|
||||||
const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap();
|
|
||||||
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
|
|
||||||
const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null;
|
|
||||||
|
|
||||||
const setFilterText = useCallback(
|
|
||||||
(v: string | null) => {
|
|
||||||
if (!stateKey) return;
|
|
||||||
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
|
|
||||||
},
|
|
||||||
[stateKey, setFilterTextMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setAppliedFilter = useCallback(
|
|
||||||
(v: string | null) => {
|
|
||||||
if (!stateKey) return;
|
|
||||||
setAppliedFilterMap((m) => ({ ...m, [stateKey]: v }));
|
|
||||||
},
|
|
||||||
[stateKey, setAppliedFilterMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
recentFilters,
|
|
||||||
addFilter,
|
|
||||||
removeFilter: removeRecentFilter,
|
|
||||||
togglePin: togglePinRecentFilter,
|
|
||||||
clearFilters: clearRecentFilters,
|
|
||||||
} = useRecentFilters(stateKey);
|
|
||||||
|
|
||||||
const applyFilter = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setFilterText(value);
|
|
||||||
const applied = value.trim() === "" ? null : value.trim();
|
|
||||||
setAppliedFilter(applied);
|
|
||||||
if (applied == null) return;
|
|
||||||
runFilter(applied).then(
|
|
||||||
() => addFilter(applied),
|
|
||||||
() => {}, // Rejected by the filter plugin — don't record
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[setFilterText, setAppliedFilter, runFilter, addFilter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
|
||||||
const replaceFilter = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
applyFilter(value);
|
|
||||||
setFilterUpdateKey((k) => k + 1);
|
|
||||||
},
|
|
||||||
[applyFilter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSearching = filterText != null;
|
|
||||||
const toggleSearch = useCallback(() => {
|
|
||||||
if (isSearching) {
|
|
||||||
setFilterText(null);
|
|
||||||
setAppliedFilter(null);
|
|
||||||
} else {
|
|
||||||
setFilterText("");
|
|
||||||
}
|
|
||||||
}, [isSearching, setFilterText, setAppliedFilter]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
stateKey,
|
|
||||||
filterText,
|
|
||||||
appliedFilter,
|
|
||||||
isSearching,
|
|
||||||
isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""),
|
|
||||||
filterUpdateKey,
|
|
||||||
setFilterText,
|
|
||||||
applyFilter,
|
|
||||||
replaceFilter,
|
|
||||||
toggleSearch,
|
|
||||||
recentFilters,
|
|
||||||
removeRecentFilter,
|
|
||||||
togglePinRecentFilter,
|
|
||||||
clearRecentFilters,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,59 +1,4 @@
|
|||||||
import { MIMEType } from "whatwg-mimetype";
|
import { MIMEType } from "whatwg-mimetype";
|
||||||
import type { EditorProps } from "../components/core/Editor/Editor";
|
|
||||||
|
|
||||||
export function languageFromContentType(
|
|
||||||
contentType: string | null,
|
|
||||||
content: string | null = null,
|
|
||||||
): EditorProps["language"] {
|
|
||||||
const justContentType = contentType?.split(";")[0] ?? contentType ?? "";
|
|
||||||
if (justContentType.includes("json")) {
|
|
||||||
return "json";
|
|
||||||
}
|
|
||||||
if (justContentType.includes("xml")) {
|
|
||||||
return "xml";
|
|
||||||
}
|
|
||||||
if (justContentType.includes("html")) {
|
|
||||||
const detected = languageFromContent(content);
|
|
||||||
if (detected === "xml") {
|
|
||||||
// If it's detected as XML, but is already HTML, don't change it
|
|
||||||
return "html";
|
|
||||||
}
|
|
||||||
return detected;
|
|
||||||
}
|
|
||||||
if (justContentType.includes("javascript")) {
|
|
||||||
// Sometimes `application/javascript` returns JSON, so try detecting that
|
|
||||||
return languageFromContent(content, "javascript");
|
|
||||||
}
|
|
||||||
if (justContentType.includes("markdown")) {
|
|
||||||
return "markdown";
|
|
||||||
}
|
|
||||||
|
|
||||||
return languageFromContent(content, "text");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function languageFromContent(
|
|
||||||
content: string | null,
|
|
||||||
fallback?: EditorProps["language"],
|
|
||||||
): EditorProps["language"] {
|
|
||||||
if (content == null) return "text";
|
|
||||||
|
|
||||||
const firstBytes = content.slice(0, 20).trim();
|
|
||||||
|
|
||||||
if (firstBytes.startsWith("{") || firstBytes.startsWith("[")) {
|
|
||||||
return "json";
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
firstBytes.toLowerCase().startsWith("<!doctype") ||
|
|
||||||
firstBytes.toLowerCase().startsWith("<html")
|
|
||||||
) {
|
|
||||||
return "html";
|
|
||||||
}
|
|
||||||
if (firstBytes.startsWith("<")) {
|
|
||||||
return "xml";
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isJSON(content: string | null | undefined): boolean {
|
export function isJSON(content: string | null | undefined): boolean {
|
||||||
if (typeof content !== "string") return false;
|
if (typeof content !== "string") return false;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { deleteModel, modelTypeLabel } from "@yaakapp-internal/models";
|
|||||||
import { InlineCode } from "@yaakapp-internal/ui";
|
import { InlineCode } from "@yaakapp-internal/ui";
|
||||||
import { Prose } from "../components/Prose";
|
import { Prose } from "../components/Prose";
|
||||||
import { showConfirmDelete } from "./confirm";
|
import { showConfirmDelete } from "./confirm";
|
||||||
import { pluralizeCount } from "./pluralize";
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
import { resolvedModelName } from "./resolvedModelName";
|
import { resolvedModelName } from "./resolvedModelName";
|
||||||
|
|
||||||
export async function deleteModelWithConfirm(
|
export async function deleteModelWithConfirm(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { createFastMutation } from "../hooks/useFastMutation";
|
|||||||
import { showAlert } from "./alert";
|
import { showAlert } from "./alert";
|
||||||
import { showDialog } from "./dialog";
|
import { showDialog } from "./dialog";
|
||||||
import { jotaiStore } from "./jotai";
|
import { jotaiStore } from "./jotai";
|
||||||
import { pluralizeCount } from "./pluralize";
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
import { router } from "./router";
|
import { router } from "./router";
|
||||||
import { rpc } from "./rpc";
|
import { rpc } from "./rpc";
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading"
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { updateAvailableAtom } from "./atoms";
|
import { updateAvailableAtom } from "./atoms";
|
||||||
import { stringToColor } from "./color";
|
import { stringToColor } from "./color";
|
||||||
import { generateId } from "./generateId";
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
import { jotaiStore } from "./jotai";
|
import { jotaiStore } from "./jotai";
|
||||||
import { showPrompt } from "./prompt";
|
import { showPrompt } from "./prompt";
|
||||||
import { showPromptForm } from "./prompt-form";
|
import { showPromptForm } from "./prompt-form";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { HttpUrlParameter } from "@yaakapp-internal/models";
|
import type { HttpUrlParameter } from "@yaakapp-internal/models";
|
||||||
import { generateId } from "./generateId";
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
|
||||||
export function prepareImportQuerystring(
|
export function prepareImportQuerystring(
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import type { ToastInstance } from "../components/Toasts";
|
import type { ToastInstance } from "../components/Toasts";
|
||||||
import { generateId } from "./generateId";
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
import { jotaiStore } from "./jotai";
|
import { jotaiStore } from "./jotai";
|
||||||
|
|
||||||
export const toastsAtom = atom<ToastInstance[]>([]);
|
export const toastsAtom = atom<ToastInstance[]>([]);
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
@@ -5,6 +5,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./editor": "./src/editor.ts"
|
||||||
|
},
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jotai-family": "^1.0.1"
|
"jotai-family": "^1.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTimedBoolean } from "../hooks/useTimedBoolean";
|
||||||
|
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||||
|
|
||||||
|
export interface CopyIconButtonProps extends Omit<IconButtonProps, "onClick" | "icon"> {
|
||||||
|
text: string | (() => Promise<string | null>);
|
||||||
|
copy: (text: string) => void;
|
||||||
|
/** Called when `text` resolves to nothing */
|
||||||
|
onCopyFailed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyIconButton({ text, copy, onCopyFailed, ...props }: CopyIconButtonProps) {
|
||||||
|
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) {
|
||||||
|
onCopyFailed?.();
|
||||||
|
} else {
|
||||||
|
copy(content);
|
||||||
|
setCopied();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,52 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
import { useInMemoryStoredValue, type UseStoredValue } from "../hooks/useStoredValue";
|
||||||
|
import type { BannerProps } from "./Banner";
|
||||||
|
import { Banner } from "./Banner";
|
||||||
|
|
||||||
|
export interface DetailsBannerProps extends HTMLAttributes<HTMLDetailsElement> {
|
||||||
|
summary: ReactNode;
|
||||||
|
color?: BannerProps["color"];
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
storageKey?: string;
|
||||||
|
/** Where the open state of a banner with a `storageKey` is kept */
|
||||||
|
useStoredValue?: UseStoredValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailsBanner({
|
||||||
|
className,
|
||||||
|
color,
|
||||||
|
summary,
|
||||||
|
children,
|
||||||
|
defaultOpen,
|
||||||
|
storageKey,
|
||||||
|
useStoredValue = useInMemoryStoredValue,
|
||||||
|
...extraProps
|
||||||
|
}: DetailsBannerProps) {
|
||||||
|
const [isOpen, setIsOpen] = useStoredValue<boolean>(storageKey || null, defaultOpen ?? false);
|
||||||
|
|
||||||
|
const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {
|
||||||
|
if (storageKey) {
|
||||||
|
void 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { UseStoredValue } from "../hooks/useStoredValue";
|
||||||
|
import { CountBadge } from "./CountBadge";
|
||||||
|
import { DetailsBanner } from "./DetailsBanner";
|
||||||
|
import { KeyValueRow, type KeyValueRowProps, KeyValueRows } from "./KeyValueRow";
|
||||||
|
|
||||||
|
export interface HeadersTableProps {
|
||||||
|
title: ReactNode;
|
||||||
|
headers: { name: string; value: string }[];
|
||||||
|
labelColor?: KeyValueRowProps["labelColor"];
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
storageKey?: string;
|
||||||
|
useStoredValue?: UseStoredValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeadersTable({
|
||||||
|
title,
|
||||||
|
headers,
|
||||||
|
labelColor,
|
||||||
|
defaultOpen,
|
||||||
|
storageKey,
|
||||||
|
useStoredValue,
|
||||||
|
}: HeadersTableProps) {
|
||||||
|
const sortedHeaders = useMemo(
|
||||||
|
() =>
|
||||||
|
[...headers].sort((a, b) =>
|
||||||
|
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||||
|
),
|
||||||
|
[headers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailsBanner
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
storageKey={storageKey}
|
||||||
|
useStoredValue={useStoredValue}
|
||||||
|
summary={
|
||||||
|
<h2 className="flex items-center">
|
||||||
|
{title} <CountBadge showZero count={sortedHeaders.length} />
|
||||||
|
</h2>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sortedHeaders.length === 0 ? (
|
||||||
|
<span className="text-text-subtlest text-sm italic">No Headers</span>
|
||||||
|
) : (
|
||||||
|
<KeyValueRows>
|
||||||
|
{sortedHeaders.map((h, i) => (
|
||||||
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
|
<KeyValueRow labelColor={labelColor} key={i} label={h.name}>
|
||||||
|
{h.value}
|
||||||
|
</KeyValueRow>
|
||||||
|
))}
|
||||||
|
</KeyValueRows>
|
||||||
|
)}
|
||||||
|
</DetailsBanner>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
import { Icon, type IconProps } from "./Icon";
|
||||||
import type { TooltipProps } from "./Tooltip";
|
import type { TooltipProps } from "./Tooltip";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||||
|
import type { CodeEditorProps } from "./editor/CodeEditor";
|
||||||
|
import { LazyCodeEditor } from "./editor/LazyCodeEditor";
|
||||||
|
import { IconButton } from "./IconButton";
|
||||||
|
import { Label } from "./Label";
|
||||||
|
import { HStack } from "./Stacks";
|
||||||
|
|
||||||
|
export type InputProps = Pick<
|
||||||
|
CodeEditorProps,
|
||||||
|
| "language"
|
||||||
|
| "forceUpdateKey"
|
||||||
|
| "disabled"
|
||||||
|
| "autoFocus"
|
||||||
|
| "autoSelect"
|
||||||
|
| "extensions"
|
||||||
|
| "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?: CodeEditorProps["onPasteOverwrite"];
|
||||||
|
placeholder?: string;
|
||||||
|
/** Renders the editor, for a host whose editor builds on CodeEditor */
|
||||||
|
renderEditor?: (props: CodeEditorProps) => ReactNode;
|
||||||
|
/** Rendered beside the right slot, given a way to replace the whole value */
|
||||||
|
renderOptionsPicker?: (insert: (value: string) => void) => ReactNode;
|
||||||
|
required?: boolean;
|
||||||
|
rightSlot?: ReactNode;
|
||||||
|
size?: "2xs" | "xs" | "sm" | "md" | "auto";
|
||||||
|
stateKey: CodeEditorProps["stateKey"];
|
||||||
|
tint?: Color;
|
||||||
|
type?: "text" | "password";
|
||||||
|
validate?: boolean | ((v: string) => boolean);
|
||||||
|
wrapLines?: boolean;
|
||||||
|
setRef?: (h: InputHandle | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface InputHandle {
|
||||||
|
focus: () => void;
|
||||||
|
isFocused: () => boolean;
|
||||||
|
value: () => string;
|
||||||
|
selectAll: () => void;
|
||||||
|
dispatch: EditorView["dispatch"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCodeEditor(props: CodeEditorProps) {
|
||||||
|
return <LazyCodeEditor {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
defaultValue,
|
||||||
|
disableObscureToggle,
|
||||||
|
disabled,
|
||||||
|
forceUpdateKey,
|
||||||
|
fullHeight,
|
||||||
|
help,
|
||||||
|
hideLabel,
|
||||||
|
inputWrapperClassName,
|
||||||
|
label,
|
||||||
|
labelClassName,
|
||||||
|
labelPosition = "top",
|
||||||
|
leftSlot,
|
||||||
|
multiLine,
|
||||||
|
onBlur,
|
||||||
|
onChange,
|
||||||
|
onFocus,
|
||||||
|
onPaste,
|
||||||
|
onPasteOverwrite,
|
||||||
|
placeholder,
|
||||||
|
readOnly,
|
||||||
|
required,
|
||||||
|
renderEditor,
|
||||||
|
renderOptionsPicker,
|
||||||
|
rightSlot,
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(renderEditor ?? renderCodeEditor)({
|
||||||
|
setRef: setEditorRef,
|
||||||
|
id: id.current,
|
||||||
|
hideGutter: true,
|
||||||
|
singleLine: !multiLine,
|
||||||
|
containerOnly: true,
|
||||||
|
stateKey,
|
||||||
|
wrapLines,
|
||||||
|
heightMode: "auto",
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
type: type === "password" && !obscured ? "text" : type,
|
||||||
|
defaultValue,
|
||||||
|
forceUpdateKey,
|
||||||
|
placeholder,
|
||||||
|
onChange: handleChange,
|
||||||
|
onPaste,
|
||||||
|
onPasteOverwrite,
|
||||||
|
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,
|
||||||
|
...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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renderOptionsPicker?.(insertOption)}
|
||||||
|
{rightSlot}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRequire(v: string) {
|
||||||
|
return v.length > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||||
|
import { CopyIconButton } from "./CopyIconButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children:
|
||||||
|
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
||||||
|
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
||||||
|
selectable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyValueRowProps {
|
||||||
|
label: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
rightSlot?: ReactNode;
|
||||||
|
leftSlot?: ReactNode;
|
||||||
|
align?: "top" | "middle";
|
||||||
|
labelClassName?: string;
|
||||||
|
labelColor?: "secondary" | "primary" | "info";
|
||||||
|
enableCopy?: boolean;
|
||||||
|
copyText?: string;
|
||||||
|
/** Copies a value for `enableCopy`, which shows no button without it */
|
||||||
|
copy?: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyValueRow({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
rightSlot,
|
||||||
|
leftSlot,
|
||||||
|
align = "top",
|
||||||
|
labelColor = "secondary",
|
||||||
|
labelClassName,
|
||||||
|
enableCopy,
|
||||||
|
copyText,
|
||||||
|
copy,
|
||||||
|
}: 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 && copy != null && textToCopy != null ? (
|
||||||
|
<CopyIconButton
|
||||||
|
text={textToCopy}
|
||||||
|
copy={copy}
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File renamed without changes.
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
|
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { generateId } from "../../lib/generateId";
|
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||||
import { Portal } from "@yaakapp-internal/ui";
|
import { Portal } from "./Portal";
|
||||||
|
|
||||||
export interface TooltipProps {
|
export interface TooltipProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
@reference "../../../main.css";
|
@reference "tailwindcss";
|
||||||
|
@reference "../../../../tailwind-config/index.css";
|
||||||
|
|
||||||
.cm-wrapper {
|
.cm-wrapper {
|
||||||
@apply h-full overflow-hidden;
|
@apply h-full overflow-hidden;
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
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 { EditorLanguage } from "@yaakapp-internal/plugins";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import type { ReactNode, RefObject } from "react";
|
||||||
|
import {
|
||||||
|
Children,
|
||||||
|
cloneElement,
|
||||||
|
isValidElement,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import { docFingerprint } from "../../lib/docFingerprint";
|
||||||
|
import { IconButton } from "../IconButton";
|
||||||
|
import { HStack } from "../Stacks";
|
||||||
|
import "./CodeEditor.css";
|
||||||
|
import {
|
||||||
|
baseExtensions,
|
||||||
|
editableExtensions,
|
||||||
|
getLanguageExtension,
|
||||||
|
multiLineExtensions,
|
||||||
|
readonlyExtensions,
|
||||||
|
} from "./extensions";
|
||||||
|
import { singleLineExtensions } from "./singleLine";
|
||||||
|
|
||||||
|
// 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 CodeEditorProps {
|
||||||
|
actions?: ReactNode;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
autoSelect?: boolean;
|
||||||
|
className?: string;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
disableTabIndent?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Added to the editable document's extensions, swapped out with read-only */
|
||||||
|
editableExtensions?: Extension;
|
||||||
|
extensions?: Extension[] | Extension;
|
||||||
|
forceUpdateKey?: string | number;
|
||||||
|
format?: (v: string) => Promise<string>;
|
||||||
|
heightMode?: "auto" | "full";
|
||||||
|
hideGutter?: boolean;
|
||||||
|
id?: string;
|
||||||
|
/** Runs before `onKeyDown`; returning true consumes the key */
|
||||||
|
keyDownHandler?: (e: KeyboardEvent, view: EditorView) => boolean;
|
||||||
|
keymap?: EditorKeymap;
|
||||||
|
language?: EditorLanguage | null;
|
||||||
|
/** Replaces the extension built from `language`, reconfigured whenever it changes */
|
||||||
|
languageExtension?: Extension;
|
||||||
|
lintExtension?: Extension;
|
||||||
|
/** Added to the read-only document's extensions, swapped out with editable */
|
||||||
|
readOnlyExtensions?: Extension;
|
||||||
|
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 };
|
||||||
|
|
||||||
|
const emptyExtension: Extension = [];
|
||||||
|
|
||||||
|
export function CodeEditor(props: CodeEditorProps) {
|
||||||
|
return <CodeEditorInner key={props.stateKey} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeEditorInner({
|
||||||
|
actions,
|
||||||
|
autoFocus,
|
||||||
|
autoSelect,
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
disableTabIndent,
|
||||||
|
disabled,
|
||||||
|
editableExtensions: extraEditableExtensions,
|
||||||
|
extensions: extraExtensions,
|
||||||
|
forceUpdateKey,
|
||||||
|
format,
|
||||||
|
heightMode,
|
||||||
|
hideGutter,
|
||||||
|
keyDownHandler,
|
||||||
|
keymap: editorKeymap = "default",
|
||||||
|
language,
|
||||||
|
languageExtension,
|
||||||
|
lintExtension,
|
||||||
|
onBlur,
|
||||||
|
onChange,
|
||||||
|
onFocus,
|
||||||
|
onKeyDown,
|
||||||
|
onPaste,
|
||||||
|
onPasteOverwrite,
|
||||||
|
placeholder,
|
||||||
|
readOnly,
|
||||||
|
readOnlyExtensions: extraReadOnlyExtensions,
|
||||||
|
singleLine,
|
||||||
|
containerOnly,
|
||||||
|
stateKey,
|
||||||
|
type,
|
||||||
|
wrapLines,
|
||||||
|
setRef,
|
||||||
|
}: CodeEditorProps) {
|
||||||
|
if (disabled) {
|
||||||
|
readOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (singleLine || language == null || language === "text") {
|
||||||
|
disableTabIndent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CodeEditorProps["onChange"]>(onChange);
|
||||||
|
useEffect(() => {
|
||||||
|
handleChange.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// Use ref so we can update the handler without re-initializing the editor
|
||||||
|
const handlePaste = useRef<CodeEditorProps["onPaste"]>(onPaste);
|
||||||
|
useEffect(() => {
|
||||||
|
handlePaste.current = onPaste;
|
||||||
|
}, [onPaste]);
|
||||||
|
|
||||||
|
// Use ref so we can update the handler without re-initializing the editor
|
||||||
|
const handlePasteOverwrite = useRef<CodeEditorProps["onPasteOverwrite"]>(onPasteOverwrite);
|
||||||
|
useEffect(() => {
|
||||||
|
handlePasteOverwrite.current = onPasteOverwrite;
|
||||||
|
}, [onPasteOverwrite]);
|
||||||
|
|
||||||
|
// Use ref so we can update the handler without re-initializing the editor
|
||||||
|
const handleFocus = useRef<CodeEditorProps["onFocus"]>(onFocus);
|
||||||
|
useEffect(() => {
|
||||||
|
handleFocus.current = onFocus;
|
||||||
|
}, [onFocus]);
|
||||||
|
|
||||||
|
// Use ref so we can update the handler without re-initializing the editor
|
||||||
|
const handleBlur = useRef<CodeEditorProps["onBlur"]>(onBlur);
|
||||||
|
useEffect(() => {
|
||||||
|
handleBlur.current = onBlur;
|
||||||
|
}, [onBlur]);
|
||||||
|
|
||||||
|
// Use ref so we can update the handler without re-initializing the editor
|
||||||
|
const handleKeyDown = useRef<CodeEditorProps["onKeyDown"]>(onKeyDown);
|
||||||
|
useEffect(() => {
|
||||||
|
handleKeyDown.current = onKeyDown;
|
||||||
|
}, [onKeyDown]);
|
||||||
|
|
||||||
|
// Use ref so we can update the handler without re-initializing the editor
|
||||||
|
const handleKeyDownFirst = useRef<CodeEditorProps["keyDownHandler"]>(keyDownHandler);
|
||||||
|
useEffect(() => {
|
||||||
|
handleKeyDownFirst.current = keyDownHandler;
|
||||||
|
}, [keyDownHandler]);
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
},
|
||||||
|
[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 (editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do
|
||||||
|
if (editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do
|
||||||
|
if (editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do
|
||||||
|
if (editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do
|
||||||
|
|
||||||
|
const ext = keymapExtensions[editorKeymap] ?? keymapExtensions.default;
|
||||||
|
const effects = keymapCompartment.current.reconfigure(ext);
|
||||||
|
cm.current.view.dispatch({ effects });
|
||||||
|
},
|
||||||
|
[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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const modeExtensions = useMemo(
|
||||||
|
() => ({
|
||||||
|
readOnly: extraReadOnlyExtensions
|
||||||
|
? [readonlyExtensions, extraReadOnlyExtensions]
|
||||||
|
: readonlyExtensions,
|
||||||
|
editable: extraEditableExtensions
|
||||||
|
? [editableExtensions, extraEditableExtensions]
|
||||||
|
: editableExtensions,
|
||||||
|
}),
|
||||||
|
[extraReadOnlyExtensions, extraEditableExtensions],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 ? modeExtensions.readOnly : modeExtensions.editable;
|
||||||
|
// 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, modeExtensions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const langExt = useMemo(
|
||||||
|
() => languageExtension ?? getLanguageExtension({ language, lintExtension, hideGutter }),
|
||||||
|
[languageExtension, language, lintExtension, hideGutter],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the language extension when the language changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (cm.current === null) return;
|
||||||
|
const { view, languageCompartment } = cm.current;
|
||||||
|
if (languageCompartment.get(view.state) === langExt) return;
|
||||||
|
view.dispatch({ effects: languageCompartment.reconfigure(langExt) });
|
||||||
|
}, [langExt]);
|
||||||
|
|
||||||
|
// 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 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[editorKeymap] ?? keymapExtensions.default),
|
||||||
|
readOnlyCompartment.current.of(
|
||||||
|
readOnly ? modeExtensions.readOnly : modeExtensions.editable,
|
||||||
|
),
|
||||||
|
...getExtensions({
|
||||||
|
container,
|
||||||
|
singleLine,
|
||||||
|
hideGutter,
|
||||||
|
stateKey,
|
||||||
|
onChange: handleChange,
|
||||||
|
onPaste: handlePaste,
|
||||||
|
onPasteOverwrite: handlePasteOverwrite,
|
||||||
|
onFocus: handleFocus,
|
||||||
|
onBlur: handleBlur,
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
keyDownHandler: handleKeyDownFirst,
|
||||||
|
}),
|
||||||
|
...(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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtensions({
|
||||||
|
stateKey,
|
||||||
|
container,
|
||||||
|
singleLine,
|
||||||
|
hideGutter,
|
||||||
|
onChange,
|
||||||
|
onPaste,
|
||||||
|
onPasteOverwrite,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
onKeyDown,
|
||||||
|
keyDownHandler,
|
||||||
|
}: Pick<CodeEditorProps, "singleLine" | "hideGutter"> & {
|
||||||
|
stateKey: CodeEditorProps["stateKey"];
|
||||||
|
container: HTMLDivElement | null;
|
||||||
|
onChange: RefObject<CodeEditorProps["onChange"]>;
|
||||||
|
onPaste: RefObject<CodeEditorProps["onPaste"]>;
|
||||||
|
onPasteOverwrite: RefObject<CodeEditorProps["onPasteOverwrite"]>;
|
||||||
|
onFocus: RefObject<CodeEditorProps["onFocus"]>;
|
||||||
|
onBlur: RefObject<CodeEditorProps["onBlur"]>;
|
||||||
|
onKeyDown: RefObject<CodeEditorProps["onKeyDown"]>;
|
||||||
|
keyDownHandler: RefObject<CodeEditorProps["keyDownHandler"]>;
|
||||||
|
}) {
|
||||||
|
// 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) => {
|
||||||
|
if (keyDownHandler.current?.(e, 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);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import type { CodeEditorProps } from "./CodeEditor";
|
||||||
|
|
||||||
|
const CodeEditor_ = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||||
|
|
||||||
|
export function LazyCodeEditor(props: CodeEditorProps) {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<CodeEditor_ {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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 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";
|
||||||
|
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||||
|
import type { EditorLanguage } from "@yaakapp-internal/plugins";
|
||||||
|
import { jsonParseLinter } from "./json-lint";
|
||||||
|
import { searchMatchCount } from "./searchMatchCount";
|
||||||
|
import { text } from "./text/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 });
|
||||||
|
|
||||||
|
export const closeBracketsExtensions: Extension = [
|
||||||
|
closeBrackets(),
|
||||||
|
keymap.of([...closeBracketsKeymap]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
|
||||||
|
return () => new LanguageSupport(StreamLanguage.define(mode));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LanguageSupports = Record<string, null | (() => LanguageSupport)>;
|
||||||
|
|
||||||
|
export const syntaxExtensions: Record<EditorLanguage, null | (() => LanguageSupport)> = {
|
||||||
|
graphql: null,
|
||||||
|
json: jsonc,
|
||||||
|
javascript: javascript,
|
||||||
|
// HTML as XML because HTML is oddly slow
|
||||||
|
html: xml,
|
||||||
|
xml: xml,
|
||||||
|
text: text,
|
||||||
|
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: string[] = ["json", "javascript", "graphql"];
|
||||||
|
|
||||||
|
export function getLanguageExtension({
|
||||||
|
language = "text",
|
||||||
|
lintExtension,
|
||||||
|
hideGutter,
|
||||||
|
languages = syntaxExtensions,
|
||||||
|
extraExtensions: hostExtensions = [],
|
||||||
|
wrap,
|
||||||
|
}: {
|
||||||
|
language?: string | null;
|
||||||
|
lintExtension?: Extension;
|
||||||
|
hideGutter?: boolean;
|
||||||
|
/** Language supports by name, for a host with languages of its own */
|
||||||
|
languages?: LanguageSupports;
|
||||||
|
extraExtensions?: Extension[];
|
||||||
|
/** Wraps the base language, as a templating language does */
|
||||||
|
wrap?: (base: LanguageSupport, extraExtensions: Extension[]) => Extension;
|
||||||
|
}): Extension {
|
||||||
|
const extraExtensions: Extension[] = [...hostExtensions];
|
||||||
|
|
||||||
|
// Only close brackets on languages that need it
|
||||||
|
if (language && closeBracketsFor.includes(language)) {
|
||||||
|
extraExtensions.push(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 ? languages[language] : null;
|
||||||
|
const base = typeof maybeBase === "function" ? maybeBase() : null;
|
||||||
|
if (base == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrap ? wrap(base, extraExtensions) : [base, extraExtensions];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const editableExtensions: Extension[] = [];
|
||||||
|
|
||||||
|
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]),
|
||||||
|
];
|
||||||
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