diff --git a/apps/yaak-client/commands/commands.tsx b/apps/yaak-client/commands/commands.tsx index 40618a77a..6bc57c3ad 100644 --- a/apps/yaak-client/commands/commands.tsx +++ b/apps/yaak-client/commands/commands.tsx @@ -16,9 +16,9 @@ import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { createFastMutation } from "../hooks/useFastMutation"; import { showDialog } from "../lib/dialog"; import { jotaiStore } from "../lib/jotai"; -import { pluralizeCount } from "../lib/pluralize"; import { showPrompt } from "../lib/prompt"; import { resolvedModelNameWithFolders } from "../lib/resolvedModelName"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; export const createFolder = createFastMutation< string | null, diff --git a/apps/yaak-client/commands/moveToWorkspace.tsx b/apps/yaak-client/commands/moveToWorkspace.tsx index 3428a5805..c88432937 100644 --- a/apps/yaak-client/commands/moveToWorkspace.tsx +++ b/apps/yaak-client/commands/moveToWorkspace.tsx @@ -3,9 +3,9 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-intern import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog"; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { createFastMutation } from "../hooks/useFastMutation"; -import { pluralizeCount } from "../lib/pluralize"; import { showDialog } from "../lib/dialog"; import { jotaiStore } from "../lib/jotai"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; export const moveToWorkspace = createFastMutation({ mutationKey: ["move_workspace"], diff --git a/apps/yaak-client/components/ConfirmLargeRequestBody.tsx b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx index f8acf535e..31956044b 100644 --- a/apps/yaak-client/components/ConfirmLargeRequestBody.tsx +++ b/apps/yaak-client/components/ConfirmLargeRequestBody.tsx @@ -1,12 +1,11 @@ import type { HttpRequest } from "@yaakapp-internal/models"; import { patchModel } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; +import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui"; import type { ReactNode } from "react"; import { useToggle } from "../hooks/useToggle"; import { showConfirm } from "../lib/confirm"; import { Button } from "./core/Button"; import { Link } from "./core/Link"; -import { SizeTag } from "./core/SizeTag"; interface Props { children: ReactNode; diff --git a/apps/yaak-client/components/ConfirmLargeResponse.tsx b/apps/yaak-client/components/ConfirmLargeResponse.tsx index 90bb2c850..f7b99072f 100644 --- a/apps/yaak-client/components/ConfirmLargeResponse.tsx +++ b/apps/yaak-client/components/ConfirmLargeResponse.tsx @@ -1,5 +1,5 @@ import type { HttpResponse } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; +import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui"; import { type ReactNode, useMemo } from "react"; import { useSaveResponse } from "../hooks/useSaveResponse"; import { useToggle } from "../hooks/useToggle"; @@ -8,7 +8,6 @@ import { getContentTypeFromHeaders } from "../lib/model_util"; import { getResponseBodyText } from "../lib/responseBody"; import { CopyButton } from "./CopyButton"; import { Button } from "./core/Button"; -import { SizeTag } from "./core/SizeTag"; interface Props { children: ReactNode; diff --git a/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx index 92525f705..639139724 100644 --- a/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx +++ b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx @@ -1,5 +1,5 @@ import type { HttpResponse } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; +import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui"; import { type ReactNode, useMemo } from "react"; import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody"; import { useToggle } from "../hooks/useToggle"; @@ -7,7 +7,6 @@ import { isProbablyTextContentType } from "../lib/contentType"; import { getContentTypeFromHeaders } from "../lib/model_util"; import { CopyButton } from "./CopyButton"; import { Button } from "./core/Button"; -import { SizeTag } from "./core/SizeTag"; interface Props { children: ReactNode; diff --git a/apps/yaak-client/components/CookieDialog.tsx b/apps/yaak-client/components/CookieDialog.tsx index 31c90913d..0c7a831f4 100644 --- a/apps/yaak-client/components/CookieDialog.tsx +++ b/apps/yaak-client/components/CookieDialog.tsx @@ -25,12 +25,13 @@ import { TableHeaderCell, TableRow, TruncatedWideTableCell, + KeyValueRows, } from "@yaakapp-internal/ui"; import { IconButton } from "./core/IconButton"; import { Checkbox } from "./core/Checkbox"; import classNames from "classnames"; import { EventDetailHeader } from "./core/EventViewer"; -import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; +import { KeyValueRow } from "./core/KeyValueRow"; import { EmptyStateText } from "./EmptyStateText"; import { PlainInput } from "./core/PlainInput"; import { Select } from "./core/Select"; diff --git a/apps/yaak-client/components/CopyIconButton.tsx b/apps/yaak-client/components/CopyIconButton.tsx index a97a032cf..d55a4ec36 100644 --- a/apps/yaak-client/components/CopyIconButton.tsx +++ b/apps/yaak-client/components/CopyIconButton.tsx @@ -1,31 +1,22 @@ -import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui"; +import { + CopyIconButton as BaseCopyIconButton, + type CopyIconButtonProps, +} from "@yaakapp-internal/ui"; import { copyToClipboard } from "../lib/copy"; import { showToast } from "../lib/toast"; -interface Props extends Omit { - text: string | (() => Promise); +export function CopyIconButton(props: Omit) { + return ; } -export function CopyIconButton({ text, ...props }: Props) { - const [copied, setCopied] = useTimedBoolean(); - return ( - { - const content = typeof text === "function" ? await text() : text; - if (content == null) { - showToast({ - id: "failed-to-copy", - color: "danger", - message: "Failed to copy", - }); - } else { - copyToClipboard(content, { disableToast: true }); - setCopied(); - } - }} - /> - ); +function copy(text: string) { + copyToClipboard(text, { disableToast: true }); +} + +function showCopyFailed() { + showToast({ + id: "failed-to-copy", + color: "danger", + message: "Failed to copy", + }); } diff --git a/apps/yaak-client/components/CreateEnvironmentDialog.tsx b/apps/yaak-client/components/CreateEnvironmentDialog.tsx index c4e1fd7a0..e5058133b 100644 --- a/apps/yaak-client/components/CreateEnvironmentDialog.tsx +++ b/apps/yaak-client/components/CreateEnvironmentDialog.tsx @@ -5,8 +5,8 @@ import { ColorIndicator } from "./ColorIndicator"; import { Checkbox } from "./core/Checkbox"; import { ColorPickerWithThemeColors } from "./core/ColorPicker"; import { DialogFooter } from "./core/Dialog"; -import { Label } from "./core/Label"; import { PlainInput } from "./core/PlainInput"; +import { Label } from "@yaakapp-internal/ui"; interface Props { onCreate: (id: string) => void; diff --git a/apps/yaak-client/components/CreateWorkspaceDialog.tsx b/apps/yaak-client/components/CreateWorkspaceDialog.tsx index 71154fbbb..557d089c1 100644 --- a/apps/yaak-client/components/CreateWorkspaceDialog.tsx +++ b/apps/yaak-client/components/CreateWorkspaceDialog.tsx @@ -1,7 +1,7 @@ import { gitMutations } from "@yaakapp-internal/git"; import type { WorkspaceMeta } from "@yaakapp-internal/models"; import { createGlobalModel, updateModel } from "@yaakapp-internal/models"; -import { VStack } from "@yaakapp-internal/ui"; +import { VStack, Label } from "@yaakapp-internal/ui"; import { useId, useState } from "react"; import { router } from "../lib/router"; import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption"; @@ -9,7 +9,6 @@ import { rpc } from "../lib/rpc"; import { showErrorToast } from "../lib/toast"; import { Checkbox } from "./core/Checkbox"; import { DialogFooter } from "./core/Dialog"; -import { Label } from "./core/Label"; import { PlainInput } from "./core/PlainInput"; import { EncryptionHelp } from "./EncryptionHelp"; import { gitCallbacks } from "./git/callbacks"; diff --git a/apps/yaak-client/components/DynamicForm.tsx b/apps/yaak-client/components/DynamicForm.tsx index adb314148..3f55cc754 100644 --- a/apps/yaak-client/components/DynamicForm.tsx +++ b/apps/yaak-client/components/DynamicForm.tsx @@ -11,7 +11,7 @@ import type { FormInputText, JsonPrimitive, } from "@yaakapp-internal/plugins"; -import { Banner, VStack } from "@yaakapp-internal/ui"; +import { Banner, VStack, Label } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo } from "react"; @@ -26,7 +26,6 @@ import { Editor } from "./core/Editor/LazyEditor"; import { IconButton } from "./core/IconButton"; import type { InputProps } from "./core/Input"; import { Input } from "./core/Input"; -import { Label } from "./core/Label"; import type { Pair } from "./core/PairEditor"; import { PairEditor } from "./core/PairEditor"; import { PlainInput } from "./core/PlainInput"; diff --git a/apps/yaak-client/components/EnvironmentEditDialog.tsx b/apps/yaak-client/components/EnvironmentEditDialog.tsx index c0b43e2f3..8e53126ff 100644 --- a/apps/yaak-client/components/EnvironmentEditDialog.tsx +++ b/apps/yaak-client/components/EnvironmentEditDialog.tsx @@ -1,7 +1,7 @@ import type { Environment, Workspace } from "@yaakapp-internal/models"; import { duplicateModel, patchModel } from "@yaakapp-internal/models"; import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui"; -import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui"; +import { Banner, Icon, InlineCode, SplitLayout, Tree, IconTooltip } from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; import { atomFamily } from "jotai-family"; import { @@ -30,7 +30,6 @@ import { showColorPicker } from "../lib/showColorPicker"; import type { ContextMenuProps, DropdownItem } from "./core/Dropdown"; import { ContextMenu } from "./core/Dropdown"; import { IconButton } from "./core/IconButton"; -import { IconTooltip } from "./core/IconTooltip"; import type { PairEditorHandle } from "./core/PairEditor"; import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator"; import { EnvironmentEditor } from "./EnvironmentEditor"; diff --git a/apps/yaak-client/components/EnvironmentSharableTooltip.tsx b/apps/yaak-client/components/EnvironmentSharableTooltip.tsx index 642d76eb2..aa8ef57c9 100644 --- a/apps/yaak-client/components/EnvironmentSharableTooltip.tsx +++ b/apps/yaak-client/components/EnvironmentSharableTooltip.tsx @@ -1,4 +1,4 @@ -import { IconTooltip } from "./core/IconTooltip"; +import { IconTooltip } from "@yaakapp-internal/ui"; export function EnvironmentSharableTooltip() { return ( diff --git a/apps/yaak-client/components/ExportDataDialog.tsx b/apps/yaak-client/components/ExportDataDialog.tsx index d0d89c3ee..1f27c7124 100644 --- a/apps/yaak-client/components/ExportDataDialog.tsx +++ b/apps/yaak-client/components/ExportDataDialog.tsx @@ -5,7 +5,6 @@ import { useAtomValue } from "jotai"; import { useCallback, useMemo, useState } from "react"; import slugify from "slugify"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; -import { pluralizeCount } from "../lib/pluralize"; import { rpc } from "../lib/rpc"; import { CommercialUseBanner } from "./CommercialUseBanner"; import { Checkbox } from "./core/Checkbox"; @@ -13,6 +12,7 @@ import { DetailsBanner } from "./core/DetailsBanner"; import { DialogFooter } from "./core/Dialog"; import { Link } from "./core/Link"; import { platform } from "@yaakapp-internal/platform"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; interface Props { onHide: () => void; diff --git a/apps/yaak-client/components/FolderLayout.tsx b/apps/yaak-client/components/FolderLayout.tsx index a693080a5..14c3085db 100644 --- a/apps/yaak-client/components/FolderLayout.tsx +++ b/apps/yaak-client/components/FolderLayout.tsx @@ -1,6 +1,14 @@ import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models"; import { foldersAtom } from "@yaakapp-internal/models"; -import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui"; +import { + Heading, + HStack, + Icon, + LoadingIcon, + HttpResponseDurationTag, + HttpStatusTag, + SizeTag, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import type { CSSProperties, ReactNode } from "react"; @@ -13,11 +21,8 @@ import { showDialog } from "../lib/dialog"; import { resolvedModelName } from "../lib/resolvedModelName"; import { router } from "../lib/router"; import { Button } from "./core/Button"; -import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag"; -import { HttpStatusTag } from "./core/HttpStatusTag"; import { IconButton } from "./core/IconButton"; import { Separator } from "./core/Separator"; -import { SizeTag } from "./core/SizeTag"; import { HttpResponsePane } from "./HttpResponsePane"; interface Props { diff --git a/apps/yaak-client/components/FolderSettingsDialog.tsx b/apps/yaak-client/components/FolderSettingsDialog.tsx index 7b0a3e4e9..66758a153 100644 --- a/apps/yaak-client/components/FolderSettingsDialog.tsx +++ b/apps/yaak-client/components/FolderSettingsDialog.tsx @@ -1,5 +1,5 @@ import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models"; -import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import { HStack, Icon, InlineCode, VStack, CountBadge } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; import { Fragment, useMemo } from "react"; import { useAuthTab } from "../hooks/useAuthTab"; @@ -11,7 +11,6 @@ import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { hideDialog } from "../lib/dialog"; import { CopyIconButton } from "./CopyIconButton"; import { Button } from "./core/Button"; -import { CountBadge } from "./core/CountBadge"; import { Input } from "./core/Input"; import { Link } from "./core/Link"; import type { TabItem } from "./core/Tabs/Tabs"; diff --git a/apps/yaak-client/components/GrpcEditor.tsx b/apps/yaak-client/components/GrpcEditor.tsx index e1fcb1dbe..c626f46e0 100644 --- a/apps/yaak-client/components/GrpcEditor.tsx +++ b/apps/yaak-client/components/GrpcEditor.tsx @@ -20,13 +20,13 @@ import { showConfirm } from "../lib/confirm"; import { showDialog } from "../lib/dialog"; import type { JsonSchema } from "../lib/jsonSchemaExample"; import { buildExampleFromSchema } from "../lib/jsonSchemaExample"; -import { pluralizeCount } from "../lib/pluralize"; import { queryClient } from "../lib/queryClient"; import { Button } from "./core/Button"; import { Dropdown } from "./core/Dropdown"; import type { EditorProps } from "./core/Editor/Editor"; import { Editor } from "./core/Editor/LazyEditor"; import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; type Props = Pick & { services: ReflectResponseService[] | null; diff --git a/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx b/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx index dab6c4f82..e010deb87 100644 --- a/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx +++ b/apps/yaak-client/components/GrpcProtoSelectionDialog.tsx @@ -3,11 +3,11 @@ import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui"; import { useActiveRequest } from "../hooks/useActiveRequest"; import { useGrpc } from "../hooks/useGrpc"; import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles"; -import { pluralizeCount } from "../lib/pluralize"; import { DialogFooter } from "./core/Dialog"; import { PathList } from "./core/PathList"; import { Link } from "./core/Link"; import { platform } from "@yaakapp-internal/platform"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; interface Props { onDone: () => void; diff --git a/apps/yaak-client/components/GrpcRequestPane.tsx b/apps/yaak-client/components/GrpcRequestPane.tsx index 0c3b91399..43754b864 100644 --- a/apps/yaak-client/components/GrpcRequestPane.tsx +++ b/apps/yaak-client/components/GrpcRequestPane.tsx @@ -4,7 +4,7 @@ import { patchModel, patchModelDebounced, } from "@yaakapp-internal/models"; -import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui"; +import { HStack, Icon, useContainerSize, VStack, CountBadge } from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { CSSProperties } from "react"; import { useCallback, useMemo, useRef } from "react"; @@ -15,7 +15,6 @@ import { useInheritedHeaders } from "../hooks/useInheritedHeaders"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { resolvedModelName } from "../lib/resolvedModelName"; import { Button } from "./core/Button"; -import { CountBadge } from "./core/CountBadge"; import { IconButton } from "./core/IconButton"; import { PlainInput } from "./core/PlainInput"; import { RadioDropdown } from "./core/RadioDropdown"; diff --git a/apps/yaak-client/components/GrpcResponsePane.tsx b/apps/yaak-client/components/GrpcResponsePane.tsx index 601b3e12b..7228011f0 100644 --- a/apps/yaak-client/components/GrpcResponsePane.tsx +++ b/apps/yaak-client/components/GrpcResponsePane.tsx @@ -1,5 +1,13 @@ import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models"; -import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui"; +import { + HStack, + Icon, + type IconProps, + LoadingIcon, + VStack, + useStateWithDeps, + KeyValueRows, +} from "@yaakapp-internal/ui"; import { useAtomValue, useSetAtom } from "jotai"; import type { CSSProperties } from "react"; import { useEffect, useMemo, useState } from "react"; @@ -9,13 +17,12 @@ import { pinnedGrpcConnectionIdAtom, useGrpcEvents, } from "../hooks/usePinnedGrpcConnection"; -import { useStateWithDeps } from "../hooks/useStateWithDeps"; import { Button } from "./core/Button"; import { Editor } from "./core/Editor/LazyEditor"; import { EventDetailHeader, EventViewer } from "./core/EventViewer"; import { EventViewerRow } from "./core/EventViewerRow"; import { HotkeyList } from "./core/HotkeyList"; -import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; +import { KeyValueRow } from "./core/KeyValueRow"; import { EmptyStateText } from "./EmptyStateText"; import { ErrorBoundary } from "./ErrorBoundary"; import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown"; diff --git a/apps/yaak-client/components/HeadersEditor.tsx b/apps/yaak-client/components/HeadersEditor.tsx index a3a499e59..d3755f78d 100644 --- a/apps/yaak-client/components/HeadersEditor.tsx +++ b/apps/yaak-client/components/HeadersEditor.tsx @@ -1,5 +1,5 @@ import type { HttpRequestHeader } from "@yaakapp-internal/models"; -import { HStack } from "@yaakapp-internal/ui"; +import { HStack, CountBadge } from "@yaakapp-internal/ui"; import { acceptLanguages } from "../lib/data/acceptLanguages"; import { cacheControlDirectives } from "../lib/data/cacheControl"; import { charsets } from "../lib/data/charsets"; @@ -10,7 +10,6 @@ import { headerNames } from "../lib/data/headerNames"; import { mimeTypes } from "../lib/data/mimetypes"; import { userAgents } from "../lib/data/userAgents"; import { validateHttpHeader } from "../lib/validateHttpHeader"; -import { CountBadge } from "./core/CountBadge"; import { DetailsBanner } from "./core/DetailsBanner"; import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion"; import type { InputProps } from "./core/Input"; diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index 494b469e5..cf081f0a8 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -15,8 +15,6 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; -import { languageFromContentType } from "../lib/contentType"; -import { generateId } from "../lib/generateId"; import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; import { convertRequestBody } from "../lib/requestBodyConversion"; import { @@ -35,11 +33,10 @@ import { resolvedModelName } from "../lib/resolvedModelName"; import { showToast } from "../lib/toast"; import { BinaryFileEditor } from "./BinaryFileEditor"; import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody"; -import { CountBadge } from "./core/CountBadge"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import { getUrlCompletionConfig } from "./core/Editor/url/completion"; import { Editor } from "./core/Editor/LazyEditor"; -import { InlineCode } from "@yaakapp-internal/ui"; +import { InlineCode, languageFromContentType, CountBadge } from "@yaakapp-internal/ui"; import { PlainInput } from "./core/PlainInput"; import type { TabItem, TabsRef } from "./core/Tabs/Tabs"; import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs"; @@ -54,6 +51,7 @@ import { RequestMethodDropdown } from "./RequestMethodDropdown"; import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor"; import { UrlBar } from "./UrlBar"; import { UrlParametersEditor } from "./UrlParameterEditor"; +import { generateId } from "@yaakapp-internal/lib/generateId"; const GraphQLEditor = lazy(() => import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })), diff --git a/apps/yaak-client/components/HttpResponsePane.tsx b/apps/yaak-client/components/HttpResponsePane.tsx index 8d0697d7d..13f864eb8 100644 --- a/apps/yaak-client/components/HttpResponsePane.tsx +++ b/apps/yaak-client/components/HttpResponsePane.tsx @@ -1,5 +1,21 @@ import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models"; -import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui"; +import { + Banner, + HStack, + Icon, + LoadingIcon, + VStack, + CountBadge, + HttpResponseDurationTag, + HttpStatusTag, + SizeTag, + Tooltip, + AudioViewer, + CsvViewer, + ImageViewer, + SvgViewer, + VideoViewer, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { ComponentType, CSSProperties } from "react"; import { lazy, Suspense, useMemo } from "react"; @@ -17,15 +33,10 @@ import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util"; import { ConfirmLargeResponse } from "./ConfirmLargeResponse"; import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest"; import { Button } from "./core/Button"; -import { CountBadge } from "./core/CountBadge"; import { HotkeyList } from "./core/HotkeyList"; -import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag"; -import { HttpStatusTag } from "./core/HttpStatusTag"; import { PillButton } from "./core/PillButton"; -import { SizeTag } from "./core/SizeTag"; import type { TabItem } from "./core/Tabs/Tabs"; import { TabContent, Tabs } from "./core/Tabs/Tabs"; -import { Tooltip } from "./core/Tooltip"; import { EmptyStateText } from "./EmptyStateText"; import { ErrorBoundary } from "./ErrorBoundary"; import { HttpResponseTimeline } from "./HttpResponseTimeline"; @@ -33,14 +44,9 @@ import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown"; import { RequestBodyViewer } from "./RequestBodyViewer"; import { ResponseCookies } from "./ResponseCookies"; import { ResponseHeaders } from "./ResponseHeaders"; -import { AudioViewer } from "./responseViewers/AudioViewer"; -import { CsvViewer } from "./responseViewers/CsvViewer"; import { EventStreamViewer } from "./responseViewers/EventStreamViewer"; import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer"; -import { ImageViewer } from "./responseViewers/ImageViewer"; import { MultipartViewer } from "./responseViewers/MultipartViewer"; -import { SvgViewer } from "./responseViewers/SvgViewer"; -import { VideoViewer } from "./responseViewers/VideoViewer"; const PdfViewer = lazy(() => import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), diff --git a/apps/yaak-client/components/HttpResponseTimeline.tsx b/apps/yaak-client/components/HttpResponseTimeline.tsx index 4475b4a0c..72b05a382 100644 --- a/apps/yaak-client/components/HttpResponseTimeline.tsx +++ b/apps/yaak-client/components/HttpResponseTimeline.tsx @@ -13,9 +13,8 @@ import { resolvedModelName } from "../lib/resolvedModelName"; import { Editor } from "./core/Editor/LazyEditor"; import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer"; import { EventViewerRow } from "./core/EventViewerRow"; -import { HttpStatusTagRaw } from "./core/HttpStatusTag"; -import { Icon, type IconProps } from "@yaakapp-internal/ui"; -import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; +import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui"; +import { KeyValueRow } from "./core/KeyValueRow"; import type { TimelineViewMode } from "./HttpResponsePane"; interface Props { diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 6bb850d91..43e698e06 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -5,11 +5,10 @@ import { type ImportSource, type Workspace, } from "@yaakapp-internal/models"; -import { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui"; +import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { errorMessage } from "../lib/errorMessage"; -import { pluralize } from "../lib/pluralize"; import { CommercialUseBanner } from "./CommercialUseBanner"; import { ImportSourceList, @@ -22,9 +21,9 @@ import type { CheckboxTreeNode } from "./core/CheckboxTree"; import { CheckboxTree } from "./core/CheckboxTree"; import { Chip } from "./core/Chip"; import { DialogFooter } from "./core/Dialog"; -import { IconTooltip } from "./core/IconTooltip"; import { Select } from "./core/Select"; import { SegmentedControl } from "./core/SegmentedControl"; +import { pluralize } from "@yaakapp-internal/lib/pluralize"; interface Props { currentWorkspace: Workspace | null; diff --git a/apps/yaak-client/components/JsonBodyEditor.tsx b/apps/yaak-client/components/JsonBodyEditor.tsx index 209fbac50..7f171c154 100644 --- a/apps/yaak-client/components/JsonBodyEditor.tsx +++ b/apps/yaak-client/components/JsonBodyEditor.tsx @@ -1,7 +1,7 @@ import { linter } from "@codemirror/lint"; import type { HttpRequest } from "@yaakapp-internal/models"; import { patchModel } from "@yaakapp-internal/models"; -import { Banner, Icon } from "@yaakapp-internal/ui"; +import { Banner, Icon, IconTooltip } from "@yaakapp-internal/ui"; import { useCallback, useMemo } from "react"; import { useKeyValue } from "../hooks/useKeyValue"; import { fireAndForget } from "../lib/fireAndForget"; @@ -12,7 +12,6 @@ import type { EditorProps } from "./core/Editor/Editor"; import { jsonParseLinter } from "./core/Editor/json-lint"; import { Editor } from "./core/Editor/LazyEditor"; import { IconButton } from "./core/IconButton"; -import { IconTooltip } from "./core/IconTooltip"; interface Props { forceUpdateKey: string; diff --git a/apps/yaak-client/components/LargeValueDialog.tsx b/apps/yaak-client/components/LargeValueDialog.tsx index ecafea00d..34f6ccf1f 100644 --- a/apps/yaak-client/components/LargeValueDialog.tsx +++ b/apps/yaak-client/components/LargeValueDialog.tsx @@ -1,15 +1,20 @@ import { formatSize } from "@yaakapp-internal/lib/formatSize"; -import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui"; +import { + Banner, + Button, + HStack, + LoadingIcon, + AudioViewer, + ImageViewer, + SvgViewer, + VideoViewer, +} from "@yaakapp-internal/ui"; import type { ReactNode } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import type { SniffedValue } from "./core/Editor/sniffValue"; import { showDialog } from "../lib/dialog"; import { decodeValue, largeValueActions } from "../lib/largeValue"; import { Dropdown } from "./core/Dropdown"; -import { AudioViewer } from "./responseViewers/AudioViewer"; -import { ImageViewer } from "./responseViewers/ImageViewer"; -import { SvgViewer } from "./responseViewers/SvgViewer"; -import { VideoViewer } from "./responseViewers/VideoViewer"; const PdfViewer = lazy(() => import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), diff --git a/apps/yaak-client/components/MoveToWorkspaceDialog.tsx b/apps/yaak-client/components/MoveToWorkspaceDialog.tsx index 564e64132..6f1bb4ca7 100644 --- a/apps/yaak-client/components/MoveToWorkspaceDialog.tsx +++ b/apps/yaak-client/components/MoveToWorkspaceDialog.tsx @@ -3,12 +3,12 @@ import { patchModel, workspacesAtom } from "@yaakapp-internal/models"; import { InlineCode, VStack } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; import { useState } from "react"; -import { pluralizeCount } from "../lib/pluralize"; import { resolvedModelName } from "../lib/resolvedModelName"; import { router } from "../lib/router"; import { showToast } from "../lib/toast"; import { Button } from "./core/Button"; import { Select } from "./core/Select"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; interface Props { activeWorkspaceId: string; diff --git a/apps/yaak-client/components/RecentGrpcConnectionsDropdown.tsx b/apps/yaak-client/components/RecentGrpcConnectionsDropdown.tsx index 224731fb5..26f034edc 100644 --- a/apps/yaak-client/components/RecentGrpcConnectionsDropdown.tsx +++ b/apps/yaak-client/components/RecentGrpcConnectionsDropdown.tsx @@ -1,6 +1,6 @@ import type { GrpcConnection } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models"; -import { HStack, Icon } from "@yaakapp-internal/ui"; +import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui"; import { differenceInHours, differenceInMinutes, @@ -9,10 +9,9 @@ import { isYesterday, } from "date-fns"; import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections"; -import { pluralizeCount } from "../lib/pluralize"; import { Dropdown, type DropdownItem } from "./core/Dropdown"; -import { formatMillis } from "./core/HttpResponseDurationTag"; import { IconButton } from "./core/IconButton"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; interface Props { connections: GrpcConnection[]; diff --git a/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx b/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx index 4db7a0f5a..94428b9a7 100644 --- a/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx +++ b/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx @@ -1,6 +1,6 @@ import type { HttpResponse } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models"; -import { HStack, Icon } from "@yaakapp-internal/ui"; +import { HStack, Icon, formatMillis, HttpStatusTag, SizeTag } from "@yaakapp-internal/ui"; import { differenceInHours, differenceInMinutes, @@ -12,10 +12,7 @@ import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses"; import { useKeyValue } from "../hooks/useKeyValue"; import { DismissibleBanner } from "./core/DismissibleBanner"; import { Dropdown, type DropdownItem } from "./core/Dropdown"; -import { formatMillis } from "./core/HttpResponseDurationTag"; -import { HttpStatusTag } from "./core/HttpStatusTag"; import { IconButton } from "./core/IconButton"; -import { SizeTag } from "./core/SizeTag"; interface Props { responses: HttpResponse[]; diff --git a/apps/yaak-client/components/RecentWebsocketConnectionsDropdown.tsx b/apps/yaak-client/components/RecentWebsocketConnectionsDropdown.tsx index 5b6aa1452..0d55a9e31 100644 --- a/apps/yaak-client/components/RecentWebsocketConnectionsDropdown.tsx +++ b/apps/yaak-client/components/RecentWebsocketConnectionsDropdown.tsx @@ -1,6 +1,6 @@ import type { WebsocketConnection } from "@yaakapp-internal/models"; import { deleteModel, getModel } from "@yaakapp-internal/models"; -import { HStack, Icon } from "@yaakapp-internal/ui"; +import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui"; import { differenceInHours, differenceInMinutes, @@ -9,10 +9,9 @@ import { isYesterday, } from "date-fns"; import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections"; -import { pluralizeCount } from "../lib/pluralize"; import { Dropdown, type DropdownItem } from "./core/Dropdown"; -import { formatMillis } from "./core/HttpResponseDurationTag"; import { IconButton } from "./core/IconButton"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; interface Props { connections: WebsocketConnection[]; diff --git a/apps/yaak-client/components/RequestBodyViewer.tsx b/apps/yaak-client/components/RequestBodyViewer.tsx index a5a77825f..f4e63ed66 100644 --- a/apps/yaak-client/components/RequestBodyViewer.tsx +++ b/apps/yaak-client/components/RequestBodyViewer.tsx @@ -1,17 +1,20 @@ import type { HttpResponse } from "@yaakapp-internal/models"; import { lazy, Suspense } from "react"; import { useHttpRequestBody } from "../hooks/useHttpRequestBody"; -import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType"; -import { LoadingIcon } from "@yaakapp-internal/ui"; +import { getMimeTypeFromContentType } from "../lib/contentType"; +import { + LoadingIcon, + languageFromContentType, + AudioViewer, + CsvViewer, + ImageViewer, + SvgViewer, + VideoViewer, + WebPageViewer, +} from "@yaakapp-internal/ui"; import { EmptyStateText } from "./EmptyStateText"; -import { AudioViewer } from "./responseViewers/AudioViewer"; -import { CsvViewer } from "./responseViewers/CsvViewer"; -import { ImageViewer } from "./responseViewers/ImageViewer"; import { MultipartViewer } from "./responseViewers/MultipartViewer"; -import { SvgViewer } from "./responseViewers/SvgViewer"; import { TextViewer } from "./responseViewers/TextViewer"; -import { VideoViewer } from "./responseViewers/VideoViewer"; -import { WebPageViewer } from "./responseViewers/WebPageViewer"; const PdfViewer = lazy(() => import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), diff --git a/apps/yaak-client/components/ResponseCookies.tsx b/apps/yaak-client/components/ResponseCookies.tsx index 235b9580a..4a9eb29ad 100644 --- a/apps/yaak-client/components/ResponseCookies.tsx +++ b/apps/yaak-client/components/ResponseCookies.tsx @@ -3,9 +3,9 @@ import classNames from "classnames"; import { useMemo } from "react"; import type { JSX } from "react/jsx-runtime"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; -import { CountBadge } from "./core/CountBadge"; import { DetailsBanner } from "./core/DetailsBanner"; -import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; +import { KeyValueRow } from "./core/KeyValueRow"; +import { CountBadge, KeyValueRows } from "@yaakapp-internal/ui"; interface Props { response: HttpResponse; diff --git a/apps/yaak-client/components/ResponseHeaders.tsx b/apps/yaak-client/components/ResponseHeaders.tsx index ea1182ffc..2e933e633 100644 --- a/apps/yaak-client/components/ResponseHeaders.tsx +++ b/apps/yaak-client/components/ResponseHeaders.tsx @@ -1,31 +1,16 @@ import type { HttpResponse } from "@yaakapp-internal/models"; -import { format, formatDistanceToNowStrict } from "date-fns"; -import { useMemo } from "react"; -import { CountBadge } from "./core/CountBadge"; -import { DetailsBanner } from "./core/DetailsBanner"; -import { IconButton } from "./core/IconButton"; -import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; import { platform } from "@yaakapp-internal/platform"; +import { HeadersTable, KeyValueRows } from "@yaakapp-internal/ui"; +import { format, formatDistanceToNowStrict } from "date-fns"; +import { DetailsBanner, useDetailsBannerStoredValue } from "./core/DetailsBanner"; +import { IconButton } from "./core/IconButton"; +import { KeyValueRow } from "./core/KeyValueRow"; interface Props { response: HttpResponse; } export function ResponseHeaders({ response }: Props) { - const responseHeaders = useMemo( - () => - [...response.headers].sort((a, b) => - a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()), - ), - [response.headers], - ); - const requestHeaders = useMemo( - () => - [...response.requestHeaders].sort((a, b) => - a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()), - ), - [response.requestHeaders], - ); return (
Info}> @@ -58,53 +43,21 @@ export function ResponseHeaders({ response }: Props) { - - Request Headers - - } - > - {requestHeaders.length === 0 ? ( - - ) : ( - - {requestHeaders.map((h, i) => ( - // oxlint-disable-next-line react/no-array-index-key - - {h.value} - - ))} - - )} - - + - Response Headers - - } - > - {responseHeaders.length === 0 ? ( - - ) : ( - - {responseHeaders.map((h, i) => ( - // oxlint-disable-next-line react/no-array-index-key - - {h.value} - - ))} - - )} - + useStoredValue={useDetailsBannerStoredValue} + />
); } - -function NoHeaders() { - return No Headers; -} diff --git a/apps/yaak-client/components/ResponseInfo.tsx b/apps/yaak-client/components/ResponseInfo.tsx index 378895fc3..7bdfe6115 100644 --- a/apps/yaak-client/components/ResponseInfo.tsx +++ b/apps/yaak-client/components/ResponseInfo.tsx @@ -1,7 +1,8 @@ import type { HttpResponse } from "@yaakapp-internal/models"; import { IconButton } from "./core/IconButton"; -import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; +import { KeyValueRow } from "./core/KeyValueRow"; import { platform } from "@yaakapp-internal/platform"; +import { KeyValueRows } from "@yaakapp-internal/ui"; interface Props { response: HttpResponse; diff --git a/apps/yaak-client/components/SelectFile.tsx b/apps/yaak-client/components/SelectFile.tsx index bc1487238..800a05d21 100644 --- a/apps/yaak-client/components/SelectFile.tsx +++ b/apps/yaak-client/components/SelectFile.tsx @@ -1,5 +1,5 @@ import { platform } from "@yaakapp-internal/platform"; -import { HStack } from "@yaakapp-internal/ui"; +import { HStack, IconTooltip, Label } from "@yaakapp-internal/ui"; import classNames from "classnames"; import mime from "mime"; import type { ReactNode } from "react"; @@ -7,8 +7,6 @@ import { useEffect, useRef, useState } from "react"; import type { ButtonProps } from "./core/Button"; import { Button } from "./core/Button"; import { IconButton } from "./core/IconButton"; -import { IconTooltip } from "./core/IconTooltip"; -import { Label } from "./core/Label"; type Props = Omit & { onChange: (value: { filePath: string | null; contentType: string | null }) => void; diff --git a/apps/yaak-client/components/Settings/Settings.tsx b/apps/yaak-client/components/Settings/Settings.tsx index 5588734df..a88e0b010 100644 --- a/apps/yaak-client/components/Settings/Settings.tsx +++ b/apps/yaak-client/components/Settings/Settings.tsx @@ -1,13 +1,12 @@ import { platform } from "@yaakapp-internal/platform"; import { useLicense } from "@yaakapp-internal/license"; import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models"; -import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui"; +import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import { useKeyPressEvent } from "react-use"; import { appInfo } from "../../lib/appInfo"; import { capitalize } from "../../lib/capitalize"; -import { CountBadge } from "../core/CountBadge"; import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs"; import { SettingsCertificates } from "./SettingsCertificates"; import { SettingsGeneral } from "./SettingsGeneral"; diff --git a/apps/yaak-client/components/Settings/SettingsLicense.tsx b/apps/yaak-client/components/Settings/SettingsLicense.tsx index f2ac0016d..9e72d481c 100644 --- a/apps/yaak-client/components/Settings/SettingsLicense.tsx +++ b/apps/yaak-client/components/Settings/SettingsLicense.tsx @@ -4,7 +4,6 @@ import { differenceInDays } from "date-fns"; import { formatDate } from "date-fns/format"; import { useState } from "react"; import { useToggle } from "../../hooks/useToggle"; -import { pluralizeCount } from "../../lib/pluralize"; import { pricingUrl } from "../../lib/pricingUrl"; import { CargoFeature } from "../CargoFeature"; import { Button } from "../core/Button"; @@ -12,6 +11,7 @@ import { Link } from "../core/Link"; import { PlainInput } from "../core/PlainInput"; import { Separator } from "../core/Separator"; import { platform } from "@yaakapp-internal/platform"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; export function SettingsLicense() { return ( diff --git a/apps/yaak-client/components/Settings/SettingsPlugins.tsx b/apps/yaak-client/components/Settings/SettingsPlugins.tsx index e83cdc382..225ab7518 100644 --- a/apps/yaak-client/components/Settings/SettingsPlugins.tsx +++ b/apps/yaak-client/components/Settings/SettingsPlugins.tsx @@ -20,6 +20,7 @@ import { TableHeaderCell, TableRow, useDebouncedValue, + CountBadge, } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; @@ -31,7 +32,6 @@ import { showConfirmDelete } from "../../lib/confirm"; import { minPromiseMillis } from "../../lib/minPromiseMillis"; import { Button } from "../core/Button"; import { Checkbox } from "../core/Checkbox"; -import { CountBadge } from "../core/CountBadge"; import { IconButton } from "../core/IconButton"; import { Link } from "../core/Link"; import { PlainInput } from "../core/PlainInput"; diff --git a/apps/yaak-client/components/Sidebar.tsx b/apps/yaak-client/components/Sidebar.tsx index 93762707c..b072b148a 100644 --- a/apps/yaak-client/components/Sidebar.tsx +++ b/apps/yaak-client/components/Sidebar.tsx @@ -69,7 +69,6 @@ import type { Ast } from "./core/Editor/filter/query"; import { evaluate, parseQuery } from "./core/Editor/filter/query"; import { formatFieldFilter } from "./core/Editor/filter/format"; import { HttpMethodTag } from "./core/HttpMethodTag"; -import { HttpStatusTag } from "./core/HttpStatusTag"; import { Icon, LoadingIcon, @@ -77,6 +76,7 @@ import { isSelectedFamily, selectedIdsFamily, InlineCode, + HttpStatusTag, } from "@yaakapp-internal/ui"; import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui"; import { IconButton } from "./core/IconButton"; diff --git a/apps/yaak-client/components/WebsocketRequestPane.tsx b/apps/yaak-client/components/WebsocketRequestPane.tsx index 26ece5412..f4868a111 100644 --- a/apps/yaak-client/components/WebsocketRequestPane.tsx +++ b/apps/yaak-client/components/WebsocketRequestPane.tsx @@ -22,11 +22,9 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; -import { languageFromContentType } from "../lib/contentType"; import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { resolvedModelName } from "../lib/resolvedModelName"; -import { CountBadge } from "./core/CountBadge"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import { getUrlCompletionConfig } from "./core/Editor/url/completion"; import { Editor } from "./core/Editor/LazyEditor"; @@ -40,6 +38,7 @@ import { MarkdownEditor } from "./MarkdownEditor"; import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor"; import { UrlBar } from "./UrlBar"; import { UrlParametersEditor } from "./UrlParameterEditor"; +import { languageFromContentType, CountBadge } from "@yaakapp-internal/ui"; interface Props { style: CSSProperties; diff --git a/apps/yaak-client/components/WebsocketResponsePane.tsx b/apps/yaak-client/components/WebsocketResponsePane.tsx index 42806e4e7..46cebd3a8 100644 --- a/apps/yaak-client/components/WebsocketResponsePane.tsx +++ b/apps/yaak-client/components/WebsocketResponsePane.tsx @@ -1,5 +1,12 @@ import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models"; -import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui"; +import { + HStack, + Icon, + LoadingIcon, + VStack, + useStateWithDeps, + languageFromContentType, +} from "@yaakapp-internal/ui"; import { hexy } from "hexy"; import { useAtomValue } from "jotai"; import { useMemo, useState } from "react"; @@ -10,8 +17,6 @@ import { setPinnedWebsocketConnectionId, useWebsocketEvents, } from "../hooks/usePinnedWebsocketConnection"; -import { useStateWithDeps } from "../hooks/useStateWithDeps"; -import { languageFromContentType } from "../lib/contentType"; import { Button } from "./core/Button"; import { Editor } from "./core/Editor/LazyEditor"; import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer"; diff --git a/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx b/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx index 43b88abe3..eadbf4790 100644 --- a/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx +++ b/apps/yaak-client/components/WorkspaceEncryptionSetting.tsx @@ -5,20 +5,17 @@ import { setWorkspaceKey, } from "@yaakapp-internal/crypto"; import type { WorkspaceMeta } from "@yaakapp-internal/models"; -import { Banner, HStack, VStack } from "@yaakapp-internal/ui"; +import { Banner, HStack, VStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import { useEffect, useState } from "react"; import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace"; import { createFastMutation } from "../hooks/useFastMutation"; -import { useStateWithDeps } from "../hooks/useStateWithDeps"; import { showConfirm } from "../lib/confirm"; import { CopyIconButton } from "./CopyIconButton"; import type { ButtonProps } from "./core/Button"; import { Button } from "./core/Button"; import { IconButton } from "./core/IconButton"; -import { IconTooltip } from "./core/IconTooltip"; -import { Label } from "./core/Label"; import { PlainInput } from "./core/PlainInput"; import { SettingRow } from "./core/SettingRow"; import { EncryptionHelp } from "./EncryptionHelp"; diff --git a/apps/yaak-client/components/WorkspaceSettingsDialog.tsx b/apps/yaak-client/components/WorkspaceSettingsDialog.tsx index 56d98a8de..a0cddc2af 100644 --- a/apps/yaak-client/components/WorkspaceSettingsDialog.tsx +++ b/apps/yaak-client/components/WorkspaceSettingsDialog.tsx @@ -1,5 +1,5 @@ import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; +import { Banner, HStack, InlineCode, CountBadge } from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; import { useAuthTab } from "../hooks/useAuthTab"; import { useHeadersTab } from "../hooks/useHeadersTab"; @@ -9,7 +9,6 @@ import { showDialog } from "../lib/dialog"; import { router } from "../lib/router"; import { CopyIconButton } from "./CopyIconButton"; import { Button } from "./core/Button"; -import { CountBadge } from "./core/CountBadge"; import { PlainInput } from "./core/PlainInput"; import { SettingsList, SettingsSection } from "./core/SettingRow"; import { TabContent, Tabs } from "./core/Tabs/Tabs"; diff --git a/apps/yaak-client/components/core/BulkPairEditor.tsx b/apps/yaak-client/components/core/BulkPairEditor.tsx index f25d09523..f15945d46 100644 --- a/apps/yaak-client/components/core/BulkPairEditor.tsx +++ b/apps/yaak-client/components/core/BulkPairEditor.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; -import { generateId } from "../../lib/generateId"; import { Editor } from "./Editor/LazyEditor"; import type { Pair, PairEditorProps, PairWithId } from "./PairEditor"; +import { generateId } from "@yaakapp-internal/lib/generateId"; type Props = PairEditorProps; diff --git a/apps/yaak-client/components/core/Checkbox.tsx b/apps/yaak-client/components/core/Checkbox.tsx index 92b19424b..670efe94f 100644 --- a/apps/yaak-client/components/core/Checkbox.tsx +++ b/apps/yaak-client/components/core/Checkbox.tsx @@ -1,7 +1,6 @@ -import { HStack, Icon } from "@yaakapp-internal/ui"; +import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { ReactNode } from "react"; -import { IconTooltip } from "./IconTooltip"; export interface CheckboxProps { checked: boolean | "indeterminate"; diff --git a/apps/yaak-client/components/core/DetailsBanner.tsx b/apps/yaak-client/components/core/DetailsBanner.tsx index 1cc49f729..7d4a4b89b 100644 --- a/apps/yaak-client/components/core/DetailsBanner.tsx +++ b/apps/yaak-client/components/core/DetailsBanner.tsx @@ -1,60 +1,27 @@ -import classNames from "classnames"; -import { atom, useAtom } from "jotai"; -import type { HTMLAttributes, ReactNode } from "react"; -import { useMemo } from "react"; +import { + DetailsBanner as BaseDetailsBanner, + type DetailsBannerProps, + type StoredValue, +} from "@yaakapp-internal/ui"; +import { atom, useAtom, type WritableAtom } from "jotai"; +import { type SetStateAction, useMemo } from "react"; import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage"; -import type { BannerProps } from "@yaakapp-internal/ui"; -import { Banner } from "@yaakapp-internal/ui"; -interface Props extends HTMLAttributes { - summary: ReactNode; - color?: BannerProps["color"]; - defaultOpen?: boolean; - storageKey?: string; +export function DetailsBanner(props: Omit) { + return ; } -export function DetailsBanner({ - className, - color, - summary, - children, - defaultOpen, - storageKey, - ...extraProps -}: Props) { - // oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes - const openAtom = useMemo( +export function useDetailsBannerStoredValue( + key: string | string[] | null, + fallback: T, +) { + // oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when the key changes + const valueAtom = useMemo], void>>( () => - storageKey - ? atomWithKVStorage(["details_banner", storageKey], defaultOpen ?? false) - : atom(defaultOpen ?? false), - [storageKey], - ); - - const [isOpen, setIsOpen] = useAtom(openAtom); - - const handleToggle = (e: React.SyntheticEvent) => { - if (storageKey) { - setIsOpen(e.currentTarget.open); - } - }; - - return ( - -
- -
- {summary} -
-
{children}
-
-
+ key != null + ? atomWithKVStorage(["details_banner", ...[key].flat()], fallback) + : atom(fallback), + [key], ); + return useAtom(valueAtom); } diff --git a/apps/yaak-client/components/core/Dropdown.tsx b/apps/yaak-client/components/core/Dropdown.tsx index 8b3b94e98..d35687b21 100644 --- a/apps/yaak-client/components/core/Dropdown.tsx +++ b/apps/yaak-client/components/core/Dropdown.tsx @@ -1,4 +1,12 @@ -import { HStack, Icon, type IconProps, LoadingIcon, Overlay, VStack } from "@yaakapp-internal/ui"; +import { + HStack, + Icon, + type IconProps, + LoadingIcon, + Overlay, + VStack, + useStateWithDeps, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; import { atom } from "jotai"; import * as m from "motion/react-m"; @@ -28,8 +36,6 @@ import { useKey, useWindowSize } from "react-use"; import { useClickOutside } from "../../hooks/useClickOutside"; import type { HotkeyAction } from "../../hooks/useHotKey"; import { useHotKey } from "../../hooks/useHotKey"; -import { useStateWithDeps } from "../../hooks/useStateWithDeps"; -import { generateId } from "../../lib/generateId"; import { getNodeText } from "../../lib/getNodeText"; import { jotaiStore } from "../../lib/jotai"; import { fireAndForget } from "../../lib/fireAndForget"; @@ -39,6 +45,7 @@ import { Hotkey } from "./Hotkey"; import { IconButton } from "./IconButton"; import type { SeparatorAction } from "./Separator"; import { Separator } from "./Separator"; +import { generateId } from "@yaakapp-internal/lib/generateId"; export type DropdownItemSeparator = { type: "separator"; diff --git a/apps/yaak-client/components/core/Editor/DiffViewer.tsx b/apps/yaak-client/components/core/Editor/DiffViewer.tsx index f36f28ee4..889a8f992 100644 --- a/apps/yaak-client/components/core/Editor/DiffViewer.tsx +++ b/apps/yaak-client/components/core/Editor/DiffViewer.tsx @@ -5,7 +5,8 @@ import { EditorView } from "@codemirror/view"; import classNames from "classnames"; import { useEffect, useRef } from "react"; import "./DiffViewer.css"; -import { readonlyExtensions, syntaxHighlightStyle } from "./extensions"; +import { syntaxHighlightStyle } from "@yaakapp-internal/ui/editor"; +import { readonlyExtensions } from "./extensions"; interface Props { /** Original/previous version (left side) */ diff --git a/apps/yaak-client/components/core/Editor/Editor.tsx b/apps/yaak-client/components/core/Editor/Editor.tsx index 85eac28ce..fb524b336 100644 --- a/apps/yaak-client/components/core/Editor/Editor.tsx +++ b/apps/yaak-client/components/core/Editor/Editor.tsx @@ -1,147 +1,53 @@ import { startCompletion } from "@codemirror/autocomplete"; -import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands"; -import { foldState, forceParsing } from "@codemirror/language"; -import type { EditorStateConfig, Extension } from "@codemirror/state"; -import { Compartment, EditorState } from "@codemirror/state"; -import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view"; -import { emacs } from "@replit/codemirror-emacs"; -import { vim } from "@replit/codemirror-vim"; - -import { vscodeKeymap } from "@replit/codemirror-vscode-keymap"; -import { debounce } from "@yaakapp-internal/lib"; -import type { EditorKeymap } from "@yaakapp-internal/models"; +import type { Extension } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; import { settingsAtom } from "@yaakapp-internal/models"; import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins"; -import { HStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; +import { CodeEditor, type CodeEditorProps } from "@yaakapp-internal/ui/editor"; import type { GraphQLSchema } from "graphql"; import { useAtomValue } from "jotai"; -import type { ReactNode, RefObject } from "react"; -import { - Children, - cloneElement, - isValidElement, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, -} from "react"; +import { useCallback, useMemo, useRef } from "react"; import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment"; import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables"; import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables"; import { eventMatchesHotkey } from "../../../hooks/useHotKey"; import { useRequestEditor } from "../../../hooks/useRequestEditor"; import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions"; -import { docFingerprint } from "../../../lib/docFingerprint"; import { editEnvironment } from "../../../lib/editEnvironment"; import { tryFormatJson, tryFormatXml } from "../../../lib/formatters"; import { jotaiStore } from "../../../lib/jotai"; import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption"; import { TemplateFunctionDialog } from "../../TemplateFunctionDialog"; -import { IconButton } from "../IconButton"; -import "./Editor.css"; -import { - baseExtensions, - editableExtensions, - getLanguageExtension, - multiLineExtensions, - readonlyExtensions, -} from "./extensions"; +import { getLanguageExtension } from "./extensions"; import type { GenericCompletionConfig } from "./genericCompletion"; -import { singleLineExtensions } from "./singleLine"; +import { largeValues, mediaValues } from "./largeValues"; -// VSCode's Tab actions mess with the single-line editor tab actions, so remove it. -const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab"); - -const keymapExtensions: Record = { - vim: vim(), - emacs: emacs(), - vscode: keymap.of(vsCodeWithoutTab), - default: [], -}; - -export interface EditorProps { - actions?: ReactNode; - autoFocus?: boolean; - autoSelect?: boolean; +export type EditorProps = Omit & { autocomplete?: GenericCompletionConfig; autocompleteFunctions?: boolean; autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean); - className?: string; - defaultValue?: string | null; - disableTabIndent?: boolean; - disabled?: boolean; extraExtensions?: Extension[] | Extension; forcedEnvironmentId?: string; - forceUpdateKey?: string | number; - format?: (v: string) => Promise; - heightMode?: "auto" | "full"; - hideGutter?: boolean; - id?: string; - language?: EditorLanguage | "pairs" | "url" | "timeline" | null; - lintExtension?: Extension; graphQLSchema?: GraphQLSchema | null; - onBlur?: () => void; - onChange?: (value: string) => void; - onFocus?: () => void; - onKeyDown?: (e: KeyboardEvent) => void; - onPaste?: (value: string) => void; - onPasteOverwrite?: (e: ClipboardEvent, value: string) => void; - placeholder?: string; - readOnly?: boolean; - singleLine?: boolean; - containerOnly?: boolean; - stateKey: string | null; - tooltipContainer?: HTMLElement; - type?: "text" | "password"; - wrapLines?: boolean; - setRef?: (view: EditorView | null) => void; -} - -const stateFields = { history: historyField, folds: foldState }; + language?: EditorLanguage | "pairs" | "url" | "timeline" | null; +}; const emptyVariables: WrappedEnvironmentVariable[] = []; -const emptyExtension: Extension = []; -export function Editor(props: EditorProps) { - return ; -} - -function EditorInner({ - actions, - autoFocus, - autoSelect, +export function Editor({ autocomplete, autocompleteFunctions, autocompleteVariables, - className, - defaultValue, - disableTabIndent, - disabled, extraExtensions, forcedEnvironmentId, - forceUpdateKey, format, - heightMode, - hideGutter, graphQLSchema, + hideGutter, language, lintExtension, - onBlur, - onChange, - onFocus, - onKeyDown, - onPaste, - onPasteOverwrite, - placeholder, readOnly, - singleLine, - containerOnly, - stateKey, - type, wrapLines, - setRef, + ...props }: EditorProps) { const settings = useAtomValue(settingsAtom); @@ -158,20 +64,10 @@ function EditorInner({ wrapLines = settings.editorSoftWrap; } - if (disabled) { + if (props.disabled) { readOnly = true; } - if ( - singleLine || - language == null || - language === "text" || - language === "url" || - language === "pairs" - ) { - disableTabIndent = true; - } - if (format == null && !readOnly) { format = language === "json" @@ -181,130 +77,22 @@ function EditorInner({ : undefined; } - const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); - - // Use ref so we can update the handler without re-initializing the editor - const handleChange = useRef(onChange); - useEffect(() => { - handleChange.current = onChange; - }, [onChange]); - - // Use ref so we can update the handler without re-initializing the editor - const handlePaste = useRef(onPaste); - useEffect(() => { - handlePaste.current = onPaste; - }, [onPaste]); - - // Use ref so we can update the handler without re-initializing the editor - const handlePasteOverwrite = useRef(onPasteOverwrite); - useEffect(() => { - handlePasteOverwrite.current = onPasteOverwrite; - }, [onPasteOverwrite]); - - // Use ref so we can update the handler without re-initializing the editor - const handleFocus = useRef(onFocus); - useEffect(() => { - handleFocus.current = onFocus; - }, [onFocus]); - - // Use ref so we can update the handler without re-initializing the editor - const handleBlur = useRef(onBlur); - useEffect(() => { - handleBlur.current = onBlur; - }, [onBlur]); - - // Use ref so we can update the handler without re-initializing the editor - const handleKeyDown = useRef(onKeyDown); - useEffect(() => { - handleKeyDown.current = onKeyDown; - }, [onKeyDown]); - - // Update placeholder - const placeholderCompartment = useRef(new Compartment()); - useEffect( - function configurePlaceholder() { - if (cm.current === null) return; - const ext = placeholderExt(placeholderElFromText(placeholder)); - const effects = placeholderCompartment.current.reconfigure(ext); - cm.current?.view.dispatch({ effects }); + // Template dialogs are opened against the editor the tag was clicked in + const viewRef = useRef(null); + const { setRef } = props; + const handleSetRef = useCallback( + (view: EditorView | null) => { + viewRef.current = view; + setRef?.(view); }, - [placeholder], - ); - - // Update vim - const keymapCompartment = useRef(new Compartment()); - useEffect( - function configureKeymap() { - if (cm.current === null) return; - const current = keymapCompartment.current.get(cm.current.view.state) ?? []; - // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary - if (settings.editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do - if (settings.editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do - if (settings.editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do - if (settings.editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do - - const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default; - const effects = keymapCompartment.current.reconfigure(ext); - cm.current.view.dispatch({ effects }); - }, - [settings.editorKeymap], - ); - - // Update wrap lines - const wrapLinesCompartment = useRef(new Compartment()); - useEffect( - function configureWrapLines() { - if (cm.current === null) return; - const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension; - // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary - if (wrapLines && current !== emptyExtension) return; // Nothing to do - if (!wrapLines && current === emptyExtension) return; // Nothing to do - - const ext = wrapLines ? EditorView.lineWrapping : emptyExtension; - const effects = wrapLinesCompartment.current.reconfigure(ext); - cm.current?.view.dispatch({ effects }); - }, - [wrapLines], - ); - - // Update tab indent - const tabIndentCompartment = useRef(new Compartment()); - useEffect( - function configureTabIndent() { - if (cm.current === null) return; - const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension; - // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary - if (disableTabIndent && current !== emptyExtension) return; // Nothing to do - if (!disableTabIndent && current === emptyExtension) return; // Nothing to do - - const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension; - const effects = tabIndentCompartment.current.reconfigure(ext); - cm.current?.view.dispatch({ effects }); - }, - [disableTabIndent], - ); - - // Update read-only - const readOnlyCompartment = useRef(new Compartment()); - useEffect( - function configureReadOnly() { - if (cm.current === null) return; - const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension; - const next = readOnly ? readonlyExtensions : editableExtensions; - // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary - if (current === next) return; - - const effects = readOnlyCompartment.current.reconfigure(next); - cm.current?.view.dispatch({ effects }); - }, - [readOnly], + [setRef], ); const onClickFunction = useCallback( async (fn: TemplateFunction, tagValue: string, startPos: number) => { const show = () => { - if (cm.current === null) return; - TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view); + if (viewRef.current === null) return; + TemplateFunctionDialog.show(fn, tagValue, startPos, viewRef.current); }; if (fn.name === "secure") { @@ -344,455 +132,64 @@ function EditorInner({ !!autocompleteFunctions, ); - // Update the language extension when the language changes - // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps - useEffect(() => { - if (cm.current === null) return; - const { view, languageCompartment } = cm.current; - const ext = getLanguageExtension({ - useTemplating, + const languageExtension = useMemo( + () => + getLanguageExtension({ + useTemplating, + language, + lintExtension, + hideGutter, + environmentVariables, + autocomplete, + completionOptions, + onClickVariable, + onClickMissingVariable, + onClickPathParameter, + graphQLSchema: graphQLSchema ?? null, + }), + [ language, lintExtension, - hideGutter, - environmentVariables, autocomplete, - completionOptions, + environmentVariables, onClickVariable, onClickMissingVariable, onClickPathParameter, - graphQLSchema: graphQLSchema ?? null, - }); - view.dispatch({ effects: languageCompartment.reconfigure(ext) }); - }, [ - language, - lintExtension, - autocomplete, - environmentVariables, - onClickFunction, - onClickVariable, - onClickMissingVariable, - onClickPathParameter, - completionOptions, - useTemplating, - graphQLSchema, - hideGutter, - ]); - - // Initialize the editor when ref mounts - // oxlint-disable-next-line react-hooks/exhaustive-deps -- only reinitialize when necessary - const initEditorRef = useCallback( - function initEditorRef(container: HTMLDivElement | null) { - if (container === null) { - flushCachedEditorState(stateKey); - cm.current?.view.destroy(); - cm.current = null; - return; - } - - try { - const languageCompartment = new Compartment(); - const langExt = getLanguageExtension({ - useTemplating, - language, - lintExtension, - completionOptions, - autocomplete, - environmentVariables, - onClickVariable, - onClickMissingVariable, - onClickPathParameter, - graphQLSchema: graphQLSchema ?? null, - }); - const extensions = [ - languageCompartment.of(langExt), - placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))), - wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension), - tabIndentCompartment.current.of( - !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension, - ), - keymapCompartment.current.of( - keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default, - ), - readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions), - ...getExtensions({ - container, - singleLine, - hideGutter, - stateKey, - onChange: handleChange, - onPaste: handlePaste, - onPasteOverwrite: handlePasteOverwrite, - onFocus: handleFocus, - onBlur: handleBlur, - onKeyDown: handleKeyDown, - }), - ...(Array.isArray(extraExtensions) - ? extraExtensions - : extraExtensions - ? [extraExtensions] - : []), - ]; - - const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly); - - const doc = `${defaultValue ?? ""}`; - const config: EditorStateConfig = { extensions, doc }; - - const state = cachedJsonState - ? EditorState.fromJSON(cachedJsonState, config, stateFields) - : EditorState.create(config); - - // PERF: Built detached and attached in a microtask, rather than passing `parent` and - // letting the constructor attach it first. - // - // The constructor sets `contenteditable` on the content element and then reads - // `document.fonts.ready`. Both force the browser to flush style and layout when the - // element is already in the document, and whatever the previous editor attached has - // just dirtied it again — so mounting n editors in one commit costs n forced layouts. - // The environment editor mounts two per variable row (~60 of them), which is where its - // half-second open went. - // - // Building detached moves that work off the live document, and deferring the attach to - // a microtask means every editor in the commit is built before any of them is inserted: - // one forced layout for the batch instead of one each. The microtask still runs before - // paint, so nothing is visible in the meantime. - const view = new EditorView({ state }); - - // For large documents, the parser may parse the max number of lines and fail to add - // things like fold markers because of it. - // This forces it to parse more but keeps the timeout to the default of 100 ms. - forceParsing(view, 9e6, 100); - - cm.current = { view, languageCompartment }; - queueMicrotask(() => { - // The editor was torn down (or replaced) before we got here - if (cm.current?.view !== view) return; - - // Runs after the surrounding try/catch has returned, so it needs its own - try { - container.appendChild(view.dom); - // Everything the constructor measured, it measured detached - view.requestMeasure(); - - if (autoFocus) { - view.focus(); - } - if (autoSelect) { - view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } }); - } - } catch (e) { - console.log("Failed to attach Codemirror", e); - } - }); - setRef?.(view); - } catch (e) { - console.log("Failed to initialize Codemirror", e); - } - }, - [forceUpdateKey], + completionOptions, + useTemplating, + graphQLSchema, + hideGutter, + ], ); - // For read-only mode, update content when `defaultValue` changes - useEffect( - function updateReadOnlyEditor() { - if (readOnly && cm.current?.view != null) { - updateContents(cm.current.view, defaultValue || ""); - } - }, - [defaultValue, readOnly], - ); - - // Force input to update when receiving change and not in focus - useLayoutEffect( - function updateNonFocusedEditor() { - const notFocused = !cm.current?.view.hasFocus; - if (notFocused && cm.current != null) { - updateContents(cm.current.view, defaultValue || ""); - } - }, - [defaultValue], - ); - - // Add bg classes to actions, so they appear over the text - const decoratedActions = useMemo(() => { - const results = []; - const actionClassName = classNames( - "bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow", - ); - - if (format) { - results.push( - { - 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 = ( -
- ); - - if (singleLine || containerOnly) { - return cmContainer; - } - return ( -
- {cmContainer} - {decoratedActions && ( - - {decoratedActions} - - )} -
+ ); } -function getExtensions({ - stateKey, - container, - singleLine, - hideGutter, - onChange, - onPaste, - onPasteOverwrite, - onFocus, - onBlur, - onKeyDown, -}: Pick & { - stateKey: EditorProps["stateKey"]; - container: HTMLDivElement | null; - onChange: RefObject; - onPaste: RefObject; - onPasteOverwrite: RefObject; - onFocus: RefObject; - onBlur: RefObject; - onKeyDown: RefObject; -}) { - // TODO: Ensure tooltips render inside the dialog if we are in one. - const parent = - container?.closest('[role="dialog"]') ?? - document.querySelector("#cm-portal") ?? - undefined; - - return [ - ...baseExtensions, // Must be first - EditorView.contentAttributes.of({ - autocapitalize: "off", - autocorrect: "off", - // Keeps macOS Writing Tools from offering to write code for us - writingsuggestions: "false", - }), - EditorView.domEventHandlers({ - focus: () => { - onFocus.current?.(); - }, - blur: () => { - onBlur.current?.(); - }, - keydown: (e, view) => { - // Check if the hotkey matches the editor.autocomplete action - if (eventMatchesHotkey(e, "editor.autocomplete")) { - e.preventDefault(); - startCompletion(view); - return true; - } - onKeyDown.current?.(e); - }, - paste: (e, v) => { - const textData = e.clipboardData?.getData("text/plain") ?? ""; - onPaste.current?.(textData); - if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) { - onPasteOverwrite.current?.(e, textData); - } - }, - }), - tooltips({ parent }), - keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap), - ...(singleLine ? [singleLineExtensions()] : []), - ...(!singleLine ? multiLineExtensions({ hideGutter }) : []), - - // ------------------------ // - // Things that must be last // - // ------------------------ // - - EditorView.updateListener.of((update) => { - if (update.startState === update.state) return; - - if (onChange && update.docChanged) { - onChange.current?.(update.state.doc.toString()); - } - - saveCachedEditorStateDebounced(stateKey, update.state); - }), - ]; +/** The client's own languages bring their extension along, so CodeEditor needn't know them */ +function codeEditorLanguage(language: EditorProps["language"]): CodeEditorProps["language"] { + return language === "url" || language === "pairs" || language === "timeline" ? null : language; } -const placeholderElFromText = (text: string | undefined) => { - const el = document.createElement("div"); - // Default to 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", "
") : " "; - 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>(); - -function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) { - if (!stateKey) return; - let saver = stateSavers.get(stateKey); - if (saver == null) { - saver = debounce( - (s: EditorState) => saveCachedEditorState(stateKey, s), - SAVE_STATE_DEBOUNCE_MS, - ); - stateSavers.set(stateKey, saver); - } - saver(state); -} - -// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map -// for every state key the session has ever shown. A pending saver holds the last EditorState, -// which holds the whole document. -function flushCachedEditorState(stateKey: string | null) { - if (!stateKey) return; - const saver = stateSavers.get(stateKey); - if (saver == null) return; - saver.flush(); - stateSavers.delete(stateKey); -} - -function saveCachedEditorState(stateKey: string | null, state: EditorState | null) { - if (!stateKey || state == null) return; - const stateObj = state.toJSON(stateFields); - - // Save state in sessionStorage by removing doc and saving a fingerprint of it instead. - // This will be checked on restore and put back in if it matches. - // Editable documents get the exact hash, so a collision can never restore undo history - // belonging to other content - stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly }); - stateObj.doc = undefined; - - try { - sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj)); - } catch (err) { - console.log("Failed to save to editor state", stateKey, err); - } -} - -function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) { - if (stateKey == null) return; - - try { - const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey)); - if (stateStr == null) return null; - - const { docHash, ...state } = JSON.parse(stateStr); - - // Ensure the doc matches the one that was used to save the state - if (docHash !== docFingerprint(doc, { exact: !readOnly })) { - return null; - } - - state.doc = doc; - return state; - } catch (err) { - console.log("Failed to restore editor storage", stateKey, err); - } - - return null; -} - -function computeFullStateKey(stateKey: string): string { - return `editor.${stateKey}`; -} - -function updateContents(view: EditorView, text: string) { - // Replace codemirror contents - const currentDoc = view.state.doc.toString(); - - if (currentDoc === text) { - return; - } - - if (text.startsWith(currentDoc)) { - // If we're just appending, append only the changes. This preserves - // things like scroll position. - view.dispatch({ - changes: view.state.changes({ - from: currentDoc.length, - insert: text.slice(currentDoc.length), - }), - }); - } else { - // If we're replacing everything, reset the entire content - view.dispatch({ - changes: view.state.changes({ - from: 0, - to: currentDoc.length, - insert: text, - }), - }); - } +function startCompletionOnHotkey(e: KeyboardEvent, view: EditorView): boolean { + if (!eventMatchesHotkey(e, "editor.autocomplete")) return false; + e.preventDefault(); + startCompletion(view); + return true; } diff --git a/apps/yaak-client/components/core/Editor/extensions.ts b/apps/yaak-client/components/core/Editor/extensions.ts index 29be56cba..80eff01cc 100644 --- a/apps/yaak-client/components/core/Editor/extensions.ts +++ b/apps/yaak-client/components/core/Editor/extensions.ts @@ -1,141 +1,30 @@ -import { - autocompletion, - closeBrackets, - closeBracketsKeymap, - completionKeymap, -} from "@codemirror/autocomplete"; -import { history, historyKeymap } from "@codemirror/commands"; -import { go } from "@codemirror/lang-go"; -import { java } from "@codemirror/lang-java"; -import { javascript } from "@codemirror/lang-javascript"; -import { markdown } from "@codemirror/lang-markdown"; -import { php } from "@codemirror/lang-php"; -import { python } from "@codemirror/lang-python"; -import { xml } from "@codemirror/lang-xml"; -import { - bracketMatching, - codeFolding, - foldGutter, - foldKeymap, - HighlightStyle, - indentOnInput, - LanguageSupport, - StreamLanguage, - syntaxHighlighting, -} from "@codemirror/language"; -import { c, csharp, kotlin, objectiveC } from "@codemirror/legacy-modes/mode/clike"; -import { clojure } from "@codemirror/legacy-modes/mode/clojure"; -import { http } from "@codemirror/legacy-modes/mode/http"; -import { oCaml } from "@codemirror/legacy-modes/mode/mllike"; -import { powerShell } from "@codemirror/legacy-modes/mode/powershell"; -import { r } from "@codemirror/legacy-modes/mode/r"; -import { ruby } from "@codemirror/legacy-modes/mode/ruby"; -import { shell } from "@codemirror/legacy-modes/mode/shell"; -import { swift } from "@codemirror/legacy-modes/mode/swift"; -import { linter, lintGutter, lintKeymap } from "@codemirror/lint"; -import { search, searchKeymap } from "@codemirror/search"; +import { linter } from "@codemirror/lint"; import type { Extension } from "@codemirror/state"; -import { EditorState } from "@codemirror/state"; import { - crosshairCursor, - drawSelection, - dropCursor, - EditorView, - highlightActiveLineGutter, - highlightSpecialChars, - keymap, - lineNumbers, - rectangularSelection, -} from "@codemirror/view"; -import { tags as t } from "@lezer/highlight"; -import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc"; + closeBracketsExtensions, + getLanguageExtension as getBaseLanguageExtension, + type LanguageSupports, + readonlyExtensions as baseReadonlyExtensions, + syntaxExtensions, +} from "@yaakapp-internal/ui/editor"; import { graphql } from "cm6-graphql"; import type { GraphQLSchema } from "graphql"; import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId"; import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables"; import { jotaiStore } from "../../../lib/jotai"; import { renderMarkdown } from "../../../lib/markdown"; -import { pluralizeCount } from "../../../lib/pluralize"; import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms"; import type { EditorProps } from "./Editor"; import { jsonParseLinter } from "./json-lint"; -import { largeValues, mediaValues } from "./largeValues"; +import { largeValues } from "./largeValues"; import { pairs } from "./pairs/extension"; -import { searchMatchCount } from "./searchMatchCount"; -import { text } from "./text/extension"; import { timeline } from "./timeline/extension"; import type { TwigCompletionOption } from "./twig/completion"; import { twig } from "./twig/extension"; import { pathParametersPlugin } from "./twig/pathParameters"; import { url } from "./url/extension"; -export const syntaxHighlightStyle = HighlightStyle.define([ - { - tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment], - color: "var(--textSubtlest)", - }, - { - tag: [t.emphasis], - textDecoration: "underline", - }, - { - tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation], - color: "var(--textSubtle)", - }, - { - tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number], - color: "var(--info)", - }, - { tag: [t.variableName], color: "var(--success)" }, - { tag: [t.bool], color: "var(--warning)" }, - { tag: [t.attributeName, t.propertyName], color: "var(--primary)" }, - { tag: [t.attributeValue], color: "var(--warning)" }, - { tag: [t.string], color: "var(--notice)" }, - { tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: "var(--danger)" }, -]); - -const syntaxTheme = EditorView.theme({}, { dark: true }); - -const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])]; - -const legacyLang = (mode: Parameters[0]) => { - return () => new LanguageSupport(StreamLanguage.define(mode)); -}; - -const syntaxExtensions: Record< - NonNullable, - null | (() => LanguageSupport) -> = { - graphql: null, - json: jsonc, - javascript: javascript, - // HTML as XML because HTML is oddly slow - html: xml, - xml: xml, - url: url, - pairs: pairs, - text: text, - timeline: timeline, - markdown: markdown, - c: legacyLang(c), - clojure: legacyLang(clojure), - csharp: legacyLang(csharp), - go: go, - http: legacyLang(http), - java: java, - kotlin: legacyLang(kotlin), - objective_c: legacyLang(objectiveC), - ocaml: legacyLang(oCaml), - php: php, - powershell: legacyLang(powerShell), - python: python, - r: legacyLang(r), - ruby: legacyLang(ruby), - shell: legacyLang(shell), - swift: legacyLang(swift), -}; - -const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"]; +const languages: LanguageSupports = { ...syntaxExtensions, url, pairs, timeline }; export function getLanguageExtension({ useTemplating, @@ -157,18 +46,7 @@ export function getLanguageExtension({ onClickPathParameter: (name: string) => void; completionOptions: TwigCompletionOption[]; graphQLSchema: GraphQLSchema | null; -} & Pick) { - const extraExtensions: Extension[] = []; - - if (language === "url") { - extraExtensions.push(pathParametersPlugin(onClickPathParameter)); - } - - // Only close brackets on languages that need it - if (language && closeBracketsFor.includes(language)) { - extraExtensions.push(closeBracketsExtensions); - } - +} & Pick): Extension { // GraphQL is a special exception if (language === "graphql") { return [ @@ -189,155 +67,31 @@ export function getLanguageExtension({ })); }, }), - extraExtensions, + [closeBracketsExtensions], ]; } - if (language === "json") { - extraExtensions.push(lintExtension ?? linter(jsonParseLinter())); - extraExtensions.push( - jsoncLanguage.data.of({ - commentTokens: { line: "//", block: { open: "/*", close: "*/" } }, - }), - ); - if (!hideGutter) { - extraExtensions.push(lintGutter()); - } - } - - const maybeBase = language ? syntaxExtensions[language] : null; - const base = typeof maybeBase === "function" ? maybeBase() : null; - if (base == null) { - return []; - } - - if (!useTemplating) { - return [base, extraExtensions]; - } - - return twig({ - base, - environmentVariables, - completionOptions, - autocomplete, - onClickVariable, - onClickMissingVariable, - onClickPathParameter, - extraExtensions, + return getBaseLanguageExtension({ + language, + hideGutter, + languages, + lintExtension: language === "json" ? (lintExtension ?? linter(jsonParseLinter())) : undefined, + extraExtensions: language === "url" ? [pathParametersPlugin(onClickPathParameter)] : [], + wrap: useTemplating + ? (base, extraExtensions) => + twig({ + base, + environmentVariables, + completionOptions, + autocomplete, + onClickVariable, + onClickMissingVariable, + onClickPathParameter, + extraExtensions, + }) + : undefined, }); } -// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys. -// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings. -const filteredCompletionKeymap = completionKeymap.filter((binding) => { - const key = binding.key?.toLowerCase() ?? ""; - const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? ""; - // Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i) - const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`"); - return !isStartTrigger; -}); - -export const baseExtensions = [ - highlightSpecialChars(), - history(), - dropCursor(), - drawSelection(), - autocompletion({ - tooltipClass: () => "x-theme-menu", - closeOnBlur: true, // Set to `false` for debugging in devtools without closing it - defaultKeymap: false, // We handle the trigger via configurable hotkeys - compareCompletions: (a, b) => { - // Don't sort completions at all, only on boost - return (a.boost ?? 0) - (b.boost ?? 0); - }, - }), - syntaxHighlighting(syntaxHighlightStyle), - syntaxTheme, - keymap.of([...historyKeymap, ...filteredCompletionKeymap]), -]; - -export const readonlyExtensions = [ - EditorState.readOnly.of(true), - EditorView.contentAttributes.of({ tabindex: "-1" }), - // Nobody is editing this, so every rule applies - largeValues, -]; - -/** - * The counterpart for a document being edited. - * - * Only encoded media collapses here: it is pasted rather than typed, and replaced rather than - * edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that - * hide text by length alone stay out. See {@link mediaValues}. - */ -export const editableExtensions = [mediaValues]; - -export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [ - search({ top: true }), - searchMatchCount(), - hideGutter - ? [] - : [ - lineNumbers(), - foldGutter({ - markerDOM: (open) => { - const el = document.createElement("div"); - el.classList.add("fold-gutter-icon"); - el.tabIndex = -1; - if (open) { - el.setAttribute("data-open", ""); - } - return el; - }, - }), - ], - codeFolding({ - placeholderDOM(_view, onclick, prepared) { - const el = document.createElement("span"); - el.onclick = onclick; - el.className = "cm-foldPlaceholder"; - el.innerText = prepared || "…"; - el.title = "unfold"; - el.ariaLabel = "folded code"; - return el; - }, - /** - * Show the number of items when code folded. NOTE: this doesn't get called when restoring - * a previous serialized editor state, which is a bummer - */ - preparePlaceholder(state, range) { - let count: number | undefined; - let startToken = "{"; - let endToken = "}"; - - const prevLine = state.doc.lineAt(range.from).text; - const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{"); - - if (isArray) { - startToken = "["; - endToken = "]"; - } - - const internal = state.sliceDoc(range.from, range.to); - const toParse = startToken + internal + endToken; - - try { - const parsed = JSON.parse(toParse); - count = Object.keys(parsed).length; - } catch { - /* empty */ - } - - if (count !== undefined) { - const label = isArray ? "item" : "key"; - return pluralizeCount(label, count); - } - }, - }), - indentOnInput(), - rectangularSelection(), - crosshairCursor(), - bracketMatching(), - highlightActiveLineGutter(), - keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]), -]; +// Nobody is editing this, so every rule applies +export const readonlyExtensions = [...baseReadonlyExtensions, largeValues]; diff --git a/apps/yaak-client/components/core/Editor/json-lint.ts b/apps/yaak-client/components/core/Editor/json-lint.ts index 004c12c09..3b0d91f79 100644 --- a/apps/yaak-client/components/core/Editor/json-lint.ts +++ b/apps/yaak-client/components/core/Editor/json-lint.ts @@ -1,59 +1,15 @@ -import type { Diagnostic } from "@codemirror/lint"; -import type { EditorView } from "@codemirror/view"; -import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"; +import { + type JsonLintOptions, + jsonParseLinter as baseJsonParseLinter, +} from "@yaakapp-internal/ui/editor"; import { replaceTemplateTags } from "../../../lib/templateTags"; -// jsonc-parser reports error codes, so these are the words the editor shows for them -const MESSAGES: Record = { - InvalidSymbol: "Invalid symbol", - InvalidNumberFormat: "Invalid number format", - PropertyNameExpected: "Property name expected", - ValueExpected: "Value expected", - ColonExpected: "Colon expected", - CommaExpected: "Comma expected", - CloseBraceExpected: "Closing brace expected", - CloseBracketExpected: "Closing bracket expected", - EndOfFileExpected: "End of file expected", - InvalidCommentToken: "Comments are not allowed", - UnexpectedEndOfComment: "Unexpected end of comment", - UnexpectedEndOfString: "Unexpected end of string", - UnexpectedEndOfNumber: "Unexpected end of number", - InvalidUnicode: "Invalid unicode sequence", - InvalidEscapeCharacter: "Invalid escape character", - InvalidCharacter: "Invalid character", -}; - -interface JsonLintOptions { - allowComments?: boolean; - allowTrailingCommas?: boolean; +export function jsonParseLinter(options?: Omit) { + return baseJsonParseLinter({ ...options, escape: escapeTemplateTags }); } -export function jsonParseLinter(options?: JsonLintOptions) { - return (view: EditorView): Diagnostic[] => { - const doc = view.state.doc.toString(); - if (doc.trim() === "") return []; - - // We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template - // syntax with repeating `1` characters, so it's valid JSON and the position is still correct. - const escapedDoc = replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start)); - - const errors: ParseError[] = []; - parse(escapedDoc, errors, { - allowTrailingComma: options?.allowTrailingCommas ?? false, - disallowComments: !(options?.allowComments ?? true), - }); - - // Later errors are mostly consequences of the first one, so only that one is shown - const error = errors[0]; - if (error == null) return []; - - return [ - { - from: error.offset, - to: error.offset + error.length, - severity: "error", - message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON", - }, - ]; - }; +// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template +// syntax with repeating `1` characters, so it's valid JSON and the position is still correct. +function escapeTemplateTags(doc: string): string { + return replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start)); } diff --git a/apps/yaak-client/components/core/Editor/largeValues.test.ts b/apps/yaak-client/components/core/Editor/largeValues.test.ts index 6df9f0aa6..abd1b6ba5 100644 --- a/apps/yaak-client/components/core/Editor/largeValues.test.ts +++ b/apps/yaak-client/components/core/Editor/largeValues.test.ts @@ -1,6 +1,5 @@ import { EditorState } from "@codemirror/state"; import { jsonc } from "@shopify/lang-jsonc"; -import { text } from "./text/extension"; import { twig } from "./twig/extension"; import { describe, expect, test } from "vite-plus/test"; import { @@ -11,6 +10,7 @@ import { MAX_VISIBLE_LINE_CHARS, } from "./largeValues"; import type { SniffedValue } from "./sniffValue"; +import { text } from "@yaakapp-internal/ui/editor"; const BIG = "A".repeat(1_000_000); diff --git a/apps/yaak-client/components/core/Editor/renderEditor.tsx b/apps/yaak-client/components/core/Editor/renderEditor.tsx new file mode 100644 index 000000000..4512115a5 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/renderEditor.tsx @@ -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 ; +} diff --git a/apps/yaak-client/components/core/Editor/twig/completion.test.ts b/apps/yaak-client/components/core/Editor/twig/completion.test.ts index ff8fd9476..c02cc65d7 100644 --- a/apps/yaak-client/components/core/Editor/twig/completion.test.ts +++ b/apps/yaak-client/components/core/Editor/twig/completion.test.ts @@ -4,9 +4,9 @@ import { json } from "@codemirror/lang-json"; import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { describe, expect, test } from "vite-plus/test"; -import { singleLineExtensions } from "../singleLine"; import { twigCompletion, type TwigCompletionOption } from "./completion"; import { parser } from "./twig"; +import { singleLineExtensions } from "@yaakapp-internal/ui/editor"; const options: TwigCompletionOption[] = [ { name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} }, diff --git a/apps/yaak-client/components/core/Editor/twig/extension.ts b/apps/yaak-client/components/core/Editor/twig/extension.ts index bbd4c8cfc..54d72a42d 100644 --- a/apps/yaak-client/components/core/Editor/twig/extension.ts +++ b/apps/yaak-client/components/core/Editor/twig/extension.ts @@ -5,11 +5,11 @@ import { parseMixed } from "@lezer/common"; import type { WrappedEnvironmentVariable } from "../../../../hooks/useEnvironmentVariables"; import type { GenericCompletionConfig } from "../genericCompletion"; import { genericCompletion } from "../genericCompletion"; -import { textLanguage } from "../text/extension"; import type { TwigCompletionOption } from "./completion"; import { twigCompletion } from "./completion"; import { templateTagsPlugin } from "./templateTags"; import { parser as twigParser } from "./twig"; +import { textLanguage } from "@yaakapp-internal/ui/editor"; export function twig({ base, diff --git a/apps/yaak-client/components/core/HttpMethodTag.tsx b/apps/yaak-client/components/core/HttpMethodTag.tsx index 0184c2bfb..f2782d888 100644 --- a/apps/yaak-client/components/core/HttpMethodTag.tsx +++ b/apps/yaak-client/components/core/HttpMethodTag.tsx @@ -1,91 +1,22 @@ -import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models"; -import classNames from "classnames"; +import { + HttpMethodTag as BaseHttpMethodTag, + HttpMethodTagRaw as BaseHttpMethodTagRaw, +} from "@yaakapp-internal/ui"; import { useAtomValue } from "jotai"; -import { memo } from "react"; +import { type ComponentProps, memo } from "react"; -interface Props { - request: HttpRequest | GrpcRequest | WebsocketRequest; - className?: string; - short?: boolean; - noAlias?: boolean; -} - -const methodNames: Record = { - 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 ; +export const HttpMethodTag = memo(function HttpMethodTag( + props: Omit, "colored">, +) { + const settings = useAtomValue(settingsAtom); + return ; }); export function HttpMethodTagRaw({ - className, - method, - short, forceColor, -}: { - method: string; - className?: string; - short?: boolean; - forceColor?: boolean; -}) { - let label = method.toUpperCase(); - if (short) { - label = methodNames[method.toLowerCase()] ?? method.slice(0, 4); - label = label.padEnd(4, " "); - } - - const m = method.toUpperCase(); - + ...props +}: Omit, "colored"> & { forceColor?: boolean }) { const settings = useAtomValue(settingsAtom); - const colored = forceColor || settings.coloredMethods; - - return ( - - {label} - - ); + return ; } diff --git a/apps/yaak-client/components/core/Input.tsx b/apps/yaak-client/components/core/Input.tsx index c00632748..e1f8cd343 100644 --- a/apps/yaak-client/components/core/Input.tsx +++ b/apps/yaak-client/components/core/Input.tsx @@ -1,19 +1,24 @@ -import type { EditorView } from "@codemirror/view"; -import type { Color } from "@yaakapp-internal/plugins"; -import { HStack, Icon, type IconProps } from "@yaakapp-internal/ui"; +import { + HStack, + Icon, + type IconProps, + IconTooltip, + Input as BaseUiInput, + type InputHandle, + type InputProps as BaseUiInputProps, + useStateWithDeps, +} from "@yaakapp-internal/ui"; +import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; import classNames from "classnames"; -import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { createFastMutation } from "../../hooks/useFastMutation"; import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled"; -import { useStateWithDeps } from "../../hooks/useStateWithDeps"; import { copyToClipboard } from "../../lib/copy"; import { analyzeTemplate, convertTemplateToInsecure, convertTemplateToSecure, } from "../../lib/encryption"; -import { generateId } from "../../lib/generateId"; import { setupOrConfigureEncryption, withEncryptionEnabled, @@ -25,69 +30,30 @@ import type { EditorProps } from "./Editor/Editor"; import type { GenericCompletion } from "./Editor/genericCompletion"; import { Editor } from "./Editor/LazyEditor"; import { IconButton } from "./IconButton"; -import { IconTooltip } from "./IconTooltip"; -import { Label } from "./Label"; -export type InputProps = Pick< - EditorProps, - | "language" - | "autocomplete" - | "forcedEnvironmentId" - | "forceUpdateKey" - | "disabled" - | "autoFocus" - | "autoSelect" - | "autocompleteVariables" - | "autocompleteFunctions" - | "onKeyDown" - | "readOnly" -> & { - className?: string; - containerClassName?: string; - inputWrapperClassName?: string; - defaultValue?: string | null; - disableObscureToggle?: boolean; - fullHeight?: boolean; - hideLabel?: boolean; - help?: ReactNode; - label: ReactNode; - labelClassName?: string; - labelPosition?: "top" | "left"; - leftSlot?: ReactNode; - multiLine?: boolean; - name?: string; - onBlur?: () => void; - onChange?: (value: string) => void; - onFocus?: () => void; - onPaste?: (value: string) => void; - onPasteOverwrite?: EditorProps["onPasteOverwrite"]; - placeholder?: string; - required?: boolean; - rightSlot?: ReactNode; - /** - * Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked - * without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without - * wanting the affordance (the URL bar completes from history, and a chevron there would be - * noise). Renders nothing when there are no options to show. - */ - showOptionsPicker?: boolean; - size?: "2xs" | "xs" | "sm" | "md" | "auto"; - stateKey: EditorProps["stateKey"]; - extraExtensions?: EditorProps["extraExtensions"]; - tint?: Color; - type?: "text" | "password"; - validate?: boolean | ((v: string) => boolean); - wrapLines?: boolean; - setRef?: (h: InputHandle | null) => void; -}; +export type { InputHandle }; -export interface InputHandle { - focus: () => void; - isFocused: () => boolean; - value: () => string; - selectAll: () => void; - dispatch: EditorView["dispatch"]; -} +export type InputProps = Omit< + BaseUiInputProps, + "language" | "extensions" | "renderEditor" | "renderOptionsPicker" +> & + Pick< + EditorProps, + | "language" + | "autocomplete" + | "forcedEnvironmentId" + | "autocompleteVariables" + | "autocompleteFunctions" + | "extraExtensions" + > & { + /** + * Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked + * without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without + * wanting the affordance (the URL bar completes from history, and a chevron there would be + * noise). Renders nothing when there are no options to show. + */ + showOptionsPicker?: boolean; + }; export function Input({ type, ...props }: InputProps) { // If it's a password and template functions are supported (ie. secure(...)) then @@ -99,283 +65,47 @@ export function Input({ type, ...props }: InputProps) { } function BaseInput({ - className, - containerClassName, - defaultValue, - disableObscureToggle, - disabled, - forceUpdateKey, - fullHeight, - help, - hideLabel, - inputWrapperClassName, - label, - labelClassName, - labelPosition = "top", - leftSlot, - multiLine, - onBlur, - onChange, - onFocus, - onPaste, - onPasteOverwrite, - placeholder, - readOnly, - required, - rightSlot, + autocomplete, + autocompleteFunctions, + autocompleteVariables, + extraExtensions, + forcedEnvironmentId, + language, showOptionsPicker, - size = "md", - stateKey, - tint, - type = "text", - validate, - wrapLines, - setRef, ...props }: InputProps) { - const [focused, setFocused] = useState(false); - const [obscured, setObscured] = useStateWithDeps(type === "password", [type]); - const [hasChanged, setHasChanged] = useStateWithDeps(false, [forceUpdateKey]); - const editorRef = useRef(null); - const skipNextFocus = useRef(false); - - const handle = useMemo( - () => ({ - focus: () => { - if (editorRef.current == null) return; - const anchor = editorRef.current.state.doc.length; - skipNextFocus.current = true; - editorRef.current.focus(); - editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true }); - }, - isFocused: () => editorRef.current?.hasFocus ?? false, - value: () => editorRef.current?.state.doc.toString() ?? "", - dispatch: (...args) => { - // oxlint-disable-next-line no-explicit-any - editorRef.current?.dispatch(...(args as any)); - }, - selectAll() { - if (editorRef.current == null) return; - editorRef.current.focus(); - editorRef.current.dispatch({ - selection: { anchor: 0, head: editorRef.current.state.doc.length }, - }); - }, - }), - [], + const renderEditor = useCallback( + (editorProps: CodeEditorProps) => ( + + ), + [ + language, + autocomplete, + autocompleteFunctions, + autocompleteVariables, + extraExtensions, + forcedEnvironmentId, + ], ); - // Replace the whole value with a picked option. Dispatching runs it through the editor's - // onChange, same as typing, so callers see one consistent change path. - const insertOption = useCallback((value: string) => { - const view = editorRef.current; - if (view == null) return; - view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }); - view.focus(); - }, []); - - const pickerOptions = showOptionsPicker && !disabled ? (props.autocomplete?.options ?? []) : []; - - const setEditorRef = useCallback( - (h: EditorView | null) => { - editorRef.current = h; - setRef?.(handle); - }, - [handle, setRef], - ); - - useEffect(() => { - const fn = () => { - skipNextFocus.current = true; - }; - window.addEventListener("focus", fn); - return () => { - window.removeEventListener("focus", fn); - }; - }, []); - - const handleFocus = useCallback(() => { - if (readOnly) return; - - if (!skipNextFocus.current) { - editorRef.current?.dispatch({ - selection: { anchor: 0, head: editorRef.current.state.doc.length }, - }); - } - - setFocused(true); - onFocus?.(); - skipNextFocus.current = false; - }, [onFocus, readOnly]); - - const handleBlur = useCallback(async () => { - setFocused(false); - // Move selection to the end on blur - const anchor = editorRef.current?.state.doc.length ?? 0; - editorRef.current?.dispatch({ - selection: { anchor, head: anchor }, - }); - onBlur?.(); - }, [onBlur]); - - const id = useRef(`input-${generateId()}`); - const editorClassName = classNames( - className, - "bg-transparent! min-w-0 h-auto w-full focus:outline-hidden placeholder:text-placeholder", - ); - - const isValid = useMemo(() => { - if (required && !validateRequire(defaultValue ?? "")) return false; - if (typeof validate === "boolean") return validate; - if (typeof validate === "function" && !validate(defaultValue ?? "")) return false; - return true; - }, [required, defaultValue, validate]); - - const handleChange = useCallback( - (value: string) => { - onChange?.(value); - setHasChanged(true); - }, - [onChange, setHasChanged], - ); - - const wrapperRef = useRef(null); - - // Submit the nearest form on Enter key press - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key !== "Enter") return; - - const form = wrapperRef.current?.closest("form"); - if (!isValid || form == null) return; - - form?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true })); - }, - [isValid], - ); + const pickerOptions = showOptionsPicker && !props.disabled ? (autocomplete?.options ?? []) : []; + const renderOptionsPicker = + pickerOptions.length > 0 + ? (insert: (value: string) => void) => ( + + ) + : undefined; return ( -
- - - {tint != null && ( -
- )} - {leftSlot} - - - - {type === "password" && !disableObscureToggle && ( - setObscured((o) => !o)} - /> - )} - {pickerOptions.length > 0 && ( - - )} - {rightSlot} - -
+ ); } @@ -419,10 +149,6 @@ function OptionsPickerDropdown({ ); } -function validateRequire(v: string) { - return v.length > 0; -} - type PasswordFieldType = "text" | "encrypted"; function EncryptionInput({ diff --git a/apps/yaak-client/components/core/KeyValueRow.tsx b/apps/yaak-client/components/core/KeyValueRow.tsx index 75847fcd9..bc9522c91 100644 --- a/apps/yaak-client/components/core/KeyValueRow.tsx +++ b/apps/yaak-client/components/core/KeyValueRow.tsx @@ -1,106 +1,10 @@ -import classNames from "classnames"; -import type { HTMLAttributes, ReactElement, ReactNode } from "react"; -import { CopyIconButton } from "../CopyIconButton"; +import { KeyValueRow as BaseKeyValueRow, type KeyValueRowProps } from "@yaakapp-internal/ui"; +import { copyToClipboard } from "../../lib/copy"; -interface Props { - children: - | ReactElement> - | (ReactElement> | null)[]; - selectable?: boolean; +export function KeyValueRow(props: Omit) { + return ; } -export function KeyValueRows({ children, selectable }: Props) { - const childArray = Array.isArray(children) ? children.filter(Boolean) : [children]; - return ( - - - {childArray.map((child, i) => ( - // oxlint-disable-next-line react/no-array-index-key - {child} - ))} - -
- ); -} - -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 ? ( - - ) : null); - - return ( - <> - - {label} - - -
- {leftSlot ?? } - {children} - {resolvedRightSlot ? ( -
{resolvedRightSlot}
- ) : ( - - )} -
- - - ); +function copy(text: string) { + copyToClipboard(text, { disableToast: true }); } diff --git a/apps/yaak-client/components/core/PairEditor.tsx b/apps/yaak-client/components/core/PairEditor.tsx index 58a6e7462..e3dbe7f86 100644 --- a/apps/yaak-client/components/core/PairEditor.tsx +++ b/apps/yaak-client/components/core/PairEditor.tsx @@ -14,9 +14,8 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables"; import { useRandomKey } from "../../hooks/useRandomKey"; import { useToggle } from "../../hooks/useToggle"; -import { languageFromContentType } from "../../lib/contentType"; import { showDialog } from "../../lib/dialog"; -import { computeSideForDragMove, DropMarker } from "@yaakapp-internal/ui"; +import { computeSideForDragMove, DropMarker, languageFromContentType } from "@yaakapp-internal/ui"; import { showPrompt } from "../../lib/prompt"; import { SelectFile } from "../SelectFile"; import { Button } from "./Button"; diff --git a/apps/yaak-client/components/core/PairEditor.util.tsx b/apps/yaak-client/components/core/PairEditor.util.tsx index 27feb01ea..b43428292 100644 --- a/apps/yaak-client/components/core/PairEditor.util.tsx +++ b/apps/yaak-client/components/core/PairEditor.util.tsx @@ -1,5 +1,5 @@ -import { generateId } from "../../lib/generateId"; import type { Pair, PairWithId } from "./PairEditor"; +import { generateId } from "@yaakapp-internal/lib/generateId"; // NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable) export function ensurePairId(p: T): T & PairWithId { diff --git a/apps/yaak-client/components/core/PlainInput.tsx b/apps/yaak-client/components/core/PlainInput.tsx index c38672e81..473ab5663 100644 --- a/apps/yaak-client/components/core/PlainInput.tsx +++ b/apps/yaak-client/components/core/PlainInput.tsx @@ -1,4 +1,4 @@ -import { HStack } from "@yaakapp-internal/ui"; +import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react"; import { @@ -10,11 +10,9 @@ import { useState, } from "react"; import { useRandomKey } from "../../hooks/useRandomKey"; -import { useStateWithDeps } from "../../hooks/useStateWithDeps"; -import { generateId } from "../../lib/generateId"; import { IconButton } from "./IconButton"; import type { InputProps } from "./Input"; -import { Label } from "./Label"; +import { generateId } from "@yaakapp-internal/lib/generateId"; export type PlainInputProps = Omit< InputProps, diff --git a/apps/yaak-client/components/core/Prompt.tsx b/apps/yaak-client/components/core/Prompt.tsx index e507e2f29..dacf1d308 100644 --- a/apps/yaak-client/components/core/Prompt.tsx +++ b/apps/yaak-client/components/core/Prompt.tsx @@ -1,9 +1,9 @@ import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins"; import type { FormEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { generateId } from "../../lib/generateId"; import { DynamicForm } from "../DynamicForm"; import { DialogFooter } from "./Dialog"; +import { generateId } from "@yaakapp-internal/lib/generateId"; export interface PromptProps { inputs: FormInput[]; diff --git a/apps/yaak-client/components/core/SegmentedControl.tsx b/apps/yaak-client/components/core/SegmentedControl.tsx index aa9aca85e..b2ab147f5 100644 --- a/apps/yaak-client/components/core/SegmentedControl.tsx +++ b/apps/yaak-client/components/core/SegmentedControl.tsx @@ -1,13 +1,10 @@ import type { IconProps } from "@yaakapp-internal/ui"; -import { HStack } from "@yaakapp-internal/ui"; +import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { type ReactNode, useRef } from "react"; -import { useStateWithDeps } from "../../hooks/useStateWithDeps"; -import { generateId } from "../../lib/generateId"; import { Button } from "./Button"; import { IconButton, type IconButtonProps } from "./IconButton"; -import { IconTooltip } from "./IconTooltip"; -import { Label } from "./Label"; +import { generateId } from "@yaakapp-internal/lib/generateId"; interface Props { options: { value: T; label: string; icon?: IconProps["icon"] }[]; diff --git a/apps/yaak-client/components/core/Select.tsx b/apps/yaak-client/components/core/Select.tsx index 083a3097e..6abf68c53 100644 --- a/apps/yaak-client/components/core/Select.tsx +++ b/apps/yaak-client/components/core/Select.tsx @@ -1,11 +1,10 @@ import { platform } from "@yaakapp-internal/platform"; -import { HStack } from "@yaakapp-internal/ui"; +import { HStack, Label } from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { CSSProperties, ReactNode } from "react"; import { useState } from "react"; import type { ButtonProps } from "./Button"; import { Button } from "./Button"; -import { Label } from "./Label"; import type { RadioDropdownItem } from "./RadioDropdown"; import { RadioDropdown } from "./RadioDropdown"; diff --git a/apps/yaak-client/components/core/Tabs/Tabs.tsx b/apps/yaak-client/components/core/Tabs/Tabs.tsx index 2ff69ec70..e023210a0 100644 --- a/apps/yaak-client/components/core/Tabs/Tabs.tsx +++ b/apps/yaak-client/components/core/Tabs/Tabs.tsx @@ -1,44 +1,27 @@ -import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core"; import { - closestCenter, - DndContext, - DragOverlay, - PointerSensor, - useDraggable, - useDroppable, - useSensor, - useSensors, -} from "@dnd-kit/core"; + Icon, + type StoredValue, + TabContent as BaseTabContent, + type TabContentProps, + type TabItem as BaseTabItem, + Tabs as BaseTabs, + type TabsProps as BaseTabsProps, + type TabsRef, + type TabsStorage, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; -import type { ReactNode, Ref } from "react"; -import { - forwardRef, - memo, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from "react"; +import type { ReactNode } from "react"; +import { forwardRef, memo, useMemo } from "react"; import { useKeyValue } from "../../../hooks/useKeyValue"; -import { computeSideForDragMove, DropMarker } from "@yaakapp-internal/ui"; -import { fireAndForget } from "../../../lib/fireAndForget"; import { ErrorBoundary } from "../../ErrorBoundary"; -import type { ButtonProps } from "../Button"; import { Button } from "../Button"; -import { Icon } from "@yaakapp-internal/ui"; import type { RadioDropdownProps } from "../RadioDropdown"; import { RadioDropdown } from "../RadioDropdown"; +export type { TabsRef }; + export type TabItem = - | { - value: string; - label: string; - hidden?: boolean; - leftSlot?: ReactNode; - rightSlot?: ReactNode; - } + | Extract | { value: string; options: Omit; @@ -46,445 +29,34 @@ export type TabItem = rightSlot?: ReactNode; }; -interface TabsStorage { - order: string[]; - activeTabs: Record; -} +type Props = Omit & { tabs: TabItem[] }; -export interface TabsRef { - /** Programmatically set the active tab */ - setActiveTab: (value: string) => void; -} - -interface Props { - label: string; - /** Default tab value. If not provided, defaults to first tab. */ - defaultValue?: string; - /** Called when active tab changes */ - onChangeValue?: (value: string) => void; - tabs: TabItem[]; - tabListClassName?: string; - className?: string; - children: ReactNode; - addBorders?: boolean; - layout?: "horizontal" | "vertical"; - /** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */ - storageKey?: string | string[]; - /** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */ - activeTabKey?: string; -} - -export const Tabs = forwardRef(function Tabs( - { - defaultValue, - onChangeValue: onChangeValueProp, - label, - children, - tabs: originalTabs, - className, - tabListClassName, - addBorders, - layout = "vertical", - storageKey, - activeTabKey, - }: Props, - forwardedRef: Ref, -) { - const ref = useRef(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({ - 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(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(originalTabs); - const [isDragging, setIsDragging] = useState(null); - const [hoveredIndex, setHoveredIndex] = useState(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(); - - // 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("[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( -
- -
, - ); - } - - items.push( - , - ); - }); - return items; - }, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]); - - const tabList = ( -
-
- {tabButtons} - {hoveredIndex === tabs.length && ( -
- -
- )} -
-
- ); - - return ( -
- {reorderable ? ( - - {tabList} - - {isDragging && ( - - )} - - - ) : ( - tabList - )} - {children} -
- ); +export const Tabs = forwardRef(function Tabs({ tabs, ...props }, ref) { + const baseTabs = useMemo(() => tabs.map(toBaseTab), [tabs]); + return ; }); -interface TabButtonProps { - tab: TabItem; - isActive: boolean; - addBorders?: boolean; - layout: "horizontal" | "vertical"; - reorderable: boolean; - isDragging: boolean; - onChangeValue?: (value: string) => void; - overlay?: boolean; -} +export const TabContent = memo(function TabContent(props: Omit) { + return ; +}); -function TabButton({ - tab, - isActive, - addBorders, - layout, - reorderable, - isDragging, - onChangeValue, - overlay = false, -}: TabButtonProps) { - const { - attributes, - listeners, - setNodeRef: setDraggableRef, - } = useDraggable({ - id: tab.value, - disabled: !reorderable, - // The button inside handles focus - attributes: { tabIndex: -1 }, - }); - const { setNodeRef: setDroppableRef } = useDroppable({ - id: tab.value, - disabled: !reorderable, - }); - - const handleSetWrapperRef = useCallback( - (n: HTMLDivElement | null) => { - if (reorderable) { - setDraggableRef(n); - setDroppableRef(n); - } - }, - [reorderable, setDraggableRef, setDroppableRef], - ); - - const btnProps: Partial = { - color: "custom", - justify: layout === "horizontal" ? "start" : "center", - onClick: isActive - ? undefined - : (e: React.MouseEvent) => { - e.preventDefault(); // Prevent dropdown from opening on first click - onChangeValue?.(tab.value); - }, - className: classNames( - "flex items-center rounded-sm whitespace-nowrap", - "px-2! ml-px", - "outline-hidden", - "ring-none", - "focus-visible-or-class:outline-2", - addBorders && "border focus-visible:bg-surface-highlight", - isActive ? "text-text" : "text-text-subtle", - isActive && addBorders - ? "border-surface-active bg-surface-active" - : layout === "vertical" - ? "border-border-subtle" - : "border-transparent", - layout === "horizontal" && "min-w-40", - isDragging && "opacity-50", - overlay && "opacity-80", - ), - }; - - const buttonContent = (() => { - if ("options" in tab) { - const option = tab.options.items.find((i) => "value" in i && i.value === tab.options.value); +function toBaseTab(tab: TabItem): BaseTabItem { + if (!("options" in tab)) return tab; + const { options } = tab; + return { + value: tab.value, + leftSlot: tab.leftSlot, + rightSlot: tab.rightSlot, + renderButton: ({ buttonProps, isActive }) => { + const option = options.items.find((i) => "value" in i && i.value === options.value); return (
} - {...btnProps} + {...buttonProps} > {option && "shortLabel" in option && option.shortLabel ? option.shortLabel @@ -509,52 +81,19 @@ function TabButton({ ); - } - return ( - - ); - })(); - - // Apply drag handlers to wrapper, not button - const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {}; - - return ( -
- {buttonContent} -
- ); + }, + }; } -interface TabContentProps { - value: string; - children: ReactNode; - className?: string; +function useTabsStoredValue(key: string | string[] | null, fallback: T) { + const { value, set } = useKeyValue({ + namespace: "no_sync", + key: key ?? ["tabs", "default"], + fallback, + }); + return [value ?? fallback, set] as const; } -export const TabContent = memo(function TabContent({ - value, - children, - className, -}: TabContentProps) { - return ( - -
- {children} -
-
- ); -}); - /** * 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). diff --git a/apps/yaak-client/components/graphql/GraphQLDocsExplorer.tsx b/apps/yaak-client/components/graphql/GraphQLDocsExplorer.tsx index f89bbc318..d04297b77 100644 --- a/apps/yaak-client/components/graphql/GraphQLDocsExplorer.tsx +++ b/apps/yaak-client/components/graphql/GraphQLDocsExplorer.tsx @@ -24,11 +24,9 @@ import { useAtomValue } from "jotai"; import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from "react"; import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useClickOutside } from "../../hooks/useClickOutside"; -import { Banner, useContainerSize } from "@yaakapp-internal/ui"; +import { Banner, useContainerSize, useStateWithDeps, CountBadge } from "@yaakapp-internal/ui"; import { Icon, useDebouncedValue } from "@yaakapp-internal/ui"; -import { useStateWithDeps } from "../../hooks/useStateWithDeps"; import { jotaiStore } from "../../lib/jotai"; -import { CountBadge } from "../core/CountBadge"; import { IconButton } from "../core/IconButton"; import { PlainInput } from "../core/PlainInput"; import { Markdown } from "../Markdown"; diff --git a/apps/yaak-client/components/graphql/GraphQLEditor.tsx b/apps/yaak-client/components/graphql/GraphQLEditor.tsx index a7adabd08..586f98bd3 100644 --- a/apps/yaak-client/components/graphql/GraphQLEditor.tsx +++ b/apps/yaak-client/components/graphql/GraphQLEditor.tsx @@ -5,7 +5,6 @@ import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo } from "react"; import { useLocalStorage } from "react-use"; import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL"; -import { useStateWithDeps } from "../../hooks/useStateWithDeps"; import { showDialog } from "../../lib/dialog"; import { Button } from "../core/Button"; import type { DropdownItem } from "../core/Dropdown"; @@ -15,7 +14,7 @@ import { Editor } from "../core/Editor/LazyEditor"; import { IconButton } from "../core/IconButton"; import type { RadioDropdownItem } from "../core/RadioDropdown"; import { RadioDropdown } from "../core/RadioDropdown"; -import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui"; +import { Banner, FormattedError, Icon, useStateWithDeps } from "@yaakapp-internal/ui"; import { Separator } from "../core/Separator"; import { tryFormatGraphql } from "../../lib/formatters"; import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames"; diff --git a/apps/yaak-client/components/responseViewers/EventStreamViewer.tsx b/apps/yaak-client/components/responseViewers/EventStreamViewer.tsx index bd563a645..4a7f4063f 100644 --- a/apps/yaak-client/components/responseViewers/EventStreamViewer.tsx +++ b/apps/yaak-client/components/responseViewers/EventStreamViewer.tsx @@ -1,6 +1,6 @@ import type { HttpResponse } from "@yaakapp-internal/models"; import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse"; -import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui"; +import { HStack, Icon, InlineCode, SplitLayout, VStack, IconTooltip } from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { CSSProperties, ReactNode } from "react"; import { Fragment, useMemo, useState } from "react"; @@ -23,7 +23,6 @@ import { Editor } from "../core/Editor/LazyEditor"; import { EventDetailHeader, EventViewer } from "../core/EventViewer"; import { EventViewerRow } from "../core/EventViewerRow"; import { IconButton } from "../core/IconButton"; -import { IconTooltip } from "../core/IconTooltip"; import { Input } from "../core/Input"; import { Select } from "../core/Select"; diff --git a/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx b/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx index a82d0aefa..7e1becb7c 100644 --- a/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx +++ b/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx @@ -1,17 +1,17 @@ import type { HttpResponse } from "@yaakapp-internal/models"; import { useQueryClient } from "@tanstack/react-query"; +import { languageFromContentType, type TextFilterApi, WebPageViewer } from "@yaakapp-internal/ui"; +import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; import { useCallback } from "react"; import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse"; +import { useRecentFilters } from "../../hooks/useRecentFilters"; import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText"; -import { useResponseFilter } from "../../hooks/useResponseFilter"; import { useSaveResponse } from "../../hooks/useSaveResponse"; -import { languageFromContentType } from "../../lib/contentType"; import { getContentTypeFromHeaders } from "../../lib/model_util"; -import type { EditorProps } from "../core/Editor/Editor"; import { IconButton } from "../core/IconButton"; import { EmptyStateText } from "../EmptyStateText"; +import { RecentFiltersDropdown } from "./RecentFiltersDropdown"; import { TextViewer } from "./TextViewer"; -import { WebPageViewer } from "./WebPageViewer"; interface Props { response: HttpResponse; @@ -48,22 +48,36 @@ export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Prop interface HttpTextViewerProps { response: HttpResponse; text: string; - language: EditorProps["language"]; + language: CodeEditorProps["language"]; pretty: boolean; className?: string; } function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) { const queryClient = useQueryClient(); - const filter = useResponseFilter({ - stateKey: `response.body.${response.requestId}`, - // Shares the display query's cache entry, so the verdict costs no extra RPC - runFilter: useCallback( - (f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })), - [queryClient, response], + const filterStateKey = `response.body.${response.requestId}`; + // Shares the response body query's cache, so applying and showing a filter cost one RPC + const filterText = useCallback( + (f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })), + [queryClient, response], + ); + const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } = + useRecentFilters(filterStateKey); + const filterLeftSlot = useCallback( + (filter: TextFilterApi) => ( +
+ +
), - }); - const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter }); + [recentFilters, removeFilter, togglePin, clearFilters], + ); const saveResponse = useSaveResponse(response); const copyResponse = useCopyHttpResponse(response); const actionsDisabled = response.state !== "closed" && response.status >= 100; @@ -95,12 +109,10 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex className="border !border-border-subtle" />, ]} - filter={filter} - filterResult={{ - data: filteredBody.data, - isPending: filteredBody.isPending, - error: !!filteredBody.error, - }} + filterText={filterText} + filterStateKey={filterStateKey} + onFilterApplied={addFilter} + filterLeftSlot={filterLeftSlot} /> ); } diff --git a/apps/yaak-client/components/responseViewers/MultipartViewer.tsx b/apps/yaak-client/components/responseViewers/MultipartViewer.tsx index 821b33788..83cd8b560 100644 --- a/apps/yaak-client/components/responseViewers/MultipartViewer.tsx +++ b/apps/yaak-client/components/responseViewers/MultipartViewer.tsx @@ -1,136 +1,34 @@ -import { type MultipartPart, parseMultipart } from "@mjackson/multipart-parser"; -import { lazy, Suspense, useMemo } from "react"; -import { languageFromContentType } from "../../lib/contentType"; -import { Banner, Icon, LoadingIcon } from "@yaakapp-internal/ui"; -import { TabContent, Tabs } from "../core/Tabs/Tabs"; -import { AudioViewer } from "./AudioViewer"; -import { CsvViewer } from "./CsvViewer"; -import { ImageViewer } from "./ImageViewer"; -import { SvgViewer } from "./SvgViewer"; -import { TextViewer } from "./TextViewer"; -import { VideoViewer } from "./VideoViewer"; -import { WebPageViewer } from "./WebPageViewer"; +import { + LoadingIcon, + MultipartViewer as BaseMultipartViewer, + type MultipartViewerProps, +} from "@yaakapp-internal/ui"; +import { lazy, Suspense } from "react"; +import { renderEditor } from "../core/Editor/renderEditor"; +import { ErrorBoundary } from "../ErrorBoundary"; +import { formatText, textViewerExtensions } from "./TextViewer"; const PdfViewer = lazy(() => import("./PdfViewer").then((m) => ({ default: m.PdfViewer }))); -interface Props { - data: Uint8Array; - boundary: string; - idPrefix?: string; -} - -export function MultipartViewer({ data, boundary, idPrefix = "multipart" }: Props) { - const parseResult = useMemo(() => { - try { - const maxFileSize = 1024 * 1024 * 10; // 10MB - const parsed = parseMultipart(data, { boundary, maxFileSize }); - const parts = Array.from(parsed); - return { parts, error: null }; - } catch (err) { - return { parts: [], error: err instanceof Error ? err.message : String(err) }; - } - }, [data, boundary]); - - const { parts, error } = parseResult; - - if (error) { - return ( - - Failed to parse multipart data: {error} - - ); - } - - if (parts.length === 0) { - return ( - - No multipart parts found - - ); - } +const textViewerProps = { extensions: textViewerExtensions, formatText, renderEditor }; +export function MultipartViewer( + props: Omit, +) { return ( - ({ - label: part.name ?? "", - value: tabValue(part, i), - rightSlot: - part.filename && part.headers.contentType.mediaType?.startsWith("image/") ? ( -
- -
- ) : part.filename ? ( - - ) : null, - }))} - > - {parts.map((part, i) => ( - - - - ))} -
+ ); } -function Part({ part }: { part: MultipartPart }) { - const mimeType = part.headers.contentType.mediaType ?? null; - const contentTypeHeader = part.headers.get("content-type"); - - const { uint8Array, content, detectedLanguage } = useMemo(() => { - const uint8Array = new Uint8Array(part.arrayBuffer); - const content = new TextDecoder().decode(part.arrayBuffer); - const detectedLanguage = languageFromContentType(contentTypeHeader, content); - return { uint8Array, content, detectedLanguage }; - }, [part, contentTypeHeader]); - - if (mimeType?.match(/^image\/svg/i)) { - return ; - } - - if (mimeType?.match(/^image/i)) { - return ; - } - - if (mimeType?.match(/^audio/i)) { - return ; - } - - if (mimeType?.match(/^video/i)) { - return ; - } - - if (mimeType?.match(/csv|tab-separated/i)) { - return ; - } - - if (mimeType?.match(/^text\/html/i) || detectedLanguage === "html") { - return ; - } - - if (mimeType?.match(/pdf/i)) { - return ( - }> - - - ); - } - - return ; -} - -function tabValue(part: MultipartPart, i: number) { - return `${part.name ?? ""}::${i}`; +function renderPdf(data: Uint8Array) { + return ( + }> + + + ); } diff --git a/apps/yaak-client/components/responseViewers/TextViewer.tsx b/apps/yaak-client/components/responseViewers/TextViewer.tsx index 41052be58..f6eeb9700 100644 --- a/apps/yaak-client/components/responseViewers/TextViewer.tsx +++ b/apps/yaak-client/components/responseViewers/TextViewer.tsx @@ -1,222 +1,33 @@ -import type { ReactNode } from "react"; -import { Children, useCallback, useMemo, useState } from "react"; -import { useFormatText } from "../../hooks/useFormatText"; -import type { ResponseFilterApi } from "../../hooks/useResponseFilter"; -import type { EditorProps } from "../core/Editor/Editor"; -import { jsonBreadcrumbExtension } from "../core/Editor/json/breadcrumbExtension"; -import type { JsonPathSegment } from "../core/Editor/json/jsonPath"; -import { jsonPathToSegments, segmentsToJsonPath } from "../core/Editor/json/jsonPath"; +import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; +import { TextViewer as BaseTextViewer, type TextViewerProps } from "@yaakapp-internal/ui"; +import { tryFormatJson, tryFormatXml } from "../../lib/formatters"; import { hyperlink } from "../core/Editor/hyperlink/extension"; -import { Editor } from "../core/Editor/LazyEditor"; -import { IconButton } from "../core/IconButton"; -import { Input } from "../core/Input"; -import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar"; -import { RecentFiltersDropdown } from "./RecentFiltersDropdown"; +import { renderEditor } from "../core/Editor/renderEditor"; -interface Props { - text: string; - language: EditorProps["language"]; - stateKey: string | null; - pretty?: boolean; - className?: string; - footerActions?: ReactNode; - filter?: ResponseFilterApi; - filterResult?: { - data: string | null | undefined; - isPending: boolean; - error: boolean; - }; -} - -export function TextViewer({ - language, - text, - stateKey, - pretty, - className, - footerActions, - filter, - filterResult, -}: Props) { - // Track the JSON path under the cursor to drive the breadcrumb bar. Selection - // works even in this read-only editor, so it updates as the user clicks around. - const [breadcrumbSegments, setBreadcrumbSegments] = useState([]); - 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(() => { - const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions); - - if (!canFilter) return nodes; - - if (isSearching) { - nodes.push( -
- - -
- } - rightSlot={ -
- -
- } - /> -
, - ); - } else { - nodes.push( - , - ); - } - - return nodes; - }, [ - canFilter, - footerActions, - filter, - filterResult?.isPending, - resultError, - isSearching, - language, - handleFilterKeyDown, - ]); - - const formattedBody = useFormatText({ text, language, pretty: pretty ?? false }); - if (formattedBody == null) { - return null; - } - - let body: string; - if (appliedFilter) { - if (resultError) { - body = ""; - } else { - body = filterResult?.data != null ? filterResult.data : ""; - } - } else { - body = formattedBody; - } - - // Decode unicode sequences in the text to readable characters - if (language === "json" && pretty) { - body = decodeUnicodeLiterals(body); - body = body.replace(/\\\//g, "/"); // Hide unnecessary escaping of '/' by some older frameworks - } +export const textViewerExtensions = [hyperlink]; +export function TextViewer( + props: Omit, +) { return ( -
- - {showBreadcrumbs && ( -
- - filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count)) - } - /> -
- )} -
+ ); } -/** Convert \uXXXX to actual Unicode characters */ -function decodeUnicodeLiterals(text: string): string { - return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { - const charCode = Number.parseInt(hex, 16); - return String.fromCharCode(charCode); - }); +export async function formatText( + text: string, + language: CodeEditorProps["language"], +): Promise { + if (language === "json") { + return tryFormatJson(text); + } + if (language === "xml" || language === "html") { + return tryFormatXml(text); + } + return text; } diff --git a/apps/yaak-client/hooks/useAuthTab.tsx b/apps/yaak-client/hooks/useAuthTab.tsx index e094b6e52..871a4d915 100644 --- a/apps/yaak-client/hooks/useAuthTab.tsx +++ b/apps/yaak-client/hooks/useAuthTab.tsx @@ -1,10 +1,9 @@ import type { Folder } from "@yaakapp-internal/models"; import { modelTypeLabel, patchModel } from "@yaakapp-internal/models"; -import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui"; +import { HStack, Icon, InlineCode, IconTooltip } from "@yaakapp-internal/ui"; import { useMemo } from "react"; import { openFolderSettings } from "../commands/openFolderSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; -import { IconTooltip } from "../components/core/IconTooltip"; import type { RadioDropdownProps } from "../components/core/RadioDropdown"; import type { TabItem } from "../components/core/Tabs/Tabs"; import { capitalize } from "../lib/capitalize"; diff --git a/apps/yaak-client/hooks/useCreateDropdownItems.tsx b/apps/yaak-client/hooks/useCreateDropdownItems.tsx index 795432c0d..f8c0cc0e8 100644 --- a/apps/yaak-client/hooks/useCreateDropdownItems.tsx +++ b/apps/yaak-client/hooks/useCreateDropdownItems.tsx @@ -6,10 +6,10 @@ import { createFolder } from "../commands/commands"; import type { DropdownItem } from "../components/core/Dropdown"; import { Icon } from "@yaakapp-internal/ui"; import { createRequestAndNavigate } from "../lib/createRequestAndNavigate"; -import { generateId } from "../lib/generateId"; import { BODY_TYPE_GRAPHQL } from "../lib/model_util"; import { activeRequestAtom } from "./useActiveRequest"; import { activeWorkspaceIdAtom } from "./useActiveWorkspace"; +import { generateId } from "@yaakapp-internal/lib/generateId"; export function useCreateDropdownItems({ hideFolder, diff --git a/apps/yaak-client/hooks/useDeleteSendHistory.tsx b/apps/yaak-client/hooks/useDeleteSendHistory.tsx index d64f886b3..c1d88457d 100644 --- a/apps/yaak-client/hooks/useDeleteSendHistory.tsx +++ b/apps/yaak-client/hooks/useDeleteSendHistory.tsx @@ -7,10 +7,10 @@ import { useAtomValue } from "jotai"; import { showAlert } from "../lib/alert"; import { showConfirmDelete } from "../lib/confirm"; import { jotaiStore } from "../lib/jotai"; -import { pluralizeCount } from "../lib/pluralize"; import { rpc } from "../lib/rpc"; import { activeWorkspaceIdAtom } from "./useActiveWorkspace"; import { useFastMutation } from "./useFastMutation"; +import { pluralizeCount } from "@yaakapp-internal/lib/pluralize"; export function useDeleteSendHistory() { const httpResponses = useAtomValue(httpResponsesAtom); diff --git a/apps/yaak-client/hooks/useHeadersTab.tsx b/apps/yaak-client/hooks/useHeadersTab.tsx index 70849f0a1..c7f495c40 100644 --- a/apps/yaak-client/hooks/useHeadersTab.tsx +++ b/apps/yaak-client/hooks/useHeadersTab.tsx @@ -1,8 +1,8 @@ import { useMemo } from "react"; -import { CountBadge } from "../components/core/CountBadge"; import type { TabItem } from "../components/core/Tabs/Tabs"; import type { HeaderModel } from "./useInheritedHeaders"; import { useInheritedHeaders } from "./useInheritedHeaders"; +import { CountBadge } from "@yaakapp-internal/ui"; export function useHeadersTab( tabValue: T, diff --git a/apps/yaak-client/hooks/useRandomKey.ts b/apps/yaak-client/hooks/useRandomKey.ts index a615088cb..9a73cf832 100644 --- a/apps/yaak-client/hooks/useRandomKey.ts +++ b/apps/yaak-client/hooks/useRandomKey.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import { generateId } from "../lib/generateId"; +import { generateId } from "@yaakapp-internal/lib/generateId"; export function useRandomKey(initialValue?: string) { const [value, setValue] = useState(initialValue ?? generateId()); diff --git a/apps/yaak-client/hooks/useRequestUpdateKey.ts b/apps/yaak-client/hooks/useRequestUpdateKey.ts index dd570125b..aef282561 100644 --- a/apps/yaak-client/hooks/useRequestUpdateKey.ts +++ b/apps/yaak-client/hooks/useRequestUpdateKey.ts @@ -1,8 +1,8 @@ import { platform } from "@yaakapp-internal/platform"; import type { ModelPayload } from "@yaakapp-internal/models"; import { atom, useAtomValue } from "jotai"; -import { generateId } from "../lib/generateId"; import { jotaiStore } from "../lib/jotai"; +import { generateId } from "@yaakapp-internal/lib/generateId"; const requestUpdateKeyAtom = atom>({}); diff --git a/apps/yaak-client/hooks/useResponseFilter.ts b/apps/yaak-client/hooks/useResponseFilter.ts deleted file mode 100644 index bc6cd004c..000000000 --- a/apps/yaak-client/hooks/useResponseFilter.ts +++ /dev/null @@ -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>({}); - -/** What's actually applied to the response. Only changes on an explicit apply */ -const useAppliedFilterMap = createGlobalState>({}); - -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; -}): 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, - }; -} diff --git a/apps/yaak-client/lib/contentType.ts b/apps/yaak-client/lib/contentType.ts index 2c198282f..262d7fc98 100644 --- a/apps/yaak-client/lib/contentType.ts +++ b/apps/yaak-client/lib/contentType.ts @@ -1,59 +1,4 @@ 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("([]); diff --git a/apps/yaak-client/lib/generateId.ts b/packages/common-lib/generateId.ts similarity index 100% rename from apps/yaak-client/lib/generateId.ts rename to packages/common-lib/generateId.ts diff --git a/apps/yaak-client/lib/pluralize.ts b/packages/common-lib/pluralize.ts similarity index 100% rename from apps/yaak-client/lib/pluralize.ts rename to packages/common-lib/pluralize.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index 283be298d..7018f7bfa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,6 +5,13 @@ "type": "module", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./editor": "./src/editor.ts" + }, + "sideEffects": [ + "*.css" + ], "dependencies": { "jotai-family": "^1.0.1" } diff --git a/packages/ui/src/components/CopyIconButton.tsx b/packages/ui/src/components/CopyIconButton.tsx new file mode 100644 index 000000000..54b37ce0f --- /dev/null +++ b/packages/ui/src/components/CopyIconButton.tsx @@ -0,0 +1,29 @@ +import { useTimedBoolean } from "../hooks/useTimedBoolean"; +import { IconButton, type IconButtonProps } from "./IconButton"; + +export interface CopyIconButtonProps extends Omit { + text: string | (() => Promise); + 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 ( + { + const content = typeof text === "function" ? await text() : text; + if (content == null) { + onCopyFailed?.(); + } else { + copy(content); + setCopied(); + } + }} + /> + ); +} diff --git a/apps/yaak-client/components/core/CountBadge.tsx b/packages/ui/src/components/CountBadge.tsx similarity index 100% rename from apps/yaak-client/components/core/CountBadge.tsx rename to packages/ui/src/components/CountBadge.tsx diff --git a/packages/ui/src/components/DetailsBanner.tsx b/packages/ui/src/components/DetailsBanner.tsx new file mode 100644 index 000000000..ce89e69dc --- /dev/null +++ b/packages/ui/src/components/DetailsBanner.tsx @@ -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 { + 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(storageKey || null, defaultOpen ?? false); + + const handleToggle = (e: React.SyntheticEvent) => { + if (storageKey) { + void setIsOpen(e.currentTarget.open); + } + }; + + return ( + +
+ +
+ {summary} +
+
{children}
+
+
+ ); +} diff --git a/packages/ui/src/components/HeadersTable.tsx b/packages/ui/src/components/HeadersTable.tsx new file mode 100644 index 000000000..4edfe856d --- /dev/null +++ b/packages/ui/src/components/HeadersTable.tsx @@ -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 ( + + {title} + + } + > + {sortedHeaders.length === 0 ? ( + No Headers + ) : ( + + {sortedHeaders.map((h, i) => ( + // oxlint-disable-next-line react/no-array-index-key + + {h.value} + + ))} + + )} + + ); +} diff --git a/apps/yaak-client/components/core/IconTooltip.tsx b/packages/ui/src/components/IconTooltip.tsx similarity index 90% rename from apps/yaak-client/components/core/IconTooltip.tsx rename to packages/ui/src/components/IconTooltip.tsx index 3886048e6..554a8366a 100644 --- a/apps/yaak-client/components/core/IconTooltip.tsx +++ b/packages/ui/src/components/IconTooltip.tsx @@ -1,4 +1,4 @@ -import { Icon, type IconProps } from "@yaakapp-internal/ui"; +import { Icon, type IconProps } from "./Icon"; import type { TooltipProps } from "./Tooltip"; import { Tooltip } from "./Tooltip"; diff --git a/packages/ui/src/components/Input.tsx b/packages/ui/src/components/Input.tsx new file mode 100644 index 000000000..537a3749c --- /dev/null +++ b/packages/ui/src/components/Input.tsx @@ -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 ; +} + +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(false, [forceUpdateKey]); + const editorRef = useRef(null); + const skipNextFocus = useRef(false); + + const handle = useMemo( + () => ({ + 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(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 ( +
+ + + {tint != null && ( +
+ )} + {leftSlot} + + {(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, + })} + + {type === "password" && !disableObscureToggle && ( + setObscured((o) => !o)} + /> + )} + {renderOptionsPicker?.(insertOption)} + {rightSlot} + +
+ ); +} + +function validateRequire(v: string) { + return v.length > 0; +} diff --git a/packages/ui/src/components/KeyValueRow.tsx b/packages/ui/src/components/KeyValueRow.tsx new file mode 100644 index 000000000..3f2d486a9 --- /dev/null +++ b/packages/ui/src/components/KeyValueRow.tsx @@ -0,0 +1,110 @@ +import classNames from "classnames"; +import type { HTMLAttributes, ReactElement, ReactNode } from "react"; +import { CopyIconButton } from "./CopyIconButton"; + +interface Props { + children: + | ReactElement> + | (ReactElement> | null)[]; + selectable?: boolean; +} + +export function KeyValueRows({ children, selectable }: Props) { + const childArray = Array.isArray(children) ? children.filter(Boolean) : [children]; + return ( + + + {childArray.map((child, i) => ( + // oxlint-disable-next-line react/no-array-index-key + {child} + ))} + +
+ ); +} + +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 ? ( + + ) : null); + + return ( + <> + + {label} + + +
+ {leftSlot ?? } + {children} + {resolvedRightSlot ? ( +
{resolvedRightSlot}
+ ) : ( + + )} +
+ + + ); +} diff --git a/apps/yaak-client/components/core/Label.tsx b/packages/ui/src/components/Label.tsx similarity index 100% rename from apps/yaak-client/components/core/Label.tsx rename to packages/ui/src/components/Label.tsx diff --git a/apps/yaak-client/components/core/Tooltip.tsx b/packages/ui/src/components/Tooltip.tsx similarity index 98% rename from apps/yaak-client/components/core/Tooltip.tsx rename to packages/ui/src/components/Tooltip.tsx index e2274b295..d32624cc2 100644 --- a/apps/yaak-client/components/core/Tooltip.tsx +++ b/packages/ui/src/components/Tooltip.tsx @@ -1,8 +1,8 @@ import classNames from "classnames"; import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; import { useRef, useState } from "react"; -import { generateId } from "../../lib/generateId"; -import { Portal } from "@yaakapp-internal/ui"; +import { generateId } from "@yaakapp-internal/lib/generateId"; +import { Portal } from "./Portal"; export interface TooltipProps { children: ReactNode; diff --git a/apps/yaak-client/components/core/Editor/Editor.css b/packages/ui/src/components/editor/CodeEditor.css similarity index 99% rename from apps/yaak-client/components/core/Editor/Editor.css rename to packages/ui/src/components/editor/CodeEditor.css index bc473ab78..14b2957fb 100644 --- a/apps/yaak-client/components/core/Editor/Editor.css +++ b/packages/ui/src/components/editor/CodeEditor.css @@ -1,4 +1,5 @@ -@reference "../../../main.css"; +@reference "tailwindcss"; +@reference "../../../../tailwind-config/index.css"; .cm-wrapper { @apply h-full overflow-hidden; diff --git a/packages/ui/src/components/editor/CodeEditor.tsx b/packages/ui/src/components/editor/CodeEditor.tsx new file mode 100644 index 000000000..3341864b7 --- /dev/null +++ b/packages/ui/src/components/editor/CodeEditor.tsx @@ -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 = { + 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; + 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 ; +} + +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(onChange); + useEffect(() => { + handleChange.current = onChange; + }, [onChange]); + + // Use ref so we can update the handler without re-initializing the editor + const handlePaste = useRef(onPaste); + useEffect(() => { + handlePaste.current = onPaste; + }, [onPaste]); + + // Use ref so we can update the handler without re-initializing the editor + const handlePasteOverwrite = useRef(onPasteOverwrite); + useEffect(() => { + handlePasteOverwrite.current = onPasteOverwrite; + }, [onPasteOverwrite]); + + // Use ref so we can update the handler without re-initializing the editor + const handleFocus = useRef(onFocus); + useEffect(() => { + handleFocus.current = onFocus; + }, [onFocus]); + + // Use ref so we can update the handler without re-initializing the editor + const handleBlur = useRef(onBlur); + useEffect(() => { + handleBlur.current = onBlur; + }, [onBlur]); + + // Use ref so we can update the handler without re-initializing the editor + const handleKeyDown = useRef(onKeyDown); + useEffect(() => { + handleKeyDown.current = onKeyDown; + }, [onKeyDown]); + + // Use ref so we can update the handler without re-initializing the editor + const handleKeyDownFirst = useRef(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( + { + 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 = ( +
+ ); + + if (singleLine || containerOnly) { + return cmContainer; + } + + return ( +
+ {cmContainer} + {decoratedActions && ( + + {decoratedActions} + + )} +
+ ); +} + +function getExtensions({ + stateKey, + container, + singleLine, + hideGutter, + onChange, + onPaste, + onPasteOverwrite, + onFocus, + onBlur, + onKeyDown, + keyDownHandler, +}: Pick & { + stateKey: CodeEditorProps["stateKey"]; + container: HTMLDivElement | null; + onChange: RefObject; + onPaste: RefObject; + onPasteOverwrite: RefObject; + onFocus: RefObject; + onBlur: RefObject; + onKeyDown: RefObject; + keyDownHandler: RefObject; +}) { + // TODO: Ensure tooltips render inside the dialog if we are in one. + const parent = + container?.closest('[role="dialog"]') ?? + document.querySelector("#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 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", "
") : " "; + 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>(); + +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, + }), + }); + } +} diff --git a/packages/ui/src/components/editor/LazyCodeEditor.tsx b/packages/ui/src/components/editor/LazyCodeEditor.tsx new file mode 100644 index 000000000..dbbf53f10 --- /dev/null +++ b/packages/ui/src/components/editor/LazyCodeEditor.tsx @@ -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 ( + + + + ); +} diff --git a/packages/ui/src/components/editor/extensions.ts b/packages/ui/src/components/editor/extensions.ts new file mode 100644 index 000000000..07c09b507 --- /dev/null +++ b/packages/ui/src/components/editor/extensions.ts @@ -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[0]) => { + return () => new LanguageSupport(StreamLanguage.define(mode)); +}; + +export type LanguageSupports = Record LanguageSupport)>; + +export const syntaxExtensions: Record 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]), +]; diff --git a/packages/ui/src/components/editor/json-lint.ts b/packages/ui/src/components/editor/json-lint.ts new file mode 100644 index 000000000..4f0cf9ab4 --- /dev/null +++ b/packages/ui/src/components/editor/json-lint.ts @@ -0,0 +1,58 @@ +import type { Diagnostic } from "@codemirror/lint"; +import type { EditorView } from "@codemirror/view"; +import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"; + +// jsonc-parser reports error codes, so these are the words the editor shows for them +const MESSAGES: Record = { + 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", +}; + +export interface JsonLintOptions { + allowComments?: boolean; + allowTrailingCommas?: boolean; + /** Rewrites the document before parsing, keeping every offset where it was */ + escape?: (doc: string) => string; +} + +export function jsonParseLinter(options?: JsonLintOptions) { + return (view: EditorView): Diagnostic[] => { + const doc = view.state.doc.toString(); + if (doc.trim() === "") return []; + + const escapedDoc = options?.escape?.(doc) ?? doc; + + 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", + }, + ]; + }; +} diff --git a/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts b/packages/ui/src/components/editor/json/breadcrumbExtension.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts rename to packages/ui/src/components/editor/json/breadcrumbExtension.ts diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts b/packages/ui/src/components/editor/json/jsonPath.test.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/json/jsonPath.test.ts rename to packages/ui/src/components/editor/json/jsonPath.test.ts diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.ts b/packages/ui/src/components/editor/json/jsonPath.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/json/jsonPath.ts rename to packages/ui/src/components/editor/json/jsonPath.ts diff --git a/apps/yaak-client/components/core/Editor/searchMatchCount.test.ts b/packages/ui/src/components/editor/searchMatchCount.test.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/searchMatchCount.test.ts rename to packages/ui/src/components/editor/searchMatchCount.test.ts diff --git a/apps/yaak-client/components/core/Editor/searchMatchCount.ts b/packages/ui/src/components/editor/searchMatchCount.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/searchMatchCount.ts rename to packages/ui/src/components/editor/searchMatchCount.ts diff --git a/apps/yaak-client/components/core/Editor/singleLine.ts b/packages/ui/src/components/editor/singleLine.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/singleLine.ts rename to packages/ui/src/components/editor/singleLine.ts diff --git a/apps/yaak-client/components/core/Editor/text/extension.ts b/packages/ui/src/components/editor/text/extension.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/text/extension.ts rename to packages/ui/src/components/editor/text/extension.ts diff --git a/apps/yaak-client/components/core/Editor/text/text.grammar b/packages/ui/src/components/editor/text/text.grammar similarity index 100% rename from apps/yaak-client/components/core/Editor/text/text.grammar rename to packages/ui/src/components/editor/text/text.grammar diff --git a/apps/yaak-client/components/core/Editor/text/text.terms.ts b/packages/ui/src/components/editor/text/text.terms.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/text/text.terms.ts rename to packages/ui/src/components/editor/text/text.terms.ts diff --git a/apps/yaak-client/components/core/Editor/text/text.ts b/packages/ui/src/components/editor/text/text.ts similarity index 100% rename from apps/yaak-client/components/core/Editor/text/text.ts rename to packages/ui/src/components/editor/text/text.ts diff --git a/packages/ui/src/components/tabs/Tabs.tsx b/packages/ui/src/components/tabs/Tabs.tsx new file mode 100644 index 000000000..046199855 --- /dev/null +++ b/packages/ui/src/components/tabs/Tabs.tsx @@ -0,0 +1,528 @@ +import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core"; +import { + closestCenter, + DndContext, + DragOverlay, + PointerSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import classNames from "classnames"; +import type { ComponentType, ReactNode, Ref } from "react"; +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { useInMemoryStoredValue, type UseStoredValue } from "../../hooks/useStoredValue"; +import { computeSideForDragMove } from "../../lib/dnd"; +import type { ButtonProps } from "../Button"; +import { Button } from "../Button"; +import { DropMarker } from "../DropMarker"; + +export interface TabButtonRenderProps { + buttonProps: Partial; + isActive: boolean; +} + +export type TabItem = + | { + value: string; + label: string; + hidden?: boolean; + leftSlot?: ReactNode; + rightSlot?: ReactNode; + } + | { + value: string; + /** Renders the tab's button, for a tab that is more than a label */ + renderButton: (props: TabButtonRenderProps) => ReactNode; + leftSlot?: ReactNode; + rightSlot?: ReactNode; + }; + +export interface TabsStorage { + order: string[]; + activeTabs: Record; +} + +export interface TabsRef { + /** Programmatically set the active tab */ + setActiveTab: (value: string) => void; +} + +export interface TabsProps { + 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; + /** Where the order and active tab under `storageKey` are kept */ + useStoredValue?: UseStoredValue; +} + +export const Tabs = forwardRef(function Tabs( + { + defaultValue, + onChangeValue: onChangeValueProp, + label, + children, + tabs: originalTabs, + className, + tabListClassName, + addBorders, + layout = "vertical", + storageKey, + activeTabKey, + useStoredValue = useInMemoryStoredValue, + }: TabsProps, + forwardedRef: Ref, +) { + const ref = useRef(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 [rawStorage, setStorage] = useStoredValue(storageKey ?? null, { + 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(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) => { + onChangeValue(value).catch(console.error); + }, + }), + [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(originalTabs); + const [isDragging, setIsDragging] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(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(); + + // 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("[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( +
+ +
, + ); + } + + items.push( + , + ); + }); + return items; + }, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]); + + const tabList = ( +
+
+ {tabButtons} + {hoveredIndex === tabs.length && ( +
+ +
+ )} +
+
+ ); + + return ( +
+ {reorderable ? ( + + {tabList} + + {isDragging && ( + + )} + + + ) : ( + tabList + )} + {children} +
+ ); +}); + +interface TabButtonProps { + tab: TabItem; + isActive: boolean; + addBorders?: boolean; + layout: "horizontal" | "vertical"; + reorderable: boolean; + isDragging: boolean; + onChangeValue?: (value: string) => void; + overlay?: boolean; +} + +function TabButton({ + tab, + isActive, + addBorders, + layout, + reorderable, + isDragging, + onChangeValue, + overlay = false, +}: TabButtonProps) { + const { + attributes, + listeners, + setNodeRef: setDraggableRef, + } = useDraggable({ + id: tab.value, + disabled: !reorderable, + // The button inside handles focus + attributes: { tabIndex: -1 }, + }); + const { setNodeRef: setDroppableRef } = useDroppable({ + id: tab.value, + disabled: !reorderable, + }); + + const handleSetWrapperRef = useCallback( + (n: HTMLDivElement | null) => { + if (reorderable) { + setDraggableRef(n); + setDroppableRef(n); + } + }, + [reorderable, setDraggableRef, setDroppableRef], + ); + + const btnProps: Partial = { + 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 = + "renderButton" in tab ? ( + tab.renderButton({ buttonProps: btnProps, isActive }) + ) : ( + + ); + + // Apply drag handlers to wrapper, not button + const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {}; + + return ( +
+ {buttonContent} +
+ ); +} + +export interface TabContentProps { + value: string; + children: ReactNode; + className?: string; + /** Catches errors thrown while rendering the tab, so they stay inside it */ + ErrorBoundary?: ComponentType<{ name: string; children: ReactNode }>; +} + +export const TabContent = memo(function TabContent({ + value, + children, + className, + ErrorBoundary, +}: TabContentProps) { + const content = ( +
+ {children} +
+ ); + return ErrorBoundary ? {content} : content; +}); diff --git a/packages/ui/src/components/tags/HttpMethodTag.tsx b/packages/ui/src/components/tags/HttpMethodTag.tsx new file mode 100644 index 000000000..d504a288a --- /dev/null +++ b/packages/ui/src/components/tags/HttpMethodTag.tsx @@ -0,0 +1,88 @@ +import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models"; +import classNames from "classnames"; +import { memo } from "react"; + +interface Props { + request: HttpRequest | GrpcRequest | WebsocketRequest; + className?: string; + short?: boolean; + noAlias?: boolean; + colored?: boolean; +} + +const methodNames: Record = { + 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, + colored, +}: 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 ; +}); + +export function HttpMethodTagRaw({ + className, + method, + short, + colored, +}: { + method: string; + className?: string; + short?: boolean; + colored?: boolean; +}) { + let label = method.toUpperCase(); + if (short) { + label = methodNames[method.toLowerCase()] ?? method.slice(0, 4); + label = label.padEnd(4, " "); + } + + const m = method.toUpperCase(); + + return ( + + {label} + + ); +} diff --git a/apps/yaak-client/components/core/HttpResponseDurationTag.tsx b/packages/ui/src/components/tags/HttpResponseDurationTag.tsx similarity index 100% rename from apps/yaak-client/components/core/HttpResponseDurationTag.tsx rename to packages/ui/src/components/tags/HttpResponseDurationTag.tsx diff --git a/apps/yaak-client/components/core/HttpStatusTag.tsx b/packages/ui/src/components/tags/HttpStatusTag.tsx similarity index 100% rename from apps/yaak-client/components/core/HttpStatusTag.tsx rename to packages/ui/src/components/tags/HttpStatusTag.tsx diff --git a/apps/yaak-client/components/core/SizeTag.tsx b/packages/ui/src/components/tags/SizeTag.tsx similarity index 100% rename from apps/yaak-client/components/core/SizeTag.tsx rename to packages/ui/src/components/tags/SizeTag.tsx diff --git a/apps/yaak-client/components/responseViewers/AudioViewer.tsx b/packages/ui/src/components/viewers/AudioViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/AudioViewer.tsx rename to packages/ui/src/components/viewers/AudioViewer.tsx diff --git a/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx b/packages/ui/src/components/viewers/CsvViewer.test.tsx similarity index 97% rename from apps/yaak-client/components/responseViewers/CsvViewer.test.tsx rename to packages/ui/src/components/viewers/CsvViewer.test.tsx index e3df58c4c..05dc52e0b 100644 --- a/apps/yaak-client/components/responseViewers/CsvViewer.test.tsx +++ b/packages/ui/src/components/viewers/CsvViewer.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, test, vi } from "vite-plus/test"; import { CsvViewerInner } from "./CsvViewer"; -vi.mock("@yaakapp-internal/ui", () => ({ +vi.mock("../Table", () => ({ Table: ({ children }: { children: ReactNode }) => {children}
, TableBody: ({ children }: { children: ReactNode }) => {children}, TableCell: ({ children }: { children: ReactNode }) => {children}, diff --git a/apps/yaak-client/components/responseViewers/CsvViewer.tsx b/packages/ui/src/components/viewers/CsvViewer.tsx similarity index 93% rename from apps/yaak-client/components/responseViewers/CsvViewer.tsx rename to packages/ui/src/components/viewers/CsvViewer.tsx index f318a4374..031c37315 100644 --- a/apps/yaak-client/components/responseViewers/CsvViewer.tsx +++ b/packages/ui/src/components/viewers/CsvViewer.tsx @@ -1,14 +1,7 @@ import classNames from "classnames"; import Papa from "papaparse"; import { useMemo } from "react"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, -} from "@yaakapp-internal/ui"; +import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "../Table"; interface Props { text: string | null; diff --git a/apps/yaak-client/components/responseViewers/ImageViewer.tsx b/packages/ui/src/components/viewers/ImageViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/ImageViewer.tsx rename to packages/ui/src/components/viewers/ImageViewer.tsx diff --git a/apps/yaak-client/components/core/JsonAttributeTree.tsx b/packages/ui/src/components/viewers/JsonAttributeTree.tsx similarity index 99% rename from apps/yaak-client/components/core/JsonAttributeTree.tsx rename to packages/ui/src/components/viewers/JsonAttributeTree.tsx index 58e6a4d4e..274ffdb4e 100644 --- a/apps/yaak-client/components/core/JsonAttributeTree.tsx +++ b/packages/ui/src/components/viewers/JsonAttributeTree.tsx @@ -1,7 +1,7 @@ import classNames from "classnames"; import type { ReactNode } from "react"; import { useMemo, useState } from "react"; -import { Icon } from "@yaakapp-internal/ui"; +import { Icon } from "../Icon"; interface Props { depth?: number; diff --git a/apps/yaak-client/components/responseViewers/JsonViewer.tsx b/packages/ui/src/components/viewers/JsonViewer.tsx similarity index 86% rename from apps/yaak-client/components/responseViewers/JsonViewer.tsx rename to packages/ui/src/components/viewers/JsonViewer.tsx index 3cd02837e..298ef01c6 100644 --- a/apps/yaak-client/components/responseViewers/JsonViewer.tsx +++ b/packages/ui/src/components/viewers/JsonViewer.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import { JsonAttributeTree } from "../core/JsonAttributeTree"; +import { JsonAttributeTree } from "./JsonAttributeTree"; interface Props { text: string; diff --git a/packages/ui/src/components/viewers/MultipartViewer.tsx b/packages/ui/src/components/viewers/MultipartViewer.tsx new file mode 100644 index 000000000..50a176537 --- /dev/null +++ b/packages/ui/src/components/viewers/MultipartViewer.tsx @@ -0,0 +1,151 @@ +import { type MultipartPart, parseMultipart } from "@mjackson/multipart-parser"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { languageFromContentType } from "../../lib/contentType"; +import { Banner } from "../Banner"; +import { Icon } from "../Icon"; +import { TabContent, type TabContentProps, Tabs } from "../tabs/Tabs"; +import { AudioViewer } from "./AudioViewer"; +import { CsvViewer } from "./CsvViewer"; +import { ImageViewer } from "./ImageViewer"; +import { SvgViewer } from "./SvgViewer"; +import { TextViewer, type TextViewerProps } from "./TextViewer"; +import { VideoViewer } from "./VideoViewer"; +import { WebPageViewer } from "./WebPageViewer"; + +export interface MultipartViewerProps { + data: Uint8Array; + boundary: string; + idPrefix?: string; + /** Renders a PDF part, which shows as text without it */ + renderPdf?: (data: Uint8Array) => ReactNode; + /** Passed to the viewer of each text part */ + textViewerProps?: Pick; + ErrorBoundary?: TabContentProps["ErrorBoundary"]; +} + +export function MultipartViewer({ + data, + boundary, + idPrefix = "multipart", + renderPdf, + textViewerProps, + ErrorBoundary, +}: MultipartViewerProps) { + 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 ( + + Failed to parse multipart data: {error} + + ); + } + + if (parts.length === 0) { + return ( + + No multipart parts found + + ); + } + + return ( + ({ + label: part.name ?? "", + value: tabValue(part, i), + rightSlot: + part.filename && part.headers.contentType.mediaType?.startsWith("image/") ? ( +
+ +
+ ) : part.filename ? ( + + ) : null, + }))} + > + {parts.map((part, i) => ( + + + + ))} +
+ ); +} + +function Part({ + part, + renderPdf, + textViewerProps, +}: { part: MultipartPart } & Pick) { + const mimeType = part.headers.contentType.mediaType ?? null; + const contentTypeHeader = part.headers.get("content-type"); + + const { uint8Array, content, detectedLanguage } = useMemo(() => { + const uint8Array = new Uint8Array(part.arrayBuffer); + const content = new TextDecoder().decode(part.arrayBuffer); + const detectedLanguage = languageFromContentType(contentTypeHeader, content); + return { uint8Array, content, detectedLanguage }; + }, [part, contentTypeHeader]); + + if (mimeType?.match(/^image\/svg/i)) { + return ; + } + + if (mimeType?.match(/^image/i)) { + return ; + } + + if (mimeType?.match(/^audio/i)) { + return ; + } + + if (mimeType?.match(/^video/i)) { + return ; + } + + if (mimeType?.match(/csv|tab-separated/i)) { + return ; + } + + if (mimeType?.match(/^text\/html/i) || detectedLanguage === "html") { + return ; + } + + if (mimeType?.match(/pdf/i) && renderPdf != null) { + return renderPdf(uint8Array); + } + + return ( + + ); +} + +function tabValue(part: MultipartPart, i: number) { + return `${part.name ?? ""}::${i}`; +} diff --git a/apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx b/packages/ui/src/components/viewers/ResponseBreadcrumbBar.tsx similarity index 96% rename from apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx rename to packages/ui/src/components/viewers/ResponseBreadcrumbBar.tsx index ba03595f4..02840d2d0 100644 --- a/apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx +++ b/packages/ui/src/components/viewers/ResponseBreadcrumbBar.tsx @@ -1,9 +1,9 @@ -import { Icon } from "@yaakapp-internal/ui"; +import { Icon } from "../Icon"; import classNames from "classnames"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import type { JsonPathSegment } from "../core/Editor/json/jsonPath"; -import { IconButton } from "../core/IconButton"; -import { Tooltip } from "../core/Tooltip"; +import type { JsonPathSegment } from "../editor/json/jsonPath"; +import { IconButton } from "../IconButton"; +import { Tooltip } from "../Tooltip"; interface Props { segments: JsonPathSegment[]; diff --git a/apps/yaak-client/components/responseViewers/SvgViewer.tsx b/packages/ui/src/components/viewers/SvgViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/SvgViewer.tsx rename to packages/ui/src/components/viewers/SvgViewer.tsx diff --git a/packages/ui/src/components/viewers/TextViewer.tsx b/packages/ui/src/components/viewers/TextViewer.tsx new file mode 100644 index 000000000..6e25defe9 --- /dev/null +++ b/packages/ui/src/components/viewers/TextViewer.tsx @@ -0,0 +1,262 @@ +import type { Extension } from "@codemirror/state"; +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { Children, useCallback, useMemo, useState } from "react"; +import { type TextFilterApi, useTextFilter } from "../../hooks/useTextFilter"; +import { docFingerprint } from "../../lib/docFingerprint"; +import type { CodeEditorProps } from "../editor/CodeEditor"; +import { jsonBreadcrumbExtension } from "../editor/json/breadcrumbExtension"; +import type { JsonPathSegment } from "../editor/json/jsonPath"; +import { jsonPathToSegments, segmentsToJsonPath } from "../editor/json/jsonPath"; +import { LazyCodeEditor } from "../editor/LazyCodeEditor"; +import { IconButton } from "../IconButton"; +import { Input } from "../Input"; +import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar"; + +export interface TextViewerProps { + text: string; + language: CodeEditorProps["language"]; + stateKey: string | null; + pretty?: boolean; + className?: string; + footerActions?: ReactNode; + extensions?: Extension[]; + /** Pretty-prints the text when `pretty` is set */ + formatText?: (text: string, language: CodeEditorProps["language"]) => Promise; + /** Evaluates a JSONPath or XPath expression, rejecting when it's invalid. Enables the filter box */ + filterText?: (expression: string) => Promise; + /** Where the filter's state is kept, when it outlives the text (defaults to `stateKey`) */ + filterStateKey?: string | null; + /** Called once the filter has accepted an applied expression */ + onFilterApplied?: (expression: string) => void; + /** Rendered at the start of the filter box */ + filterLeftSlot?: (filter: TextFilterApi) => ReactNode; + /** Renders the editors, for a host whose editor builds on CodeEditor */ + renderEditor?: (props: CodeEditorProps) => ReactNode; +} + +export function TextViewer({ + language, + text, + stateKey, + pretty, + className, + footerActions, + extensions, + formatText, + filterText, + filterStateKey = stateKey, + onFilterApplied, + filterLeftSlot, + renderEditor = renderCodeEditor, +}: TextViewerProps) { + // 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([]); + const handleBreadcrumbUpdate = useCallback( + ({ segments }: { segments: JsonPathSegment[] | null }) => + setBreadcrumbSegments((prev) => { + const next = segments ?? []; + return segmentsToJsonPath(prev) === segmentsToJsonPath(next) ? prev : next; + }), + [], + ); + const extraExtensions = useMemo( + () => + language === "json" + ? [...(extensions ?? []), jsonBreadcrumbExtension(handleBreadcrumbUpdate)] + : (extensions ?? []), + [language, extensions, handleBreadcrumbUpdate], + ); + + const filter = useTextFilter({ + stateKey: filterStateKey, + runFilter: filterText ?? rejectFilter, + onApplied: onFilterApplied, + }); + const hasFilter = filterText != null; + + // Exact, so two same-length bodies never share a cached filter result + const textFingerprint = useMemo(() => docFingerprint(text, { exact: true }), [text]); + const filterResult = useQuery({ + placeholderData: (prev) => prev, // Keep previous data on refetch + queryKey: [ + "text_viewer_filter", + filterStateKey, + stateKey, + textFingerprint, + filter.appliedFilter, + ], + queryFn: () => (filterText ?? rejectFilter)(filter.appliedFilter ?? ""), + enabled: hasFilter && filter.appliedFilter != null, + }); + + const canFilter = hasFilter && (language === "json" || language === "xml" || language === "html"); + const { isSearching, appliedFilter } = filter; + const resultError = !!filterResult.error; + + // 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 = + hasFilter && (appliedFilter != null || (language === "json" && breadcrumbSegments.length > 0)); + + const handleFilterKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + filter.toggleSearch(); + } else if (e.key === "Enter" && filter.filterText != null) { + filter.applyFilter(filter.filterText); + } + }, + [filter], + ); + + const actions = useMemo(() => { + const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions); + + if (!canFilter) return nodes; + + if (isSearching) { + nodes.push( +
+ + +
+ } + /> +
, + ); + } else { + nodes.push( + , + ); + } + + return nodes; + }, [ + canFilter, + footerActions, + filter, + filterResult.isPending, + filterResult.isFetching, + filterLeftSlot, + renderEditor, + resultError, + isSearching, + language, + handleFilterKeyDown, + ]); + + const formattedBody = useQuery({ + placeholderData: (prev) => prev, // Keep previous data on refetch + queryKey: ["text_viewer_format", text, language, pretty ?? false], + queryFn: async () => { + if (text === "" || !pretty || formatText == null) { + return text; + } + return formatText(text, language); + }, + }).data; + 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 + } + + return ( +
+ {renderEditor({ + readOnly: true, + className, + defaultValue: body, + language, + actions, + extensions: extraExtensions, + stateKey, + })} + {showBreadcrumbs && ( +
+ + filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count)) + } + /> +
+ )} +
+ ); +} + +function renderCodeEditor(props: CodeEditorProps) { + return ; +} + +function rejectFilter(): Promise { + return Promise.reject(new Error("No filter")); +} + +/** Convert \uXXXX to actual Unicode characters */ +function decodeUnicodeLiterals(text: string): string { + return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { + const charCode = Number.parseInt(hex, 16); + return String.fromCharCode(charCode); + }); +} diff --git a/apps/yaak-client/components/responseViewers/VideoViewer.tsx b/packages/ui/src/components/viewers/VideoViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/VideoViewer.tsx rename to packages/ui/src/components/viewers/VideoViewer.tsx diff --git a/apps/yaak-client/components/responseViewers/WebPageViewer.tsx b/packages/ui/src/components/viewers/WebPageViewer.tsx similarity index 100% rename from apps/yaak-client/components/responseViewers/WebPageViewer.tsx rename to packages/ui/src/components/viewers/WebPageViewer.tsx diff --git a/packages/ui/src/editor.ts b/packages/ui/src/editor.ts new file mode 100644 index 000000000..d49dc84a9 --- /dev/null +++ b/packages/ui/src/editor.ts @@ -0,0 +1,15 @@ +export type { CodeEditorProps } from "./components/editor/CodeEditor"; +export { CodeEditor } from "./components/editor/CodeEditor"; +export type { LanguageSupports } from "./components/editor/extensions"; +export { + closeBracketsExtensions, + getLanguageExtension, + readonlyExtensions, + syntaxExtensions, + syntaxHighlightStyle, +} from "./components/editor/extensions"; +export type { JsonLintOptions } from "./components/editor/json-lint"; +export { jsonParseLinter } from "./components/editor/json-lint"; +export { searchMatchCount } from "./components/editor/searchMatchCount"; +export { singleLineExtensions } from "./components/editor/singleLine"; +export { text, textLanguage } from "./components/editor/text/extension"; diff --git a/apps/yaak-client/hooks/useStateWithDeps.ts b/packages/ui/src/hooks/useStateWithDeps.ts similarity index 100% rename from apps/yaak-client/hooks/useStateWithDeps.ts rename to packages/ui/src/hooks/useStateWithDeps.ts diff --git a/packages/ui/src/hooks/useStoredValue.ts b/packages/ui/src/hooks/useStoredValue.ts new file mode 100644 index 000000000..10906066b --- /dev/null +++ b/packages/ui/src/hooks/useStoredValue.ts @@ -0,0 +1,42 @@ +import { atom, useAtom } from "jotai"; +import { atomFamily } from "jotai-family"; +import { useCallback, useState } from "react"; + +export type StoredValue = object | boolean | number | string | null; + +export type SetStoredValue = (value: T | ((prev: T) => T)) => void | Promise; + +/** + * Reads and writes a value kept under `key`, falling back to `fallback` until one is written. A + * `null` key keeps the value for the component's lifetime only. Components that remember things + * take one of these, so each host decides where the memory lives. + */ +export type UseStoredValue = ( + key: string | string[] | null, + fallback: T, +) => readonly [T, SetStoredValue]; + +const memoryAtoms = atomFamily((_key: string) => atom<{ value: unknown } | null>(null)); + +/** The default {@link UseStoredValue}: kept in memory for the session */ +export function useInMemoryStoredValue( + key: string | string[] | null, + fallback: T, +): readonly [T, SetStoredValue] { + const memoryKey = key == null ? null : Array.isArray(key) ? key.join("::") : key; + const [local, setLocal] = useState(fallback); + const [stored, setStored] = useAtom(memoryAtoms(memoryKey ?? "")); + + const storedValue = stored == null ? fallback : (stored.value as T); + const setStoredValue = useCallback>( + (update) => + setStored((prev) => { + const prevValue = prev == null ? fallback : (prev.value as T); + const value = typeof update === "function" ? (update as (p: T) => T)(prevValue) : update; + return { value }; + }), + [fallback, setStored], + ); + + return memoryKey == null ? [local, setLocal] : [storedValue, setStoredValue]; +} diff --git a/packages/ui/src/hooks/useTextFilter.ts b/packages/ui/src/hooks/useTextFilter.ts new file mode 100644 index 000000000..e8cccc6ad --- /dev/null +++ b/packages/ui/src/hooks/useTextFilter.ts @@ -0,0 +1,114 @@ +import { atom, useAtom } from "jotai"; +import { atomFamily } from "jotai-family"; +import { useCallback, useState } from "react"; + +/** What's typed in the filter box. `null` means the filter box is closed */ +const filterTextAtoms = atomFamily((_stateKey: string) => atom(null)); + +/** What's actually applied to the text. Only changes on an explicit apply */ +const appliedFilterAtoms = atomFamily((_stateKey: string) => atom(null)); + +export interface TextFilterApi { + stateKey: string | null; + /** Draft text in the filter box, or `null` when the box is closed */ + filterText: string | null; + /** The expression currently filtering the text */ + 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 text, reporting it to `onApplied` 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; +} + +/** + * Draft/applied state for a text filter (JSONPath/XPath). + * + * `onApplied` hears at most once per apply gesture, and only after `runFilter` + * confirms the filter accepts the expression — the filter is the sole judge of + * validity. Because nothing but the gesture reports, refetches can't resurrect + * deleted history entries and a gesture can't report into another state key. + */ +export function useTextFilter({ + stateKey, + runFilter, + onApplied, +}: { + stateKey: string | null; + /** Evaluate an expression, rejecting if the filter reports an error */ + runFilter: (filter: string) => Promise; + onApplied?: (filter: string) => void; +}): TextFilterApi { + const [storedFilterText, setStoredFilterText] = useAtom(filterTextAtoms(stateKey ?? "")); + const [storedAppliedFilter, setStoredAppliedFilter] = useAtom(appliedFilterAtoms(stateKey ?? "")); + const filterText = stateKey ? storedFilterText : null; + const appliedFilter = stateKey ? storedAppliedFilter : null; + + const setFilterText = useCallback( + (v: string | null) => { + if (!stateKey) return; + setStoredFilterText(v); + }, + [stateKey, setStoredFilterText], + ); + + const setAppliedFilter = useCallback( + (v: string | null) => { + if (!stateKey) return; + setStoredAppliedFilter(v); + }, + [stateKey, setStoredAppliedFilter], + ); + + const applyFilter = useCallback( + (value: string) => { + setFilterText(value); + const applied = value.trim() === "" ? null : value.trim(); + setAppliedFilter(applied); + if (applied == null) return; + runFilter(applied).then( + () => onApplied?.(applied), + () => {}, // Rejected by the filter — don't report + ); + }, + [setFilterText, setAppliedFilter, runFilter, onApplied], + ); + + 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, + }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 338c4dce9..a8f460a9c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,15 +2,29 @@ export type { BannerProps } from "./components/Banner"; export { Banner } from "./components/Banner"; export type { ButtonProps } from "./components/Button"; export { Button } from "./components/Button"; +export type { CopyIconButtonProps } from "./components/CopyIconButton"; +export { CopyIconButton } from "./components/CopyIconButton"; +export { CountBadge } from "./components/CountBadge"; +export type { DetailsBannerProps } from "./components/DetailsBanner"; +export { DetailsBanner } from "./components/DetailsBanner"; export { DropMarker } from "./components/DropMarker"; +export { LazyCodeEditor } from "./components/editor/LazyCodeEditor"; +export { FormattedError } from "./components/FormattedError"; export { HeaderSize } from "./components/HeaderSize"; +export type { HeadersTableProps } from "./components/HeadersTable"; +export { HeadersTable } from "./components/HeadersTable"; export { Heading } from "./components/Heading"; export type { IconProps } from "./components/Icon"; export { Icon } from "./components/Icon"; export type { IconButtonProps } from "./components/IconButton"; export { IconButton } from "./components/IconButton"; -export { FormattedError } from "./components/FormattedError"; +export { IconTooltip } from "./components/IconTooltip"; export { InlineCode } from "./components/InlineCode"; +export type { InputHandle, InputProps } from "./components/Input"; +export { Input } from "./components/Input"; +export type { KeyValueRowProps } from "./components/KeyValueRow"; +export { KeyValueRow, KeyValueRows } from "./components/KeyValueRow"; +export { Label } from "./components/Label"; export { LoadingIcon } from "./components/LoadingIcon"; export { Overlay } from "./components/Overlay"; export { Portal } from "./components/Portal"; @@ -30,19 +44,53 @@ export { TableRow, TruncatedWideTableCell, } from "./components/Table"; +export type { + TabButtonRenderProps, + TabContentProps, + TabItem, + TabsProps, + TabsRef, + TabsStorage, +} from "./components/tabs/Tabs"; +export { TabContent, Tabs } from "./components/tabs/Tabs"; +export { HttpMethodTag, HttpMethodTagRaw } from "./components/tags/HttpMethodTag"; +export { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag"; +export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag"; +export { SizeTag } from "./components/tags/SizeTag"; +export type { TooltipProps } from "./components/Tooltip"; +export { Tooltip } from "./components/Tooltip"; export { isSelectedFamily, selectedIdsFamily } from "./components/tree/atoms"; export type { TreeNode } from "./components/tree/common"; export type { TreeHandle, TreeProps } from "./components/tree/Tree"; export { Tree } from "./components/tree/Tree"; export type { TreeItemProps } from "./components/tree/TreeItem"; +export { AudioViewer } from "./components/viewers/AudioViewer"; +export { CsvViewer, CsvViewerInner } from "./components/viewers/CsvViewer"; +export { ImageViewer } from "./components/viewers/ImageViewer"; +export { JsonAttributeTree } from "./components/viewers/JsonAttributeTree"; +export { JsonViewer } from "./components/viewers/JsonViewer"; +export type { MultipartViewerProps } from "./components/viewers/MultipartViewer"; +export { MultipartViewer } from "./components/viewers/MultipartViewer"; +export { SvgViewer } from "./components/viewers/SvgViewer"; +export type { TextViewerProps } from "./components/viewers/TextViewer"; +export { TextViewer } from "./components/viewers/TextViewer"; +export { VideoViewer } from "./components/viewers/VideoViewer"; +export { WebPageViewer } from "./components/viewers/WebPageViewer"; export { WindowControls } from "./components/WindowControls"; export { useContainerSize } from "./hooks/useContainerSize"; export { useDebouncedState } from "./hooks/useDebouncedState"; export { useDebouncedValue } from "./hooks/useDebouncedValue"; export { useIsFullscreen } from "./hooks/useIsFullscreen"; export { usePortal } from "./hooks/usePortal"; +export { useStateWithDeps } from "./hooks/useStateWithDeps"; +export type { SetStoredValue, StoredValue, UseStoredValue } from "./hooks/useStoredValue"; +export { useInMemoryStoredValue } from "./hooks/useStoredValue"; +export type { TextFilterApi } from "./hooks/useTextFilter"; +export { useTextFilter } from "./hooks/useTextFilter"; export { useTimedBoolean } from "./hooks/useTimedBoolean"; export { clamp } from "./lib/clamp"; export { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from "./lib/constants"; +export { languageFromContent, languageFromContentType } from "./lib/contentType"; export { computeSideForDragMove } from "./lib/dnd"; +export { docFingerprint } from "./lib/docFingerprint"; export { minPromiseMillis } from "./lib/minPromiseMillis"; diff --git a/packages/ui/src/lib/contentType.ts b/packages/ui/src/lib/contentType.ts new file mode 100644 index 000000000..6b01b0483 --- /dev/null +++ b/packages/ui/src/lib/contentType.ts @@ -0,0 +1,55 @@ +import type { EditorLanguage } from "@yaakapp-internal/plugins"; + +export function languageFromContentType( + contentType: string | null, + content: string | null = null, +): EditorLanguage | undefined { + 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?: EditorLanguage, +): EditorLanguage | undefined { + if (content == null) return "text"; + + const firstBytes = content.slice(0, 20).trim(); + + if (firstBytes.startsWith("{") || firstBytes.startsWith("[")) { + return "json"; + } + if ( + firstBytes.toLowerCase().startsWith("